diff options
Diffstat (limited to 'app')
1760 files changed, 17086 insertions, 14995 deletions
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png Binary files differdeleted file mode 100644 index b562e09c20f..00000000000 --- a/app/assets/images/auth_buttons/salesforce_64.png +++ /dev/null diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue index 85b3c994e02..9a7296b6b1f 100644 --- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -33,7 +33,7 @@ export default { emptyField: __('Never'), expired: __('Expired'), modalMessage: __( - 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', + 'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.', ), revokeButton: __('Revoke'), tokenValidity: __('Token valid until revoked'), @@ -72,11 +72,6 @@ export default { return FIELDS.filter(({ key }) => !ignoredFields.includes(key)); }, - modalMessage() { - return sprintf(this.$options.i18n.modalMessage, { - accessTokenType: this.accessTokenType, - }); - }, showPagination() { return this.activeAccessTokens.length > PAGE_SIZE; }, @@ -87,6 +82,12 @@ export default { this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true }); this.currentPage = INITIAL_PAGE; }, + modalMessage(tokenName) { + return sprintf(this.$options.i18n.modalMessage, { + accessTokenType: this.accessTokenType, + tokenName, + }); + }, sortingChanged(aRow, bRow, key) { if (['createdAt', 'lastUsedAt', 'expiresAt'].includes(key)) { // Transform `null` value to the latest possible date @@ -149,13 +150,13 @@ export default { }}</span> </template> - <template #cell(action)="{ item: { revokePath } }"> + <template #cell(action)="{ item: { name, revokePath } }"> <gl-button v-if="revokePath" category="tertiary" :title="$options.i18n.revokeButton" :aria-label="$options.i18n.revokeButton" - :data-confirm="modalMessage" + :data-confirm="modalMessage(name)" data-confirm-btn-variant="danger" data-qa-selector="revoke_button" data-method="put" diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue index c1ec46cfc50..9bcdcec8b78 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -267,7 +267,8 @@ export default { }); } }); - } else if (this.uniqueCommits.length > 0) { + } + if (this.uniqueCommits.length > 0) { return this.createContextCommits({ commits: this.uniqueCommits, forceReload: true }); } diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue index 1490d7e64f5..3c46de7c2be 100644 --- a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue @@ -1,9 +1,12 @@ <script> import { GlAlert } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReportHeader from './report_header.vue'; import UserDetails from './user_details.vue'; +import ReportDetails from './report_details.vue'; import ReportedContent from './reported_content.vue'; -import HistoryItems from './history_items.vue'; +import ActivityEventsList from './activity_events_list.vue'; +import ActivityHistoryItem from './activity_history_item.vue'; const alertDefaults = { visible: false, @@ -17,9 +20,12 @@ export default { GlAlert, ReportHeader, UserDetails, + ReportDetails, ReportedContent, - HistoryItems, + ActivityEventsList, + ActivityHistoryItem, }, + mixins: [glFeatureFlagsMixin()], props: { abuseReport: { type: Object, @@ -31,6 +37,11 @@ export default { alert: { ...alertDefaults }, }; }, + computed: { + similarOpenReports() { + return this.abuseReport.user?.similarOpenReports || []; + }, + }, methods: { showAlert(variant, message) { this.alert.visible = true; @@ -49,6 +60,7 @@ export default { <gl-alert v-if="alert.visible" :variant="alert.variant" class="gl-mt-4" @dismiss="closeAlert">{{ alert.message }}</gl-alert> + <report-header v-if="abuseReport.user" :user="abuseReport.user" @@ -56,7 +68,33 @@ export default { @showAlert="showAlert" /> <user-details v-if="abuseReport.user" :user="abuseReport.user" /> - <reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" /> - <history-items :report="abuseReport.report" :reporter="abuseReport.reporter" /> + + <report-details + v-if="glFeatures.abuseReportLabels" + :report-id="abuseReport.report.globalId" + class="gl-mt-6" + /> + + <reported-content :report="abuseReport.report" data-testid="reported-content" /> + + <div + v-for="report in similarOpenReports" + :key="report.id" + data-testid="reported-content-similar-open-reports" + > + <reported-content :report="report" /> + </div> + + <activity-events-list> + <template #history-items> + <activity-history-item :report="abuseReport.report" data-testid="activity" /> + <activity-history-item + v-for="report in similarOpenReports" + :key="report.id" + :report="report" + data-testid="activity-similar-open-reports" + /> + </template> + </activity-events-list> </section> </template> diff --git a/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue b/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue new file mode 100644 index 00000000000..8c4c1da28b8 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue @@ -0,0 +1,19 @@ +<script> +import { HISTORY_ITEMS_I18N } from '../constants'; + +export default { + name: 'ActivityEventsList', + i18n: HISTORY_ITEMS_I18N, +}; +</script> + +<template> + <!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below + are declared in app/assets/stylesheets/pages/notes.scss --> + <section class="gl-pt-6 issuable-discussion"> + <h2 class="gl-font-lg gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2> + <ul class="timeline main-notes-list notes"> + <slot name="history-items"></slot> + </ul> + </section> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue b/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue new file mode 100644 index 00000000000..5962203c382 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue @@ -0,0 +1,42 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import { HISTORY_ITEMS_I18N } from '../constants'; + +export default { + name: 'ActivityHistoryItem', + components: { + GlSprintf, + TimeAgoTooltip, + HistoryItem, + }, + props: { + report: { + type: Object, + required: true, + }, + }, + computed: { + reporter() { + return this.report.reporter; + }, + reporterName() { + return this.reporter?.name || this.$options.i18n.deletedReporter; + }, + }, + i18n: HISTORY_ITEMS_I18N, +}; +</script> + +<template> + <history-item icon="warning"> + <div class="gl-display-flex gl-xs-flex-direction-column"> + <gl-sprintf :message="$options.i18n.reportedByForCategory"> + <template #name>{{ reporterName }}</template> + <template #category>{{ report.category }}</template> + </gl-sprintf> + <time-ago-tooltip :time="report.reportedAt" class="gl-text-secondary gl-sm-ml-3" /> + </div> + </history-item> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report.query.graphql b/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report.query.graphql new file mode 100644 index 00000000000..f5b075cb9af --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report.query.graphql @@ -0,0 +1,13 @@ +query abuseReportQuery($id: AbuseReportID!) { + abuseReport(id: $id) { + labels { + nodes { + id + title + description + color + textColor + } + } + } +} diff --git a/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql b/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql new file mode 100644 index 00000000000..4e724b4db2c --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql @@ -0,0 +1,11 @@ +query abuseReportLabelsQuery($searchTerm: String) { + labels: abuseReportLabels(searchTerm: $searchTerm) { + nodes { + id + title + description + color + textColor + } + } +} diff --git a/app/assets/javascripts/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql b/app/assets/javascripts/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql new file mode 100644 index 00000000000..0781b8e634b --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql @@ -0,0 +1,10 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +mutation createAbuseReportLabel($title: String!, $color: String) { + labelCreate: abuseReportLabelCreate(input: { title: $title, color: $color }) { + label { + ...Label + } + errors + } +} diff --git a/app/assets/javascripts/admin/abuse_report/components/history_items.vue b/app/assets/javascripts/admin/abuse_report/components/history_items.vue deleted file mode 100644 index 28b66db84a2..00000000000 --- a/app/assets/javascripts/admin/abuse_report/components/history_items.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -import { GlSprintf } from '@gitlab/ui'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; -import { HISTORY_ITEMS_I18N } from '../constants'; - -export default { - name: 'HistoryItems', - components: { - GlSprintf, - TimeAgoTooltip, - HistoryItem, - }, - props: { - report: { - type: Object, - required: true, - }, - reporter: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - reporterName() { - return this.reporter?.name || this.$options.i18n.deletedReporter; - }, - }, - i18n: HISTORY_ITEMS_I18N, -}; -</script> - -<template> - <!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below - are declared in app/assets/stylesheets/pages/notes.scss --> - <section class="gl-pt-6 issuable-discussion"> - <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2> - <ul class="timeline main-notes-list notes"> - <history-item icon="warning"> - <div class="gl-display-flex gl-xs-flex-direction-column"> - <gl-sprintf :message="$options.i18n.reportedByForCategory"> - <template #name>{{ reporterName }}</template> - <template #category>{{ report.category }}</template> - </gl-sprintf> - <time-ago-tooltip :time="report.reportedAt" class="gl-text-secondary gl-sm-ml-3" /> - </div> - </history-item> - </ul> - </section> -</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/labels_select.vue b/app/assets/javascripts/admin/abuse_report/components/labels_select.vue new file mode 100644 index 00000000000..747c9a1a947 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/labels_select.vue @@ -0,0 +1,235 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { __, s__, sprintf } from '~/locale'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; +import DropdownHeader from '~/sidebar/components/labels/labels_select_widget/dropdown_header.vue'; +import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; +import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; +import abuseReportLabelsQuery from './graphql/abuse_report_labels.query.graphql'; + +export default { + components: { + DropdownWidget, + GlButton, + GlLoadingIcon, + LabelItem, + DropdownValue, + DropdownContentsCreateView, + DropdownHeader, + DropdownFooter, + }, + inject: ['updatePath', 'listPath'], + props: { + report: { + type: Object, + required: true, + }, + }, + data() { + return { + search: '', + labels: [], + selected: this.report.labels, + initialLoading: true, + isEditing: false, + isUpdating: false, + showCreateView: false, + }; + }, + apollo: { + labels: { + query() { + return abuseReportLabelsQuery; + }, + variables() { + return { searchTerm: this.search }; + }, + skip() { + return !this.isEditing; + }, + update(data) { + return data.labels?.nodes; + }, + error() { + createAlert({ message: this.$options.i18n.searchError }); + }, + }, + }, + computed: { + isLabelsEmpty() { + return this.selected.length === 0; + }, + selectedLabelIds() { + return this.selected.map((label) => label.id); + }, + isLoading() { + return this.$apollo.queries.labels.loading; + }, + selectText() { + if (!this.selected.length) { + return this.$options.i18n.labelsListTitle; + } + if (this.selected.length > 1) { + return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { + firstLabelName: this.selected[0].title, + remainingLabelCount: this.selected.length - 1, + }); + } + return this.selected[0].title; + }, + }, + watch: { + report({ labels }) { + this.selected = labels; + this.initialLoading = false; + }, + }, + created() { + const setSearch = (search) => { + this.search = search; + }; + this.debouncedSetSearch = debounce(setSearch, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + toggleEdit() { + return this.isEditing ? this.hideDropdown() : this.showDropdown(); + }, + showDropdown() { + this.isEditing = true; + this.$refs.editDropdown.showDropdown(); + }, + hideDropdown() { + this.saveSelectedLabels(); + this.isEditing = false; + }, + saveSelectedLabels() { + this.isUpdating = true; + + axios + .put(this.updatePath, { label_ids: this.selectedLabelIds }) + .catch((error) => { + createAlert({ + message: __('An error occurred while updating labels.'), + captureError: true, + error, + }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + isLabelSelected(label) { + return this.selectedLabelIds.includes(label.id); + }, + filterSelected(id) { + return this.selected.filter(({ id: labelId }) => labelId !== id); + }, + toggleLabelSelection(label) { + this.selected = this.isLabelSelected(label) + ? this.filterSelected(label.id) + : [...this.selected, label]; + }, + removeLabel(labelId) { + this.selected = this.filterSelected(labelId); + this.saveSelectedLabels(); + }, + toggleCreateView() { + this.showCreateView = !this.showCreateView; + }, + onLabelCreated(label) { + this.toggleLabelSelection(label); + this.toggleCreateView(); + }, + }, + i18n: { + label: __('Labels'), + noLabels: __('None'), + labelsListTitle: __('Assign labels'), + searchError: __('An error occurred while searching for labels, please try again.'), + edit: __('Edit'), + }, +}; +</script> +<template> + <div class="labels-select-wrapper"> + <div class="gl-display-flex gl-align-items-center gl-gap-3 gl-mb-2"> + <span>{{ $options.i18n.label }}</span> + <gl-loading-icon v-if="initialLoading" size="sm" inline class="gl-ml-2" /> + <gl-button + category="tertiary" + size="small" + :disabled="isUpdating || initialLoading" + class="edit-link gl-ml-auto" + @click="toggleEdit" + > + {{ $options.i18n.edit }} + </gl-button> + </div> + <div class="gl-text-gray-500 gl-mb-2" data-testid="selected-labels"> + <template v-if="isLabelsEmpty">{{ $options.i18n.noLabels }}</template> + <dropdown-value + v-else + :disable-labels="isLoading" + :selected-labels="selected" + :allow-label-remove="!isUpdating" + :labels-filter-base-path="listPath" + :labels-filter-param="'label_name'" + @onLabelRemove="removeLabel" + /> + </div> + + <dropdown-widget + v-show="isEditing" + ref="editDropdown" + :select-text="selectText" + :options="labels" + :is-loading="isLoading" + :selected="selected" + :search-term="search" + :allow-multiselect="true" + :no-options-text="__('No labels found')" + @hide="hideDropdown" + @set-option="toggleLabelSelection" + @set-search="debouncedSetSearch" + > + <template #header> + <dropdown-header + ref="header" + :search-key="search" + labels-create-title="" + :labels-list-title="$options.i18n.labelsListTitle" + :show-dropdown-contents-create-view="showCreateView" + @toggleDropdownContentsCreateView="toggleCreateView" + @closeDropdown="hideDropdown" + @input="debouncedSetSearch" + /> + </template> + <template #item="{ item }"> + <label-item v-if="item" :label="item" /> + </template> + <template v-if="showCreateView" #default> + <dropdown-contents-create-view + attr-workspace-path="" + full-path="" + label-create-type="" + workspace-type="abuseReport" + @hideCreateView="toggleCreateView" + @labelCreated="onLabelCreated" + /> + </template> + <template #footer> + <dropdown-footer + v-if="!showCreateView" + :footer-create-label-title="__('Create label')" + @toggleDropdownContentsCreateView="toggleCreateView" + /> + </template> + </dropdown-widget> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue index 92478e10289..560d733c10c 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue @@ -95,12 +95,8 @@ export default { return; } - // TODO: In 16.4 use moderateUserPath without falling back to using updatePath - // See https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/167?work_item_iid=443 - const { moderateUserPath, updatePath } = this.report; - const path = moderateUserPath || updatePath; - - axios.put(path, this.form).then(this.handleResponse).catch(this.handleError); + const { moderateUserPath } = this.report; + axios.put(moderateUserPath, this.form).then(this.handleResponse).catch(this.handleError); }, handleResponse({ data }) { this.toggleActionsDrawer(); diff --git a/app/assets/javascripts/admin/abuse_report/components/report_details.vue b/app/assets/javascripts/admin/abuse_report/components/report_details.vue new file mode 100644 index 00000000000..10e1dca7f91 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/report_details.vue @@ -0,0 +1,49 @@ +<script> +import { __ } from '~/locale'; +import { createAlert } from '~/alert'; +import LabelsSelect from './labels_select.vue'; +import abuseReportQuery from './graphql/abuse_report.query.graphql'; + +export default { + name: 'ReportDetails', + components: { + LabelsSelect, + }, + props: { + reportId: { + type: String, + required: true, + }, + }, + data() { + return { + report: { labels: [] }, + }; + }, + apollo: { + report: { + query() { + return abuseReportQuery; + }, + variables() { + return { id: this.reportId }; + }, + update({ abuseReport }) { + return { + labels: abuseReport.labels?.nodes, + }; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + i18n: { + fetchError: __('An error occurred while fetching labels, please try again.'), + }, +}; +</script> + +<template> + <labels-select :report="report" /> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue index 624dcd47650..90c1943cb27 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_header.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue @@ -32,9 +32,6 @@ export default { isOpen() { return this.state === STATUS_OPEN; }, - badgeClass() { - return this.isOpen ? 'issuable-status-badge-open' : 'issuable-status-badge-closed'; - }, badgeVariant() { return this.isOpen ? 'success' : 'info'; }, @@ -58,21 +55,16 @@ export default { <header class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" > - <div class="gl-display-flex gl-align-items-center"> - <gl-badge - class="issuable-status-badge gl-mr-3" - :class="badgeClass" - :variant="badgeVariant" - :aria-label="badgeText" - > + <div class="gl-display-flex gl-align-items-center gl-gap-3"> + <gl-badge :variant="badgeVariant" :aria-label="badgeText"> <gl-icon :name="badgeIcon" class="gl-badge-icon" /> <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span> </gl-badge> <gl-avatar :size="48" :src="user.avatarUrl" /> - <h1 class="gl-font-size-h-display gl-my-0 gl-ml-3"> + <h1 class="gl-font-size-h-display gl-my-0"> {{ user.name }} </h1> - <gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link> + <gl-link :href="user.path"> @{{ user.username }} </gl-link> </div> <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0 gl-xs-flex-direction-column" diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue index f4f0fcac58f..84d6f25ac05 100644 --- a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue +++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue @@ -26,11 +26,6 @@ export default { type: Object, required: true, }, - reporter: { - type: Object, - required: false, - default: null, - }, }, data() { return { @@ -38,6 +33,9 @@ export default { }; }, computed: { + reporter() { + return this.report.reporter; + }, reporterName() { return this.reporter?.name || this.$options.i18n.deletedReporter; }, @@ -67,11 +65,12 @@ export default { <template> <div class="gl-pt-6"> <div - class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" + class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column gl-align-items-center" > - <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2"> + <h2 class="gl-font-lg gl-mt-2 gl-mb-2"> {{ $options.i18n.reportTypes[reportType] }} </h2> + <div class="gl-display-flex gl-align-items-stretch gl-xs-flex-direction-column gl-mt-3 gl-sm-mt-0" > 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 3dc03a8748f..fe0add1ba8d 100644 --- a/app/assets/javascripts/admin/abuse_report/components/user_details.vue +++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue @@ -39,19 +39,27 @@ export default { <template> <div class="gl-mt-6"> - <user-detail data-testid="createdAt" :label="$options.i18n.createdAt"> + <user-detail data-testid="created-at" :label="$options.i18n.createdAt"> <time-ago-tooltip :time="user.createdAt" /> </user-detail> + <user-detail data-testid="email" :label="$options.i18n.email"> <gl-link :href="`mailto:${user.email}`">{{ user.email }}</gl-link> </user-detail> + <user-detail data-testid="plan" :label="$options.i18n.plan" :value="user.plan" /> + <user-detail data-testid="verification" :label="$options.i18n.verification" :value="verificationState" /> - <user-detail v-if="user.creditCard" data-testid="creditCard" :label="$options.i18n.creditCard"> + + <user-detail + v-if="user.creditCard" + data-testid="credit-card-verification" + :label="$options.i18n.creditCard" + > <gl-sprintf :message="$options.i18n.registeredWith"> <template #name>{{ user.creditCard.name }}</template> </gl-sprintf> @@ -65,17 +73,18 @@ export default { </template> </gl-sprintf> </user-detail> + <user-detail - v-if="user.otherReports.length" - data-testid="otherReports" - :label="$options.i18n.otherReports" + v-if="user.pastClosedReports.length" + data-testid="past-closed-reports" + :label="$options.i18n.pastReports" > <div - v-for="(report, index) in user.otherReports" + v-for="(report, index) in user.pastClosedReports" :key="index" - :data-testid="`other-report-${index}`" + :data-testid="`past-report-${index}`" > - <gl-sprintf :message="$options.i18n.otherReport"> + <gl-sprintf :message="$options.i18n.reportedFor"> <template #reportLink="{ content }"> <gl-link :href="report.reportPath">{{ content }}</gl-link> </template> @@ -86,28 +95,33 @@ export default { </gl-sprintf> </div> </user-detail> + <user-detail - data-testid="normalLocation" + data-testid="normal-location" :label="$options.i18n.normalLocation" :value="user.mostUsedIp || user.lastSignInIp" /> + <user-detail - data-testid="lastSignInIp" + data-testid="last-sign-in-ip" :label="$options.i18n.lastSignInIp" :value="user.lastSignInIp" /> + <user-detail - data-testid="snippets" + data-testid="user-snippets-count" :label="$options.i18n.snippets" :value="$options.i18n.snippetsCount(user.snippetsCount)" /> + <user-detail - data-testid="groups" + data-testid="user-groups-count" :label="$options.i18n.groups" :value="$options.i18n.groupsCount(user.groupsCount)" /> + <user-detail - data-testid="notes" + data-testid="user-notes-count" :label="$options.i18n.notes" :value="$options.i18n.notesCount(user.notesCount)" /> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index b290581598a..6cae6b24f20 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -58,7 +58,7 @@ export const USER_DETAILS_I18N = { plan: s__('AbuseReport|Tier'), verification: s__('AbuseReport|Verification'), creditCard: s__('AbuseReport|Credit card'), - otherReports: s__('AbuseReport|Abuse reports'), + pastReports: s__('AbuseReport|Past abuse reports'), normalLocation: s__('AbuseReport|Normal location'), lastSignInIp: s__('AbuseReport|Last login'), snippets: s__('AbuseReport|Snippets'), @@ -72,7 +72,7 @@ export const USER_DETAILS_I18N = { phone: s__('AbuseReport|Phone'), creditCard: s__('AbuseReport|Credit card'), }, - otherReport: s__( + reportedFor: s__( 'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.', ), registeredWith: s__('AbuseReport|Registered with name %{name}.'), diff --git a/app/assets/javascripts/admin/abuse_report/index.js b/app/assets/javascripts/admin/abuse_report/index.js index 8ff3e690127..c2117130d26 100644 --- a/app/assets/javascripts/admin/abuse_report/index.js +++ b/app/assets/javascripts/admin/abuse_report/index.js @@ -1,7 +1,15 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { defaultClient } from '~/graphql_shared/issuable_client'; import AbuseReportApp from './components/abuse_report_app.vue'; +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient, +}); + export const initAbuseReportApp = () => { const el = document.querySelector('#js-abuse-reports-detail-view'); @@ -9,14 +17,22 @@ export const initAbuseReportApp = () => { return null; } - const { abuseReportData } = el.dataset; + const { abuseReportData, abuseReportsListPath } = el.dataset; const abuseReport = convertObjectPropsToCamelCase(JSON.parse(abuseReportData), { deep: true, }); return new Vue({ el, + apolloProvider, name: 'AbuseReportAppRoot', + provide: { + allowScopedLabels: false, + updatePath: abuseReport.report.updatePath, + listPath: abuseReportsListPath, + labelsManagePath: '', + allowLabelCreate: true, + }, render: (createElement) => createElement(AbuseReportApp, { props: { diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue index f24e491a745..b9fef57c2a2 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue @@ -1,7 +1,7 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlLabel, GlLink } from '@gitlab/ui'; import { getTimeago } from '~/lib/utils/datetime_utility'; -import { queryToObject } from '~/lib/utils/url_utility'; +import { mergeUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { SORT_UPDATED_AT } from '../constants'; @@ -10,6 +10,7 @@ import AbuseCategory from './abuse_category.vue'; export default { name: 'AbuseReportRow', components: { + GlLabel, GlLink, ListItem, AbuseCategory, @@ -53,6 +54,11 @@ export default { }); }, }, + methods: { + labelTarget(labelName) { + return mergeUrlParams({ 'label_name[]': labelName }, window.location.href); + }, + }, }; </script> @@ -68,7 +74,16 @@ export default { </gl-link> </template> <template #left-secondary> - <abuse-category :category="report.category" class="gl-mt-2 gl-mb-3" /> + <abuse-category :category="report.category" class="gl-mr-2" /> + <gl-label + v-for="label in report.labels" + :key="label.id" + class="gl-mr-2" + size="sm" + :background-color="label.color" + :title="label.title" + :target="labelTarget(label.title)" + /> </template> <template #right-secondary> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue index 109df943c42..2c555aca3c0 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -234,7 +234,8 @@ export default { initialTarget() { if (this.targetAccessLevels.length > 0) { return TARGET_ROLES; - } else if (this.targetPath !== '') { + } + if (this.targetPath !== '') { return TARGET_ALL_MATCHING_PATH; } return TARGET_ALL; diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 5b13bd177ae..bcd17570b95 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -44,7 +44,7 @@ export default { }, actionPrimary: { text: I18N_USER_ACTIONS.approve, - attributes: { variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }, + attributes: { variant: 'confirm', 'data-testid': 'approve-user-confirm-button' }, }, messageHtml, }, @@ -55,7 +55,7 @@ export default { </script> <template> - <gl-disclosure-dropdown-item data-qa-selector="approve_user_button" @action="onClick"> + <gl-disclosure-dropdown-item @action="onClick"> <template #list-item> <slot></slot> </template> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index 38c7d3f9b90..a9482d479b6 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -116,8 +116,7 @@ export default { category="tertiary" :toggle-text="$options.i18n.userAdministration" text-sr-only - data-testid="dropdown-toggle" - data-qa-selector="user_actions_dropdown_toggle" + data-testid="user-actions-dropdown-toggle" :data-qa-username="user.username" no-caret > diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 170bd6895aa..9e57b834c88 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -313,7 +313,7 @@ export default { <template #table> <gl-table class="alert-management-table" - data-qa-selector="alert_table_container" + data-testid="alert-table-container" :items=" alerts ? alerts.list diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index b9e37b9ede7..6baa431f2d9 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -72,7 +72,7 @@ export default { </p> <form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings"> <gl-form-group class="gl-pl-0"> - <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_incident_checkbox"> + <gl-form-checkbox v-model="createIssueEnabled" data-testid="create-incident-checkbox"> <span>{{ $options.i18n.createIncident.label }}</span> </gl-form-checkbox> </gl-form-group> @@ -93,14 +93,14 @@ export default { v-model="issueTemplate" :items="templates" block - data-qa-selector="incident_templates_dropdown" + data-testid="incident-templates-dropdown" /> </gl-form-group> <gl-form-group class="gl-pl-0 gl-mb-5"> <gl-form-checkbox v-model="sendEmailEnabled" - data-qa-selector="enable_email_notification_checkbox" + data-testid="enable-email-notification-checkbox" > <span>{{ $options.i18n.sendEmail.label }}</span> </gl-form-checkbox> @@ -112,7 +112,7 @@ export default { </gl-form-group> <gl-button ref="submitBtn" - data-qa-selector="save_changes_button" + data-testid="save-changes-button" :disabled="loading" variant="confirm" type="submit" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 56740e436ca..fb872243e5e 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -406,7 +406,7 @@ export default { v-model="integrationForm.type" :disabled="isSelectDisabled" class="gl-max-w-full" - data-qa-selector="integration_type_dropdown" + data-testid="integration-type-dropdown" :options="integrationTypesOptions" autofocus /> @@ -439,7 +439,7 @@ export default { v-model="integrationForm.name" type="text" :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" - data-qa-selector="integration_name_field" + data-testid="integration-name-field" @input="validateName" /> </gl-form-group> @@ -462,7 +462,7 @@ export default { v-model="integrationForm.active" :is-loading="loading" :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" - data-qa-selector="active_toggle_container" + data-testid="active-toggle-container" class="gl-mt-4 gl-font-weight-normal" /> </gl-form-group> @@ -552,7 +552,7 @@ export default { variant="confirm" category="secondary" class="gl-ml-3 js-no-auto-disable" - data-qa-selector="save_and_create_alert_button" + data-testid="save-and-create-alert-button" @click="submit(true)" > {{ $options.i18n.saveAndTestIntegration }} @@ -654,7 +654,7 @@ export default { :debounce="$options.JSON_VALIDATE_DELAY" rows="6" max-rows="10" - data-qa-selector="test_payload_field" + data-testid="test-payload-field" @input="validateJson(false)" /> </gl-form-group> @@ -666,7 +666,6 @@ export default { data-testid="send-test-alert" variant="confirm" class="js-no-auto-disable" - data-qa-selector="send_test_alert_button" @click="isFormDirty ? null : sendTestAlert()" > {{ $options.i18n.send }} diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index e4fc37f9760..e735ee466ad 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -385,8 +385,7 @@ export default { <gl-button v-if="canAddIntegration && !formVisible" size="small" - data-testid="add-integration-btn" - data-qa-selector="add_integration_button" + data-testid="add-integration-button" @click="setFormVisibility(true)" > {{ $options.i18n.addNewIntegration }} diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue index b622b0441e2..724e9c91305 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue @@ -13,7 +13,8 @@ export default { formattedStageCount() { if (!this.stageCount) { return '-'; - } else if (this.stageCount > 1000) { + } + if (this.stageCount > 1000) { return sprintf(s__('ValueStreamAnalytics|%{stageCount}+ items'), { stageCount: formatNumber(1000), }); diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index f881c924ae5..ddfc6baafa9 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -69,7 +69,8 @@ export default { selectedProjectsLabel() { if (this.selectedProjects.length === 1) { return this.selectedProjects[0].name; - } else if (this.selectedProjects.length > 1) { + } + if (this.selectedProjects.length > 1) { return n__( 'CycleAnalytics|Project selected', 'CycleAnalytics|%d projects selected', diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 185cdaa1c99..6dfc1c609de 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -33,6 +33,7 @@ const Api = { forkedProjectsPath: '/api/:version/projects/:id/forks', projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename', + projectGroupsPath: '/api/:version/projects/:id/groups.json', projectUsersPath: '/api/:version/projects/:id/users', projectInvitationsPath: '/api/:version/projects/:id/invitations', projectMembersPath: '/api/:version/projects/:id/members', @@ -177,6 +178,19 @@ const Api = { }); }, + projectGroups(id, options) { + const url = Api.buildUrl(this.projectGroupsPath).replace(':id', encodeURIComponent(id)); + + return axios + .get(url, { + params: { + ...options, + }, + }) + .then(({ data }) => { + return data; + }); + }, /** * @deprecated This method will be removed soon. Use the * `getGroups` method in `~/rest_api` instead. diff --git a/app/assets/javascripts/api/application_settings_api.js b/app/assets/javascripts/api/application_settings_api.js new file mode 100644 index 00000000000..839636c36f4 --- /dev/null +++ b/app/assets/javascripts/api/application_settings_api.js @@ -0,0 +1,14 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const APPLICATION_SETTINGS_PATH = '/api/:version/application/settings'; + +export function getApplicationSettings() { + const url = buildApiUrl(APPLICATION_SETTINGS_PATH); + return axios.get(url); +} + +export function updateApplicationSettings(data) { + const url = buildApiUrl(APPLICATION_SETTINGS_PATH); + return axios.put(url, data); +} diff --git a/app/assets/javascripts/authentication/webauthn/error.js b/app/assets/javascripts/authentication/webauthn/error.js index 40dbecd8bc9..ce6c79a1f11 100644 --- a/app/assets/javascripts/authentication/webauthn/error.js +++ b/app/assets/javascripts/authentication/webauthn/error.js @@ -14,11 +14,14 @@ export default class WebAuthnError { message() { if (this.errorName === 'NotSupportedError') { return __('Your device is not compatible with GitLab. Please try another device'); - } else if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_AUTHENTICATE) { + } + if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_AUTHENTICATE) { return __('This device has not been registered with us.'); - } else if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_REGISTER) { + } + if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_REGISTER) { return __('This device has already been registered with us.'); - } else if (this.errorName === 'SecurityError' && this.httpsDisabled) { + } + if (this.errorName === 'SecurityError' && this.httpsDisabled) { return __( 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.', ); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js index 095634340c1..e1c1bd58ee2 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -20,7 +20,8 @@ export default () => ({ const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); if (checkbox?.matches('[data-inapplicable]')) { return { state: 'inapplicable' }; - } else if (checkbox?.checked) { + } + if (checkbox?.checked) { return { state: 'done' }; } diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index 689f2f0898e..e8c486f6e74 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -636,6 +636,7 @@ const MR_SHORTCUTS_GROUP = { MR_NEXT_UNRESOLVED_DISCUSSION, MR_PREVIOUS_UNRESOLVED_DISCUSSION, MR_COPY_SOURCE_BRANCH_NAME, + MR_TOGGLE_FILE_BROWSER, ], }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue index cb7c6f9f6bc..e81ceae57c0 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue @@ -1,15 +1,16 @@ <script> -import { GlModal, GlSearchBoxByType } from '@gitlab/ui'; +import { GlModal, GlSearchBoxByType, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import { joinPaths } from '../../lib/utils/url_utility'; import { keybindingGroups } from './keybindings'; import Shortcut from './shortcut.vue'; -import ShortcutsToggle from './shortcuts_toggle.vue'; export default { components: { GlModal, GlSearchBoxByType, - ShortcutsToggle, + GlLink, + GlSprintf, Shortcut, }, data() { @@ -39,6 +40,9 @@ export default { return mapped.filter((group) => group.keybindings.length); }, + absoluteUserPreferencesPath() { + return joinPaths(gon.relative_url_root || '/', '/-/profile/preferences'); + }, }, i18n: { title: __(`Keyboard shortcuts`), @@ -66,7 +70,21 @@ export default { :aria-label="$options.i18n.search" class="gl-w-half gl-mr-3" /> - <shortcuts-toggle class="gl-w-half gl-ml-3" /> + <span> + <gl-sprintf + :message=" + __( + 'Enable or disable keyboard shortcuts in your %{linkStart}user preferences%{linkEnd}.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="absoluteUserPreferencesPath"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </span> </div> <div v-if="filteredKeybindings.length === 0" class="gl-px-5"> {{ $options.i18n.noMatch }} diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js index 3f3e0c51de5..22f2478c530 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js @@ -3,13 +3,7 @@ import 'mousetrap/plugins/pause/mousetrap-pause'; const shorcutsDisabledKey = 'shortcutsDisabled'; -export const shouldDisableShortcuts = () => { - try { - return localStorage.getItem(shorcutsDisabledKey) === 'true'; - } catch (e) { - return false; - } -}; +export const shouldDisableShortcuts = () => !window.gon.keyboard_shortcuts_enabled; export function enableShortcuts() { localStorage.setItem(shorcutsDisabledKey, false); diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 30424fee46a..4e643f71c4b 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { fixTitle } from '~/tooltips'; import { getLocationHash } from '../lib/utils/url_utility'; // Toggle button. Show/hide content inside parent container. @@ -29,9 +30,22 @@ $(() => { $container.find('.js-toggle-content').toggle(toggleState); } + function updateTitle(el, container) { + const $container = $(container); + const isExpanded = $container.data('is-expanded'); + + el.setAttribute('title', isExpanded ? el.dataset.collapseTitle : el.dataset.expandTitle); + + fixTitle(el); + } + $('body').on('click', '.js-toggle-button', function toggleButton(e) { e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'selected'); - toggleContainer($(this).closest('.js-toggle-container')); + + const containerEl = this.closest('.js-toggle-container'); + + toggleContainer(containerEl); + updateTitle(this, containerEl); const targetTag = e.currentTarget.tagName.toLowerCase(); if (targetTag === 'a' || targetTag === 'button') { diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 4e47aa99fd8..699a0491183 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -1,5 +1,8 @@ <script> import DefaultActions from 'jh_else_ce/blob/components/blob_header_default_actions.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import userInfoQuery from '../queries/user_info.query.graphql'; +import applicationInfoQuery from '../queries/application_info.query.graphql'; import BlobFilepath from './blob_header_filepath.vue'; import ViewerSwitcher from './blob_header_viewer_switcher.vue'; import { SIMPLE_BLOB_VIEWER } from './constants'; @@ -11,6 +14,21 @@ export default { DefaultActions, BlobFilepath, TableOfContents, + WebIdeLink: () => import('ee_else_ce/vue_shared/components/web_ide_link.vue'), + }, + apollo: { + currentUser: { + query: userInfoQuery, + error() { + this.$emit('error'); + }, + }, + gitpodEnabled: { + query: applicationInfoQuery, + error() { + this.$emit('error'); + }, + }, }, props: { blob: { @@ -52,10 +70,26 @@ export default { required: false, default: false, }, + showForkSuggestion: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + projectId: { + type: String, + required: false, + default: '', + }, }, data() { return { viewer: this.hideViewerSwitcher ? null : this.activeViewerType, + gitpodEnabled: false, }; }, computed: { @@ -65,12 +99,18 @@ export default { showDefaultActions() { return !this.hideDefaultActions; }, + showWebIdeLink() { + return !this.blob.archived && this.blob.editBlobPath; + }, isEmpty() { return this.blob.rawSize === '0'; }, blobSwitcherDocIcon() { return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document'; }, + projectIdAsNumber() { + return getIdFromGraphQLId(this.projectId); + }, }, watch: { viewer(newVal, oldVal) { @@ -100,6 +140,27 @@ export default { <div class="gl-display-flex gl-flex-wrap file-actions"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" /> + <web-ide-link + v-if="showWebIdeLink" + :show-edit-button="!isBinary" + class="gl-mr-3" + :edit-url="blob.editBlobPath" + :web-ide-url="blob.ideEditPath" + :needs-to-fork="showForkSuggestion" + :show-pipeline-editor-button="Boolean(blob.pipelineEditorPath)" + :pipeline-editor-url="blob.pipelineEditorPath" + :gitpod-url="blob.gitpodBlobUrl" + :show-gitpod-button="gitpodEnabled" + :gitpod-enabled="currentUser && currentUser.gitpodEnabled" + :project-path="projectPath" + :project-id="projectIdAsNumber" + :user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath" + :user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath" + is-blob + disable-fork-modal + v-on="$listeners" + /> + <slot name="actions"></slot> <default-actions diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js index 4258d16b69f..9c6a5958e1f 100644 --- a/app/assets/javascripts/blob/line_highlighter.js +++ b/app/assets/javascripts/blob/line_highlighter.js @@ -59,7 +59,7 @@ LineHighlighter.prototype.bindEvents = function () { } }; -LineHighlighter.prototype.highlightHash = function (newHash) { +LineHighlighter.prototype.highlightHash = function (newHash, scrollEnabled = true) { let range; if (newHash && typeof newHash === 'string') this._hash = newHash; @@ -71,12 +71,14 @@ LineHighlighter.prototype.highlightHash = function (newHash) { this.highlightRange(range); const lineSelector = `#L${range[0]}`; - scrollToElement(lineSelector, { - // Scroll to the first highlighted line on initial load - // Add an offset of -100 for some context - offset: -100, - behavior: this.options.scrollBehavior, - }); + if (scrollEnabled) { + scrollToElement(lineSelector, { + // Scroll to the first highlighted line on initial load + // Add an offset of -100 for some context + offset: -100, + behavior: this.options.scrollBehavior, + }); + } } } }; @@ -94,7 +96,8 @@ LineHighlighter.prototype.clickHandler = function (event) { // treat this like a single-line selection. this.setHash(lineNumber); return this.highlightLine(lineNumber); - } else if (event.shiftKey) { + } + if (event.shiftKey) { if (lineNumber < current[0]) { range = [lineNumber, current[0]]; } else { diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 94ae281cada..9c22d960bf5 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -5,6 +5,7 @@ import { relativePathToAbsolute, joinPaths, setUrlParams, + getParameterByName, } from '~/lib/utils/url_utility'; const SANDBOX_FRAME_PATH = '/-/sandbox/swagger'; @@ -12,10 +13,14 @@ const SANDBOX_FRAME_PATH = '/-/sandbox/swagger'; const getSandboxFrameSrc = () => { const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); const absoluteUrl = relativePathToAbsolute(path, getBaseURL()); + const displayOperationId = getParameterByName('displayOperationId'); + const params = { displayOperationId }; + if (window.gon?.relative_url_root) { - return setUrlParams({ relativeRootPath: window.gon.relative_url_root }, absoluteUrl); + params.relativeRootPath = window.gon.relative_url_root; } - return absoluteUrl; + + return setUrlParams(params, absoluteUrl); }; const createSandbox = () => { diff --git a/app/assets/javascripts/repository/queries/application_info.query.graphql b/app/assets/javascripts/blob/queries/application_info.query.graphql index fd69de39f75..fd69de39f75 100644 --- a/app/assets/javascripts/repository/queries/application_info.query.graphql +++ b/app/assets/javascripts/blob/queries/application_info.query.graphql diff --git a/app/assets/javascripts/repository/queries/user_info.query.graphql b/app/assets/javascripts/blob/queries/user_info.query.graphql index 114947a423d..114947a423d 100644 --- a/app/assets/javascripts/repository/queries/user_info.query.graphql +++ b/app/assets/javascripts/blob/queries/user_info.query.graphql diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 692ca6bf59b..c441a718dd8 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -20,6 +20,7 @@ import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; import { ListType } from '../constants'; import eventHub from '../eventhub'; +import { setError } from '../graphql/cache_updates'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; @@ -45,6 +46,7 @@ export default { }, mixins: [boardCardInner], inject: [ + 'allowSubEpics', 'rootPath', 'scopedLabelsAvailable', 'isEpicBoard', @@ -85,7 +87,7 @@ export default { }; }, computed: { - ...mapState(['isShowingLabels', 'allowSubEpics']), + ...mapState(['isShowingLabels']), isLoading() { return this.item.isLoading || this.item.iid === '-1'; }, @@ -175,7 +177,8 @@ export default { }, }, methods: { - ...mapActions(['performSearch', 'setError']), + ...mapActions(['performSearch']), + setError, isIndexLessThanlimit(index) { return index < this.limitBeforeCounter; }, @@ -288,7 +291,7 @@ export default { <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" /> <span v-if="item.referencePath && !isLoading" - class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" + class="board-card-number gl-overflow-hidden gl-display-flex gl-gap-2 gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > <work-item-type-icon diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 5e1e46dd198..bb740c0e7eb 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -222,7 +222,7 @@ export default { <template #default> <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" /> <sidebar-assignees-widget - v-if="activeBoardItem.assignees" + v-if="activeBoardIssuable.assignees" :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :initial-assignees="activeBoardIssuable.assignees" @@ -232,7 +232,7 @@ export default { /> <sidebar-dropdown-widget v-if="epicFeatureAvailable && !isIncidentSidebar" - :key="`epic-${activeBoardItem.iid}`" + :key="`epic-${activeBoardIssuable.iid}`" :iid="activeBoardIssuable.iid" issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" @@ -242,7 +242,7 @@ export default { /> <div> <sidebar-dropdown-widget - :key="`milestone-${activeBoardItem.iid}`" + :key="`milestone-${activeBoardIssuable.iid}`" :iid="activeBoardIssuable.iid" issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" @@ -252,7 +252,7 @@ export default { /> <sidebar-iteration-widget v-if="iterationFeatureAvailable && !isIncidentSidebar" - :key="`iteration-${activeBoardItem.iid}`" + :key="`iteration-${activeBoardIssuable.iid}`" :iid="activeBoardIssuable.iid" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 4986c3780e5..d12478b42d8 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -178,6 +178,9 @@ export default { }, methods: { ...mapActions(['setError', 'unsetError', 'setBoard']), + isFocusMode() { + return Boolean(document.querySelector('.content-wrapper > .js-focus-mode-board.is-focused')); + }, cancel() { this.$emit('cancel'); }, @@ -281,6 +284,7 @@ export default { modal-class="board-config-modal" content-class="gl-absolute gl-top-7" visible + :static="isFocusMode()" :hide-footer="readonly" :title="title" :action-primary="primaryProps" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 67bfcfb9d97..1bb7e88122a 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -88,6 +88,7 @@ export default { toListId: null, toList: {}, addItemToListInProgress: false, + updateIssueOrderInProgress: false, }; }, apollo: { @@ -253,7 +254,9 @@ export default { return this.canMoveIssue ? options : {}; }, disableScrollingWhenMutationInProgress() { - return this.hasNextPage && this.isUpdateIssueOrderInProgress; + return ( + this.hasNextPage && (this.isUpdateIssueOrderInProgress || this.updateIssueOrderInProgress) + ); }, showMoveToPosition() { return !this.disabled && this.list.listType !== ListType.closed; @@ -343,7 +346,7 @@ export default { sortableStart(); this.track('drag_card', { label: 'board' }); }, - handleDragOnEnd({ + async handleDragOnEnd({ newIndex: originalNewIndex, oldIndex, from, @@ -394,7 +397,8 @@ export default { } if (this.isApolloBoard) { - this.moveBoardItem( + this.updateIssueOrderInProgress = true; + await this.moveBoardItem( { epicId: itemId, iid: itemIid, @@ -404,7 +408,9 @@ export default { moveAfterId, }, newIndex, - ); + ).finally(() => { + this.updateIssueOrderInProgress = false; + }); } else { this.moveItem({ itemId, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 068db98a750..42c30dc8245 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -490,7 +490,11 @@ export default { > <span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }"> <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> - <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> + <span + ref="itemCount" + class="gl-display-inline-flex gl-align-items-center" + data-testid="item-count" + > <gl-icon class="gl-mr-2" :name="countIcon" :size="14" /> <item-count v-if="!isLoading" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 58db2c9ac2a..89e13625210 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -166,7 +166,7 @@ export default { <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append> <gl-drawer v-bind="$attrs" - class="js-board-settings-sidebar gl-absolute boards-sidebar" + class="js-board-settings-sidebar boards-sidebar" :open="showSidebar" variant="sidebar" @close="unsetActiveListId" diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index 2b8418333a8..7fd1a934381 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -93,6 +93,9 @@ export default { }); return hasScope; }, + isLoading() { + return this.$apollo.queries.board.loading; + }, }, }; </script> @@ -105,7 +108,11 @@ export default { <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full gl-min-w-0" > - <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" /> + <boards-selector + :board-apollo="board" + :is-current-board-loading="isLoading" + @switchBoard="$emit('switchBoard', $event)" + /> <new-board-button /> <issue-board-filtered-search v-if="isIssueBoard" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index b3fe52944dc..cc6fde92f9b 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -70,6 +70,11 @@ export default { required: false, default: () => ({}), }, + isCurrentBoardLoading: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -92,6 +97,9 @@ export default { boardToUse() { return this.isApolloBoard ? this.boardApollo : this.board; }, + isBoardToUseLoading() { + return this.isApolloBoard ? this.isCurrentBoardLoading : this.isBoardLoading; + }, parentType() { return this.boardType; }, @@ -301,7 +309,7 @@ export default { data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle" menu-class="flex-column dropdown-extended-height" - :loading="isBoardLoading" + :loading="isBoardToUseLoading" :text="boardToUse.name" @show="loadBoards" > diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index f60f00be368..a7b3f5536a4 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -62,7 +62,7 @@ export default { tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; - const { fetchUsers, fetchLabels, fetchMilestones } = issueBoardFilters( + const { fetchUsers, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, this.isGroupBoard, @@ -148,7 +148,8 @@ export default { token: MilestoneToken, unique: true, shouldSkipSort: true, - fetchMilestones, + isProject: !this.isGroupBoard, + fullPath: this.fullPath, }, { icon: 'issues', diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 1f28974afd1..46009df2bd3 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -52,11 +52,14 @@ export default { if (timeDifference === 0) { return __('Today'); - } else if (timeDifference === 1) { + } + if (timeDifference === 1) { return __('Tomorrow'); - } else if (timeDifference === -1) { + } + if (timeDifference === -1) { return __('Yesterday'); - } else if (timeDifference > 0 && timeDifference < 7) { + } + if (timeDifference > 0 && timeDifference < 7) { return dateFormat(issueDueDate, 'dddd'); } diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index 1c2c0022ddf..a2c4b42b6c5 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -7,6 +7,7 @@ import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { titleQueries } from 'ee_else_ce/boards/constants'; +import { setError } from '../../graphql/cache_updates'; export default { components: { @@ -65,7 +66,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveItemTitle', 'setError']), + ...mapActions(['setActiveItemTitle']), getPendingChangesKey(item) { if (!item) { return ''; @@ -130,7 +131,7 @@ export default { this.showChangesAlert = false; } catch (e) { this.title = this.item.title; - this.setError({ error: e, message: this.$options.i18n.updateTitleError }); + setError({ error: e, message: this.$options.i18n.updateTitleError }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js index e54701a63c0..3551c3ed982 100644 --- a/app/assets/javascripts/boards/graphql/cache_updates.js +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -109,9 +109,9 @@ export function updateEpicsCount({ epicBoardList: { ...epicBoardList, metadata: { + ...epicBoardList.metadata, epicsCount: epicBoardList.metadata.epicsCount - 1, totalWeight: epicBoardList.metadata.totalWeight - epicWeight, - ...epicBoardList.metadata, }, }, }), @@ -127,9 +127,9 @@ export function updateEpicsCount({ epicBoardList: { ...epicBoardList, metadata: { + ...epicBoardList.metadata, epicsCount: epicBoardList.metadata.epicsCount + 1, totalWeight: epicBoardList.metadata.totalWeight + epicWeight, - ...epicBoardList.metadata, }, }, }), diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql deleted file mode 100644 index 252e8c1ab06..00000000000 --- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql +++ /dev/null @@ -1,15 +0,0 @@ -#import "~/graphql_shared/fragments/user.fragment.graphql" - -query GroupBoardMembers($fullPath: ID!, $search: String) { - workspace: group(fullPath: $fullPath) { - id - assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { - nodes { - id - user { - ...User - } - } - } - } -} diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql deleted file mode 100644 index 5279680b03c..00000000000 --- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql +++ /dev/null @@ -1,15 +0,0 @@ -#import "~/graphql_shared/fragments/user.fragment.graphql" - -query ProjectBoardMembers($fullPath: ID!, $search: String) { - workspace: project(fullPath: $fullPath) { - id - assignees: projectMembers(search: $search) { - nodes { - id - user { - ...User - } - } - } - } -} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index a03ec9193ea..9d7b7a38c6d 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -102,6 +102,7 @@ function mountBoardApp(el) { swimlanesFeatureAvailable: gon.licensed_features?.swimlanes, multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleBoardsAvailable), scopedIssueBoardFeatureEnabled: parseBoolean(el.dataset.scopedIssueBoardFeatureEnabled), + allowSubEpics: false, }, render: (createComponent) => createComponent(BoardApp), }); diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index 27efb3f775c..ba5da70c6ec 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -1,7 +1,5 @@ -import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql'; -import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql'; -import groupBoardMilestonesQuery from './graphql/group_board_milestones.query.graphql'; -import projectBoardMilestonesQuery from './graphql/project_board_milestones.query.graphql'; +import { BoardType } from 'ee_else_ce/boards/constants'; +import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import boardLabels from './graphql/board_labels.query.graphql'; export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { @@ -9,20 +7,15 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || []; }; - const boardAssigneesQuery = () => { - return isGroupBoard ? groupBoardMembers : projectBoardMembers; - }; - const fetchUsers = (usersSearchTerm) => { + const namespace = isGroupBoard ? BoardType.group : BoardType.project; + return apollo .query({ - query: boardAssigneesQuery(), - variables: { - fullPath, - search: usersSearchTerm, - }, + query: usersAutocompleteQuery, + variables: { fullPath, search: usersSearchTerm, isProject: !isGroupBoard }, }) - .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user)); + .then(({ data }) => data[namespace]?.autocompleteUsers); }; const fetchLabels = (labelSearchTerm) => { @@ -39,27 +32,8 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { .then(transformLabels); }; - const fetchMilestones = (searchTerm) => { - const variables = { - fullPath, - searchTerm, - }; - - const query = isGroupBoard ? groupBoardMilestonesQuery : projectBoardMilestonesQuery; - - return apollo - .query({ - query, - variables, - }) - .then(({ data }) => { - return data.workspace?.milestones.nodes; - }); - }; - return { fetchLabels, fetchUsers, - fetchMilestones, }; } diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue index daa4119f44d..89582e64f3a 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue @@ -1,13 +1,17 @@ <script> import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; -import { validateQueryString } from '~/jobs/components/filtered_search/utils'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; -import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; -import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue'; +import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import JobsTableTabs from '~/ci/jobs_page/components/jobs_table_tabs.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import JobsTableEmptyState from '~/ci/jobs_page/components/jobs_table_empty_state.vue'; import { createAlert } from '~/alert'; -import JobsSkeletonLoader from '../jobs_skeleton_loader.vue'; +import { + TOKEN_TYPE_STATUS, + TOKEN_TYPE_JOBS_RUNNER_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_FIELDS_ADMIN, RAW_TEXT_WARNING_ADMIN, @@ -15,10 +19,11 @@ import { JOBS_FETCH_ERROR_MSG, LOADING_ARIA_LABEL, CANCELABLE_JOBS_ERROR_MSG, -} from '../constants'; +} from './constants'; +import JobsSkeletonLoader from './components/jobs_skeleton_loader.vue'; import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql'; import GetAllJobsCount from './graphql/queries/get_all_jobs_count.query.graphql'; -import CancelableJobs from './graphql/queries/get_cancelable_jobs_count.query.graphql'; +import getCancelableJobs from './graphql/queries/get_cancelable_jobs_count.query.graphql'; export default { i18n: { @@ -39,6 +44,7 @@ export default { GlIntersectionObserver, GlLoadingIcon, }, + mixins: [glFeatureFlagsMixin()], inject: { jobStatuses: { default: null, @@ -72,6 +78,9 @@ export default { }, jobsCount: { query: GetAllJobsCount, + variables() { + return this.variables; + }, update(data) { return data?.jobs?.count || 0; }, @@ -83,7 +92,7 @@ export default { }, }, cancelable: { - query: CancelableJobs, + query: getCancelableJobs, update(data) { this.isCancelable = data.cancelable.count !== 0; }, @@ -150,11 +159,11 @@ export default { }, }, methods: { - updateHistoryAndFetchCount(status = null) { - this.$apollo.queries.jobsCount.refetch({ statuses: status }); + updateHistoryAndFetchCount(filterParams = {}) { + this.$apollo.queries.jobsCount.refetch(filterParams); updateHistory({ - url: setUrlParams({ statuses: status }, window.location.href, true), + url: setUrlParams(filterParams, window.location.href, true), }); }, fetchJobsByStatus(scope) { @@ -184,33 +193,36 @@ export default { this.infiniteScrollingTriggered = false; this.filterSearchTriggered = true; - // all filters have been cleared reset query param - // and refetch jobs/count with defaults - if (!filters.length) { - this.updateHistoryAndFetchCount(); - this.$apollo.queries.jobs.refetch({ statuses: null }); - - return; - } - - // Eventually there will be more tokens available - // this code is written to scale for those tokens - filters.forEach((filter) => { + if (filters.some((filter) => !filter.type)) { // Raw text input in filtered search does not have a type // when a user enters raw text we alert them that it is // not supported and we do not make an additional API call - if (!filter.type) { - createAlert({ - message: RAW_TEXT_WARNING_ADMIN, - type: 'warning', - }); - } + createAlert({ message: RAW_TEXT_WARNING_ADMIN, type: 'warning' }); + return; + } + + const defaultFilterParams = this.glFeatures.adminJobsFilterRunnerType + ? { statuses: null, runnerTypes: null } + : { statuses: null }; - if (filter.type === 'status') { - this.updateHistoryAndFetchCount(filter.value.data); - this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); + const filterParams = filters.reduce((acc, filter) => { + switch (filter.type) { + case TOKEN_TYPE_STATUS: + return { ...acc, statuses: filter.value.data }; + + case TOKEN_TYPE_JOBS_RUNNER_TYPE: + if (this.glFeatures.adminJobsFilterRunnerType) { + return { ...acc, runnerTypes: filter.value.data }; + } + return acc; + + default: + return acc; } - }); + }, defaultFilterParams); + + this.updateHistoryAndFetchCount(filterParams); + this.$apollo.queries.jobs.refetch(filterParams); }, }, }; diff --git a/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs.vue index 72cfc005782..fb13fd4b03e 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { CANCEL_JOBS_MODAL_ID, CANCEL_JOBS_BUTTON_TEXT, CANCEL_BUTTON_TOOLTIP } from '../constants'; import CancelJobsModal from './cancel_jobs_modal.vue'; -import { CANCEL_JOBS_MODAL_ID, CANCEL_JOBS_BUTTON_TEXT, CANCEL_BUTTON_TOOLTIP } from './constants'; export default { name: 'CancelJobs', diff --git a/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs_modal.vue index b2c5326fefd..7c1cd75609a 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs_modal.vue @@ -9,7 +9,7 @@ import { CANCEL_JOBS_MODAL_TITLE, CANCEL_JOBS_WARNING, PRIMARY_ACTION_TEXT, -} from './constants'; +} from '../constants'; export default { components: { diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue index cbb80a5175f..cbb80a5175f 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue index 33bcee5b34b..a76829aa129 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue @@ -1,6 +1,6 @@ <script> import { GlLink } from '@gitlab/ui'; -import { RUNNER_EMPTY_TEXT, RUNNER_NO_DESCRIPTION } from '~/pages/admin/jobs/components/constants'; +import { RUNNER_EMPTY_TEXT, RUNNER_NO_DESCRIPTION } from '../../constants'; export default { i18n: { diff --git a/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue b/app/assets/javascripts/ci/admin/jobs_table/components/jobs_skeleton_loader.vue index c305e09af0d..c305e09af0d 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/jobs_skeleton_loader.vue diff --git a/app/assets/javascripts/pages/admin/jobs/components/constants.js b/app/assets/javascripts/ci/admin/jobs_table/constants.js index 4af8cb355fc..ff0efdb1f5b 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/constants.js +++ b/app/assets/javascripts/ci/admin/jobs_table/constants.js @@ -1,5 +1,5 @@ import { s__, __ } from '~/locale'; -import { RAW_TEXT_WARNING } from '~/jobs/components/table/constants'; +import { RAW_TEXT_WARNING } from '~/ci/jobs_page/constants'; export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.'); export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.'); diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/ci/admin/jobs_table/graphql/cache_config.js index fd7ee2a6f8c..fd7ee2a6f8c 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/cache_config.js diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql index 9e2795966e0..89fb1782e46 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql @@ -1,5 +1,10 @@ -query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) { - jobs(after: $after, first: $first, statuses: $statuses) { +query getAllJobs( + $after: String + $first: Int = 50 + $statuses: [CiJobStatus!] + $runnerTypes: [CiRunnerType!] +) { + jobs(after: $after, first: $first, statuses: $statuses, runnerTypes: $runnerTypes) { pageInfo { endCursor hasNextPage diff --git a/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql new file mode 100644 index 00000000000..bcb0123e9e3 --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql @@ -0,0 +1,5 @@ +query getAllJobsCount($statuses: [CiJobStatus!], $runnerTypes: [CiRunnerType!]) { + jobs(statuses: $statuses, runnerTypes: $runnerTypes) { + count + } +} diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_cancelable_jobs_count.query.graphql index 9b90abebbf7..9bf5e1449b7 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_cancelable_jobs_count.query.graphql @@ -1,4 +1,4 @@ -query canelableJobs { +query getCancelableJobsCount { cancelable: jobs(statuses: [PENDING, RUNNING]) { count } diff --git a/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue deleted file mode 100644 index d2c96b1a201..00000000000 --- a/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue +++ /dev/null @@ -1,41 +0,0 @@ -<script> -import { GlBanner } from '@gitlab/ui'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; -import { - I18N_FEEDBACK_BANNER_TITLE, - I18N_FEEDBACK_BANNER_BODY, - I18N_FEEDBACK_BANNER_BUTTON, - FEEDBACK_URL, -} from '../constants'; - -export default { - components: { - GlBanner, - UserCalloutDismisser, - }, - inject: ['artifactsManagementFeedbackImagePath'], - FEEDBACK_URL, - i18n: { - title: I18N_FEEDBACK_BANNER_TITLE, - body: I18N_FEEDBACK_BANNER_BODY, - button: I18N_FEEDBACK_BANNER_BUTTON, - }, -}; -</script> -<template> - <user-callout-dismisser feature-name="artifacts_management_page_feedback_banner"> - <template #default="{ dismiss, shouldShowCallout }"> - <gl-banner - v-if="shouldShowCallout" - class="gl-mb-6" - :title="$options.i18n.title" - :button-text="$options.i18n.button" - :button-link="$options.FEEDBACK_URL" - :svg-path="artifactsManagementFeedbackImagePath" - @close="dismiss" - > - <p>{{ $options.i18n.body }}</p> - </gl-banner> - </template> - </user-callout-dismisser> -</template> diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index 88334488fdd..e08470c62be 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -48,7 +48,6 @@ import JobCheckbox from './job_checkbox.vue'; import ArtifactsBulkDelete from './artifacts_bulk_delete.vue'; import BulkDeleteModal from './bulk_delete_modal.vue'; import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; -import FeedbackBanner from './feedback_banner.vue'; const INITIAL_PAGINATION_STATE = { currentPage: INITIAL_CURRENT_PAGE, @@ -76,7 +75,6 @@ export default { ArtifactsBulkDelete, BulkDeleteModal, ArtifactsTableRowDetails, - FeedbackBanner, }, directives: { GlTooltip: GlTooltipDirective, @@ -374,7 +372,6 @@ export default { </script> <template> <div> - <feedback-banner /> <artifacts-bulk-delete v-if="canBulkDestroyArtifacts" :selected-artifacts="selectedArtifacts" diff --git a/app/assets/javascripts/ci/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js index 2d89b6541f3..28c371cda1e 100644 --- a/app/assets/javascripts/ci/artifacts/constants.js +++ b/app/assets/javascripts/ci/artifacts/constants.js @@ -47,13 +47,6 @@ export const I18N_MODAL_BODY = s__( export const I18N_MODAL_PRIMARY = s__('Artifacts|Delete artifact'); export const I18N_MODAL_CANCEL = __('Cancel'); -export const I18N_FEEDBACK_BANNER_TITLE = s__('Artifacts|Help us improve this page'); -export const I18N_FEEDBACK_BANNER_BODY = s__( - 'Artifacts|We want you to be able to use this page to easily manage your CI/CD job artifacts. We are working to improve this experience and would appreciate any feedback you have about the improvements we are making.', -); -export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey'); -export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8'; - export const SELECTED_ARTIFACTS_MAX_COUNT = 50; export const I18N_BULK_DELETE_MAX_SELECTED = s__( 'Artifacts|Maximum selected artifacts limit reached', diff --git a/app/assets/javascripts/ci/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js index 6e795fd9bd7..c6021eb056f 100644 --- a/app/assets/javascripts/ci/artifacts/index.js +++ b/app/assets/javascripts/ci/artifacts/index.js @@ -19,12 +19,7 @@ export const initArtifactsTable = () => { return false; } - const { - projectPath, - projectId, - canDestroyArtifacts, - artifactsManagementFeedbackImagePath, - } = el.dataset; + const { projectPath, projectId, canDestroyArtifacts } = el.dataset; return new Vue({ el, @@ -33,7 +28,6 @@ export const initArtifactsTable = () => { projectPath, projectId, canDestroyArtifacts: parseBoolean(canDestroyArtifacts), - artifactsManagementFeedbackImagePath, }, render: (createElement) => createElement(App), }); diff --git a/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js deleted file mode 100644 index 574a5e7fd99..00000000000 --- a/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js +++ /dev/null @@ -1,262 +0,0 @@ -import $ from 'jquery'; -import SecretValues from '~/behaviors/secret_values'; -import CreateItemDropdown from '~/create_item_dropdown'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; - -const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments'); - -function createEnvironmentItem(value) { - return { - title: value === '*' ? ALL_ENVIRONMENTS_STRING : value, - id: value, - text: value === '*' ? s__('CiVariable|* (All environments)') : value, - }; -} - -export default class VariableList { - constructor({ container, formField, maskableRegex }) { - this.$container = $(container); - this.formField = formField; - this.maskableRegex = new RegExp(maskableRegex); - this.environmentDropdownMap = new WeakMap(); - - this.inputMap = { - id: { - selector: '.js-ci-variable-input-id', - default: '', - }, - variable_type: { - selector: '.js-ci-variable-input-variable-type', - default: 'env_var', - }, - key: { - selector: '.js-ci-variable-input-key', - default: '', - }, - secret_value: { - selector: '.js-ci-variable-input-value', - default: '', - }, - protected: { - selector: '.js-ci-variable-input-protected', - // use `attr` instead of `data` as we don't want the value to be - // converted. we need the value as a string. - default: $('.js-ci-variable-input-protected').attr('data-default'), - }, - masked: { - selector: '.js-ci-variable-input-masked', - // use `attr` instead of `data` as we don't want the value to be - // converted. we need the value as a string. - default: $('.js-ci-variable-input-masked').attr('data-default'), - }, - environment_scope: { - // We can't use a `.js-` class here because - // deprecated_jquery_dropdown replaces the <input> and doesn't copy over the class - // See https://gitlab.com/gitlab-org/gitlab-foss/issues/42458 - selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`, - default: '*', - }, - _destroy: { - selector: '.js-ci-variable-input-destroy', - default: '', - }, - }; - - this.secretValues = new SecretValues({ - container: this.$container[0], - valueSelector: '.js-row:not(:last-child) .js-secret-value', - placeholderSelector: '.js-row:not(:last-child) .js-secret-value-placeholder', - }); - } - - init() { - this.bindEvents(); - this.secretValues.init(); - } - - bindEvents() { - this.$container.find('.js-row').each((index, rowEl) => { - this.initRow(rowEl); - }); - - this.$container.on('click', '.js-row-remove-button', (e) => { - e.preventDefault(); - this.removeRow($(e.currentTarget).closest('.js-row')); - }); - - const inputSelector = Object.keys(this.inputMap) - .map((name) => this.inputMap[name].selector) - .join(','); - - // Remove any empty rows except the last row - this.$container.on('blur', inputSelector, (e) => { - const $row = $(e.currentTarget).closest('.js-row'); - - if ($row.is(':not(:last-child)') && !this.checkIfRowTouched($row)) { - this.removeRow($row); - } - }); - - this.$container.on('input trigger-change', inputSelector, (e) => { - // Always make sure there is an empty last row - const $lastRow = this.$container.find('.js-row').last(); - - if (this.checkIfRowTouched($lastRow)) { - this.insertRow($lastRow); - } - - // If masked, validate value against regex - this.validateMaskability($(e.currentTarget).closest('.js-row')); - }); - } - - initRow(rowEl) { - const $row = $(rowEl); - - // Reset the resizable textarea - $row.find(this.inputMap.secret_value.selector).css('height', ''); - - const $environmentSelect = $row.find('.js-variable-environment-toggle'); - if ($environmentSelect.length) { - const createItemDropdown = new CreateItemDropdown({ - $dropdown: $environmentSelect, - defaultToggleLabel: ALL_ENVIRONMENTS_STRING, - fieldName: `${this.formField}[variables_attributes][][environment_scope]`, - getData: (term, callback) => callback(this.getEnvironmentValues()), - createNewItemFromValue: createEnvironmentItem, - onSelect: () => { - // Refresh the other dropdowns in the variable list - // so they have the new value we just picked - this.refreshDropdownData(); - - $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change'); - }, - }); - - // Clear out any data that might have been left-over from the row clone - createItemDropdown.clearDropdown(); - - this.environmentDropdownMap.set($row[0], createItemDropdown); - } - } - - insertRow($row) { - const $rowClone = $row.clone(); - $rowClone.removeAttr('data-is-persisted'); - - // Reset the inputs to their defaults - Object.keys(this.inputMap).forEach((name) => { - const entry = this.inputMap[name]; - $rowClone.find(entry.selector).val(entry.default); - }); - - // Close any dropdowns - $rowClone.find('.dropdown-menu.show').each((index, $dropdown) => { - $dropdown.classList.remove('show'); - }); - - this.initRow($rowClone); - - $row.after($rowClone); - } - - removeRow(row) { - const $row = $(row); - const isPersisted = parseBoolean($row.attr('data-is-persisted')); - - if (isPersisted) { - $row.hide(); - $row - // eslint-disable-next-line no-underscore-dangle - .find(this.inputMap._destroy.selector) - .val(true); - } else { - $row.remove(); - } - - // Refresh the other dropdowns in the variable list - // so any value with the variable deleted is gone - this.refreshDropdownData(); - } - - checkIfRowTouched($row) { - return Object.keys(this.inputMap).some((name) => { - // Row should not qualify as touched if only switches have been touched - if (['protected', 'masked'].includes(name)) return false; - - const entry = this.inputMap[name]; - const $el = $row.find(entry.selector); - return $el.length && $el.val() !== entry.default; - }); - } - - validateMaskability($row) { - const invalidInputClass = 'gl-field-error-outline'; - - const variableValue = $row.find(this.inputMap.secret_value.selector).val(); - const isValueMaskable = this.maskableRegex.test(variableValue) || variableValue === ''; - const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true'; - - // Show a validation error if the user wants to mask an unmaskable variable value - $row - .find(this.inputMap.secret_value.selector) - .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); - $row - .find('.js-secret-value-placeholder') - .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); - $row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable); - } - - toggleEnableRow(isEnabled = true) { - this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); - this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); - } - - hideValues() { - this.secretValues.updateDom(false); - } - - getAllData() { - // Ignore the last empty row because we don't want to try persist - // a blank variable and run into validation problems. - const validRows = this.$container.find('.js-row').toArray().slice(0, -1); - - return validRows.map((rowEl) => { - const resultant = {}; - Object.keys(this.inputMap).forEach((name) => { - const entry = this.inputMap[name]; - const $input = $(rowEl).find(entry.selector); - if ($input.length) { - resultant[name] = $input.val(); - } - }); - - return resultant; - }); - } - - getEnvironmentValues() { - const valueMap = this.$container - .find(this.inputMap.environment_scope.selector) - .toArray() - .reduce( - (prevValueMap, envInput) => ({ - ...prevValueMap, - [envInput.value]: envInput.value, - }), - {}, - ); - - return Object.keys(valueMap).map(createEnvironmentItem); - } - - refreshDropdownData() { - this.$container.find('.js-row').each((index, rowEl) => { - const environmentDropdown = this.environmentDropdownMap.get(rowEl); - if (environmentDropdown) { - environmentDropdown.refreshData(); - } - }); - } -} 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 0ce11da658c..c609e05bbb7 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 @@ -1,10 +1,12 @@ <script> import { + GlAlert, GlButton, GlDrawer, GlFormCheckbox, GlFormCombobox, GlFormGroup, + GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, @@ -15,9 +17,14 @@ import { __, s__ } from '~/locale'; 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 { + allEnvironments, defaultVariableState, + DRAWER_EVENT_LABEL, + EDIT_VARIABLE_ACTION, ENVIRONMENT_SCOPE_LINK_TITLE, + EVENT_ACTION, EXPANDED_VARIABLES_NOTE, FLAG_LINK_TITLE, VARIABLE_ACTIONS, @@ -26,9 +33,13 @@ import { import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokenList } from './ci_variable_autocomplete_tokens'; -const i18n = { +const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL }); + +export const i18n = { addVariable: s__('CiVariables|Add Variable'), cancel: __('Cancel'), + defaultScope: allEnvironments.text, + editVariable: s__('CiVariables|Edit Variable'), environments: __('Environments'), environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, expandedField: s__('CiVariables|Expand variable reference'), @@ -44,39 +55,60 @@ const i18n = { protectedDescription: s__( 'CiVariables|Export variable to pipelines running on protected branches and tags only.', ), + valueFeedback: { + rawHelpText: s__('CiVariables|Variable value will be evaluated as raw string.'), + maskedReqsNotMet: s__( + 'CiVariables|This variable value does not meet the masking requirements.', + ), + }, + variableReferenceTitle: s__('CiVariables|Value might contain a variable reference'), + variableReferenceDescription: s__( + 'CiVariables|Unselect "Expand variable reference" if you want to use the variable value as a raw string.', + ), type: __('Type'), value: __('Value'), }; +const VARIABLE_REFERENCE_REGEX = /\$/; + export default { DRAWER_Z_INDEX, components: { CiEnvironmentsDropdown, + GlAlert, GlButton, GlDrawer, GlFormCheckbox, GlFormCombobox, GlFormGroup, + GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, GlLink, GlSprintf, }, - inject: ['environmentScopeLink'], + mixins: [trackingMixin], + inject: ['environmentScopeLink', 'isProtectedByDefault', 'maskableRawRegex', 'maskableRegex'], props: { areEnvironmentsLoading: { type: Boolean, required: true, }, + areScopedVariablesAvailable: { + type: Boolean, + required: false, + default: false, + }, environments: { type: Array, required: false, default: () => [], }, - hasEnvScopeQuery: { + hideEnvironmentScope: { type: Boolean, - required: true, + required: false, + default: false, }, mode: { type: String, @@ -85,22 +117,107 @@ export default { return VARIABLE_ACTIONS.includes(val); }, }, + selectedVariable: { + type: Object, + required: false, + default: () => {}, + }, }, data() { return { - key: defaultVariableState.key, - variableType: defaultVariableState.variableType, + variable: { ...defaultVariableState, ...this.selectedVariable }, + trackedValidationErrorProperty: undefined, }; }, computed: { + isValueMaskable() { + return this.variable.masked && !this.isValueMasked; + }, + isValueMasked() { + const regex = RegExp(this.maskedRegexToUse); + return regex.test(this.variable.value); + }, + canSubmit() { + return this.variable.key.length > 0 && this.isValueValid; + }, getDrawerHeaderHeight() { return getContentWrapperHeight(); }, + hasVariableReference() { + return this.isExpanded && VARIABLE_REFERENCE_REGEX.test(this.variable.value); + }, + isExpanded() { + return !this.variable.raw; + }, + isMaskedReqsMet() { + return !this.variable.masked || this.isValueMasked; + }, + isValueEmpty() { + return this.variable.value === ''; + }, + isValueValid() { + return this.isValueEmpty || this.isMaskedReqsMet; + }, + isEditing() { + return this.mode === EDIT_VARIABLE_ACTION; + }, + maskedRegexToUse() { + return this.variable.raw ? this.maskableRawRegex : this.maskableRegex; + }, + maskedReqsNotMetText() { + return !this.isMaskedReqsMet ? this.$options.i18n.valueFeedback.maskedReqsNotMet : ''; + }, + modalActionText() { + return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable; + }, + }, + watch: { + variable: { + handler() { + this.trackVariableValidationErrors(); + }, + deep: true, + }, + }, + mounted() { + if (this.isProtectedByDefault && !this.isEditing) { + this.variable = { ...this.variable, protected: true }; + } }, methods: { close() { this.$emit('close-form'); }, + getTrackingErrorProperty() { + if (this.isValueEmpty) { + return null; + } + + let property; + if (this.isValueMaskable) { + const supportedChars = this.maskedRegexToUse.replace('^', '').replace(/{(\d,)}\$/, ''); + const regex = new RegExp(supportedChars, 'g'); + property = this.variable.value.replace(regex, ''); + } else if (this.hasVariableReference) { + property = '$'; + } + + return property; + }, + setRaw(expanded) { + this.variable = { ...this.variable, raw: !expanded }; + }, + submit() { + this.$emit(this.isEditing ? 'update-variable' : 'add-variable', this.variable); + this.close(); + }, + trackVariableValidationErrors() { + const property = this.getTrackingErrorProperty(); + if (property && !this.trackedValidationErrorProperty) { + this.track(EVENT_ACTION, { property }); + this.trackedValidationErrorProperty = property; + } + }, }, awsTokenList, flagLink: helpPagePath('ci/variables/index', { @@ -119,20 +236,25 @@ export default { @close="close" > <template #title> - <h2 class="gl-m-0">{{ $options.i18n.addVariable }}</h2> + <h2 class="gl-m-0">{{ modalActionText }}</h2> </template> <gl-form-group :label="$options.i18n.type" label-for="ci-variable-type" - class="gl-border-none gl-mb-n5" + class="gl-border-none" + :class="{ + 'gl-mb-n5': !hideEnvironmentScope, + 'gl-mb-n1': hideEnvironmentScope, + }" > <gl-form-select id="ci-variable-type" - v-model="variableType" + v-model="variable.variableType" :options="$options.variableOptions" /> </gl-form-group> <gl-form-group + v-if="!hideEnvironmentScope" class="gl-border-none gl-mb-n5" label-for="ci-variable-env" data-testid="environment-scope" @@ -154,11 +276,18 @@ export default { </div> </template> <ci-environments-dropdown + v-if="areScopedVariablesAvailable" class="gl-mb-5" + has-env-scope-query :are-environments-loading="areEnvironmentsLoading" :environments="environments" - :has-env-scope-query="hasEnvScopeQuery" - selected-environment-scope="" + :selected-environment-scope="variable.environmentScope" + /> + <gl-form-input + v-else + :value="$options.i18n.defaultScope" + class="gl-w-full gl-mb-5" + readonly /> </gl-form-group> <gl-form-group class="gl-border-none gl-mb-n8"> @@ -177,17 +306,21 @@ export default { </gl-link> </div> </template> - <gl-form-checkbox data-testid="ci-variable-protected-checkbox"> + <gl-form-checkbox v-model="variable.protected" data-testid="ci-variable-protected-checkbox"> {{ $options.i18n.protectedField }} <p class="gl-text-secondary"> {{ $options.i18n.protectedDescription }} </p> </gl-form-checkbox> - <gl-form-checkbox data-testid="ci-variable-masked-checkbox"> + <gl-form-checkbox v-model="variable.masked" data-testid="ci-variable-masked-checkbox"> {{ $options.i18n.maskedField }} <p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p> </gl-form-checkbox> - <gl-form-checkbox data-testid="ci-variable-expanded-checkbox"> + <gl-form-checkbox + data-testid="ci-variable-expanded-checkbox" + :checked="isExpanded" + @change="setRaw" + > {{ $options.i18n.expandedField }} <p class="gl-text-secondary"> <gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary"> @@ -199,34 +332,56 @@ export default { </gl-form-checkbox> </gl-form-group> <gl-form-combobox - v-model="key" + v-model="variable.key" :token-list="$options.awsTokenList" :label-text="$options.i18n.key" class="gl-border-none gl-pb-0! gl-mb-n5" - data-testid="pipeline-form-ci-variable-key" + data-testid="ci-variable-key" data-qa-selector="ci_variable_key_field" /> <gl-form-group :label="$options.i18n.value" label-for="ci-variable-value" class="gl-border-none gl-mb-n2" + data-testid="ci-variable-value-label" + :invalid-feedback="maskedReqsNotMetText" + :state="isValueValid" > <gl-form-textarea id="ci-variable-value" + v-model="variable.value" class="gl-border-none gl-font-monospace!" rows="3" max-rows="10" - data-testid="pipeline-form-ci-variable-value" + data-testid="ci-variable-value" data-qa-selector="ci_variable_value_field" spellcheck="false" /> + <p v-if="variable.raw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip"> + {{ $options.i18n.valueFeedback.rawHelpText }} + </p> </gl-form-group> + <gl-alert + v-if="hasVariableReference" + :title="$options.i18n.variableReferenceTitle" + :dismissible="false" + variant="warning" + class="gl-mx-4 gl-pl-9! gl-border-bottom-0" + data-testid="has-variable-reference-alert" + > + {{ $options.i18n.variableReferenceDescription }} + </gl-alert> <div class="gl-display-flex gl-justify-content-end"> - <gl-button category="primary" class="gl-mr-3" data-testid="cancel-button" @click="close" + <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" >{{ $options.i18n.cancel }} </gl-button> - <gl-button category="primary" variant="confirm" data-testid="confirm-button" - >{{ $options.i18n.addVariable }} + <gl-button + category="primary" + variant="confirm" + :disabled="!canSubmit" + data-testid="ci-variable-confirm-btn" + @click="submit" + >{{ modalActionText }} </gl-button> </div> </gl-drawer> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index f4e1da9b34f..482f6da5617 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -139,7 +139,10 @@ export default { <ci-variable-drawer v-if="showDrawer" :are-environments-loading="areEnvironmentsLoading" - :has-env-scope-query="hasEnvScopeQuery" + :are-scoped-variables-available="areScopedVariablesAvailable" + :environments="environments" + :hide-environment-scope="hideEnvironmentScope" + :selected-variable="selectedVariable" :mode="mode" v-on="$listeners" @close-form="closeForm" 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 9786f25ed87..3d5ed327dc7 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 @@ -2,7 +2,8 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; +import { reportMessageToSentry } from '~/ci/utils'; +import { mapEnvironmentNames } from '../utils'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, 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 a14cd1e387a..3d62313815c 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 @@ -32,24 +32,20 @@ export default { label: s__('CiVariables|Key'), tdClass: 'text-plain', sortable: true, + thClass: 'gl-w-40p', }, { key: 'value', label: s__('CiVariables|Value'), }, { - key: 'Attributes', - label: s__('CiVariables|Attributes'), - thClass: 'gl-w-40p', - }, - { key: 'environmentScope', label: s__('CiVariables|Environments'), }, { key: 'actions', label: __('Actions'), - thClass: 'gl-text-right', + thClass: 'gl-text-right gl-w-15', }, ], inheritedVarsFields: [ @@ -287,7 +283,7 @@ export default { </template> <template #cell(key)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <span :id="`ci-variable-key-${item.id}`" @@ -298,16 +294,28 @@ export default { v-gl-tooltip category="tertiary" icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" + class="gl-my-n2 gl-ml-2" + size="small" :title="__('Copy key')" :data-clipboard-text="item.key" :aria-label="__('Copy to clipboard')" /> </div> + <div data-testid="ci-variable-table-row-attributes" class="gl-mt-2"> + <gl-badge + v-for="attribute in item.attributes" + :key="`${item.key}-${attribute}`" + class="gl-mr-2" + variant="info" + size="sm" + > + {{ attribute }} + </gl-badge> + </div> </template> <template v-if="!isInheritedGroupVars" #cell(value)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span> <span @@ -321,29 +329,17 @@ export default { v-gl-tooltip category="tertiary" icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" + class="gl-my-n2 gl-ml-2" + size="small" :title="__('Copy value')" :data-clipboard-text="item.value" :aria-label="__('Copy to clipboard')" /> </div> </template> - <template #cell(attributes)="{ item }"> - <span data-testid="ci-variable-table-row-attributes"> - <gl-badge - v-for="attribute in item.attributes" - :key="`${item.key}-${attribute}`" - class="gl-mr-2" - variant="info" - size="sm" - > - {{ attribute }} - </gl-badge> - </span> - </template> <template #cell(environmentScope)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <span :id="`ci-variable-env-${item.id}`" @@ -354,7 +350,8 @@ export default { v-gl-tooltip category="tertiary" icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" + class="gl-my-n2 gl-ml-2" + size="small" :title="__('Copy environment')" :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)" :aria-label="__('Copy to clipboard')" @@ -363,7 +360,7 @@ export default { </template> <template v-if="isInheritedGroupVars" #cell(group)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <gl-link :id="`ci-variable-group-${item.id}`" diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index 825b39e0cf9..fc37b62299d 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -46,6 +46,7 @@ export const AWS_TIP_MESSAGE = s__( ); export const EVENT_LABEL = 'ci_variable_modal'; +export const DRAWER_EVENT_LABEL = 'ci_variable_drawer'; export const EVENT_ACTION = 'validation_error'; // AWS TOKEN CONSTANTS diff --git a/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js deleted file mode 100644 index fdbefd8c313..00000000000 --- a/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js +++ /dev/null @@ -1,25 +0,0 @@ -import $ from 'jquery'; -import VariableList from './ci_variable_list'; - -// Used for the variable list on scheduled pipeline edit page -export default function setupNativeFormVariableList({ container, formField = 'variables' }) { - const $container = $(container); - - const variableList = new VariableList({ - container: $container, - formField, - }); - variableList.init(); - - // Clear out the names in the empty last row so it - // doesn't get submitted and throw validation errors - $container.closest('form').on('submit trigger-submit', () => { - const $lastRow = $container.find('.js-row').last(); - - const isTouched = variableList.checkIfRowTouched($lastRow); - if (!isTouched) { - $lastRow.find('input, textarea').attr('name', ''); - $lastRow.find('select').attr('name', ''); - } - }); -} diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js index eeca69274ce..1faa97a5f73 100644 --- a/app/assets/javascripts/ci/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci/ci_variable_list/utils.js @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/browser'; import { uniq } from 'lodash'; import { allEnvironments } from './constants'; @@ -49,12 +48,3 @@ export const convertEnvironmentScope = (environmentScope = '') => { export const mapEnvironmentNames = (nodes = []) => { return nodes.map((env) => env.name); }; - -export const reportMessageToSentry = (component, message, context) => { - Sentry.withScope((scope) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - scope.setContext('Vue data', context); - scope.setTag('component', component); - Sentry.captureMessage(message); - }); -}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/ci/common/pipelines_table.vue index c03085e6419..807128d2341 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/ci/common/pipelines_table.vue @@ -4,16 +4,16 @@ import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; -import eventHub from '../../event_hub'; -import { TRACKING_CATEGORIES } from '../../constants'; -import PipelineOperations from './pipeline_operations.vue'; -import PipelineStopModal from './pipeline_stop_modal.vue'; -import PipelineTriggerer from './pipeline_triggerer.vue'; -import PipelineUrl from './pipeline_url.vue'; -import PipelinesStatusBadge from './pipelines_status_badge.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; +import eventHub from '~/ci/event_hub'; +import PipelineOperations from '../pipelines_page/components/pipeline_operations.vue'; +import PipelineStopModal from '../pipelines_page/components/pipeline_stop_modal.vue'; +import PipelineTriggerer from '../pipelines_page/components/pipeline_triggerer.vue'; +import PipelineUrl from '../pipelines_page/components/pipeline_url.vue'; +import PipelinesStatusBadge from '../pipelines_page/components/pipelines_status_badge.vue'; const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; const DEFAULT_TH_CLASSES = @@ -95,10 +95,10 @@ export default { }, { key: 'triggerer', - label: s__('Pipeline|Triggerer'), + label: s__('Pipeline|Created by'), thClass: DEFAULT_TH_CLASSES, tdClass: `${this.tdClasses} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', + columnClass: 'gl-w-15p', thAttr: { 'data-testid': 'triggerer-th' }, }, { @@ -113,7 +113,7 @@ export default { key: 'actions', thClass: DEFAULT_TH_CLASSES, tdClass: this.tdClasses, - columnClass: 'gl-w-15p', + columnClass: 'gl-w-20p', thAttr: { 'data-testid': 'actions-th' }, }, ]; diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/ci/common/private/job_action_component.vue index ffb6ab71b22..f649750ce8a 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/ci/common/private/job_action_component.vue @@ -5,7 +5,7 @@ import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; -import { reportToSentry } from '../../utils'; +import { reportToSentry } from '~/ci/utils'; /** * Renders either a cancel, retry or play icon button and handles the post request diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/ci/common/private/job_links_layer.vue index ef24694e494..59260ca3f81 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/ci/common/private/job_links_layer.vue @@ -1,8 +1,8 @@ <script> import { memoize } from 'lodash'; -import { reportToSentry } from '../../utils'; -import { parseData } from '../parsing_utils'; -import LinksInner from './links_inner.vue'; +import { reportToSentry } from '~/ci/utils'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; const parseForLinksBare = (pipeline) => { const arrayOfJobs = pipeline.flatMap(({ groups }) => groups); diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/ci/common/private/job_name_component.vue index 1c7f5a7476d..1c7f5a7476d 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue +++ b/app/assets/javascripts/ci/common/private/job_name_component.vue diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue new file mode 100644 index 00000000000..86ccdb2c87b --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue @@ -0,0 +1,99 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { + OPERATOR_IS, + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, + TOKEN_TITLE_JOBS_RUNNER_TYPE, + TOKEN_TYPE_JOBS_RUNNER_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import JobStatusToken from './tokens/job_status_token.vue'; +import JobRunnerTypeToken from './tokens/job_runner_type_token.vue'; + +export default { + components: { + GlFilteredSearch, + }, + mixins: [glFeatureFlagsMixin()], + props: { + queryString: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + tokens() { + const tokens = [ + { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + token: JobStatusToken, + operators: OPERATORS_IS, + }, + ]; + + if (this.glFeatures.adminJobsFilterRunnerType) { + tokens.push({ + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + title: TOKEN_TITLE_JOBS_RUNNER_TYPE, + unique: true, + token: JobRunnerTypeToken, + operators: OPERATORS_IS, + }); + } + + return tokens; + }, + filteredSearchValue() { + return Object.entries(this.queryString || {}).reduce( + (acc, [queryStringKey, queryStringValue]) => { + switch (queryStringKey) { + case 'statuses': + return [ + ...acc, + { + type: TOKEN_TYPE_STATUS, + value: { data: queryStringValue, operator: OPERATOR_IS }, + }, + ]; + case 'runnerTypes': + if (!this.glFeatures.adminJobsFilterRunnerType) { + return acc; + } + + return [ + ...acc, + { + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + value: { data: queryStringValue, operator: OPERATOR_IS }, + }, + ]; + default: + return acc; + } + }, + [], + ); + }, + }, + methods: { + onSubmit(filters) { + this.$emit('filterJobsBySearch', filters); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + :placeholder="s__('Jobs|Filter jobs')" + :available-tokens="tokens" + :value="filteredSearchValue" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js b/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js new file mode 100644 index 00000000000..86b8290864c --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js @@ -0,0 +1,23 @@ +export const jobStatusValues = [ + 'CANCELED', + 'CREATED', + 'FAILED', + 'MANUAL', + 'SUCCESS', + 'PENDING', + 'PREPARING', + 'RUNNING', + 'SCHEDULED', + 'SKIPPED', + 'WAITING_FOR_RESOURCE', +]; + +export const JOB_RUNNER_TYPE_INSTANCE_TYPE = 'INSTANCE_TYPE'; +export const JOB_RUNNER_TYPE_GROUP_TYPE = 'GROUP_TYPE'; +export const JOB_RUNNER_TYPE_PROJECT_TYPE = 'PROJECT_TYPE'; + +export const jobRunnerTypeValues = [ + JOB_RUNNER_TYPE_INSTANCE_TYPE, + JOB_RUNNER_TYPE_GROUP_TYPE, + JOB_RUNNER_TYPE_PROJECT_TYPE, +]; diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue new file mode 100644 index 00000000000..5bd3693b4d9 --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue @@ -0,0 +1,79 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + JOB_RUNNER_TYPE_INSTANCE_TYPE, + JOB_RUNNER_TYPE_GROUP_TYPE, + JOB_RUNNER_TYPE_PROJECT_TYPE, +} from '../constants'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + runnerTypes() { + return [ + { + class: 'ci-runner-runner-type-instance', + icon: 'users', + text: s__('Runners|Instance'), + value: JOB_RUNNER_TYPE_INSTANCE_TYPE, + }, + { + class: 'ci-runner-runner-type-group', + icon: 'group', + text: s__('Runners|Group'), + value: JOB_RUNNER_TYPE_GROUP_TYPE, + }, + { + class: 'ci-runner-runner-type-project', + icon: 'project', + text: s__('Runners|Project'), + value: JOB_RUNNER_TYPE_PROJECT_TYPE, + }, + ]; + }, + findActiveRunnerType() { + return this.runnerTypes.find((runnerType) => runnerType.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveRunnerType.class"> + <gl-icon :name="findActiveRunnerType.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveRunnerType.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(runnerType, index) in runnerTypes" + :key="index" + :value="runnerType.value" + > + <div class="gl-display-flex" :class="runnerType.class"> + <gl-icon :name="runnerType.icon" class="gl-mr-3" /> + <span>{{ runnerType.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue index aad86ded80a..aad86ded80a 100644 --- a/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js b/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js new file mode 100644 index 00000000000..43c0da72d3d --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js @@ -0,0 +1,22 @@ +import { jobStatusValues, jobRunnerTypeValues } from './constants'; + +// validates query string used for filtered search +// on jobs table to ensure GraphQL query is called correctly +export const validateQueryString = (queryStringObj) => { + return Object.entries(queryStringObj).reduce((acc, [queryStringKey, queryStringValue]) => { + switch (queryStringKey) { + case 'statuses': { + const statusValue = queryStringValue.toUpperCase(); + const statusValueValid = jobStatusValues.includes(statusValue); + return statusValueValid ? { ...acc, statuses: statusValue } : acc; + } + case 'runnerTypes': { + const runnerTypesValue = queryStringValue.toUpperCase(); + const runnerTypesValueValid = jobRunnerTypeValues.includes(runnerTypesValue); + return runnerTypesValueValid ? { ...acc, runnerTypes: runnerTypesValue } : acc; + } + default: + return acc; + } + }, null); +}; diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js new file mode 100644 index 00000000000..93c2504dd5d --- /dev/null +++ b/app/assets/javascripts/ci/constants.js @@ -0,0 +1,51 @@ +import { __, s__ } from '~/locale'; + +export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; + +export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs'); +export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline'); + +export const FILTER_TAG_IDENTIFIER = 'tag'; + +export const JOB_GRAPHQL_ERRORS = { + jobMutationErrorText: __('There was an error running the job. Please try again.'), + jobQueryErrorText: __('There was an error fetching the job.'), +}; + +export const ICONS = { + TAG: 'tag', + MR: 'git-merge', + BRANCH: 'branch', + RETRY: 'retry', + SUCCESS: 'success', +}; + +export const SUCCESS_STATUS = 'SUCCESS'; +export const PASSED_STATUS = 'passed'; +export const MANUAL_STATUS = 'manual'; + +// Constants for the ID and IID selection dropdown +export const PipelineKeyOptions = [ + { + text: __('Show Pipeline ID'), + label: __('Pipeline ID'), + value: 'id', + }, + { + text: __('Show Pipeline IID'), + label: __('Pipeline IID'), + value: 'iid', + }, +]; + +export const RAW_TEXT_WARNING = s__( + 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', +); + +export const TRACKING_CATEGORIES = { + table: 'pipelines_table_component', + tabs: 'pipelines_filter_tabs', + search: 'pipelines_filtered_search', + failed: 'pipeline_failed_jobs_tab', + tests: 'pipeline_tests_tab', +}; diff --git a/app/assets/javascripts/jobs/components/table/event_hub.js b/app/assets/javascripts/ci/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/jobs/components/table/event_hub.js +++ b/app/assets/javascripts/ci/event_hub.js diff --git a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue index 27ee1b794f6..f02d59af1d9 100644 --- a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue +++ b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue @@ -3,7 +3,7 @@ import { produce } from 'immer'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { reportMessageToSentry } from '~/ci/ci_variable_list/utils'; +import { reportMessageToSentry } from '~/ci/utils'; import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; import getInheritedCiVariables from '../graphql/queries/inherited_ci_variables.query.graphql'; diff --git a/app/assets/javascripts/jobs/components/job/empty_state.vue b/app/assets/javascripts/ci/job_details/components/empty_state.vue index d0a39025807..5756d4a71df 100644 --- a/app/assets/javascripts/jobs/components/job/empty_state.vue +++ b/app/assets/javascripts/ci/job_details/components/empty_state.vue @@ -1,6 +1,6 @@ <script> import { GlLink } from '@gitlab/ui'; -import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; +import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; export default { components: { diff --git a/app/assets/javascripts/jobs/components/job/environments_block.vue b/app/assets/javascripts/ci/job_details/components/environments_block.vue index 4046e1ade82..4046e1ade82 100644 --- a/app/assets/javascripts/jobs/components/job/environments_block.vue +++ b/app/assets/javascripts/ci/job_details/components/environments_block.vue diff --git a/app/assets/javascripts/jobs/components/job/erased_block.vue b/app/assets/javascripts/ci/job_details/components/erased_block.vue index a815689659e..a815689659e 100644 --- a/app/assets/javascripts/jobs/components/job/erased_block.vue +++ b/app/assets/javascripts/ci/job_details/components/erased_block.vue diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/ci/job_details/components/job_header.vue index 1adda905006..13f3eebd447 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/ci/job_details/components/job_header.vue @@ -4,16 +4,9 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; -import CiBadgeLink from './ci_badge_link.vue'; -import TimeagoTooltip from './time_ago_tooltip.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -/** - * Renders header component for job and pipeline page based on UI mockups - * - * Used in: - * - job show page - * - pipeline show page - */ export default { components: { CiBadgeLink, @@ -33,33 +26,21 @@ export default { type: Object, required: true, }, - itemName: { + name: { type: String, required: true, }, - itemId: { - type: String, - required: false, - default: '', - }, time: { type: String, required: true, }, user: { type: Object, - required: false, - default: () => ({}), - }, - hasSidebarButton: { - type: Boolean, - required: false, - default: false, + required: true, }, shouldRenderTriggeredLabel: { type: Boolean, - required: false, - default: true, + required: true, }, }, @@ -92,13 +73,6 @@ export default { message() { return this.user?.status?.message; }, - item() { - if (this.itemId) { - return `${this.itemName} #${this.itemId}`; - } - - return this.itemName; - }, userId() { return isGid(this.user?.id) ? getIdFromGraphQLId(this.user?.id) : this.user?.id; }, @@ -114,13 +88,16 @@ export default { </script> <template> - <header class="page-content-header gl-md-display-flex gl-min-h-7" data-testid="ci-header-content"> + <header + class="page-content-header gl-md-display-flex gl-min-h-7" + data-testid="job-header-content" + > <section class="header-main-content gl-mr-3"> <ci-badge-link class="gl-mr-3" :status="status" /> - <strong data-testid="ci-header-item-text">{{ item }}</strong> + <strong data-testid="job-name">{{ name }}</strong> - <template v-if="shouldRenderTriggeredLabel">{{ __('triggered') }}</template> + <template v-if="shouldRenderTriggeredLabel">{{ __('started') }}</template> <template v-else>{{ __('created') }}</template> <timeago-tooltip :time="time" /> @@ -158,11 +135,10 @@ export default { </section> <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> - <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex"> + <section v-if="$slots.default" data-testid="job-header-action-buttons" class="gl-display-flex"> <slot></slot> </section> <gl-button - v-if="hasSidebarButton" class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle" icon="chevron-double-lg-left" :aria-label="__('Toggle sidebar')" diff --git a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue index efd4eed2a9f..419efcba46d 100644 --- a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue +++ b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue @@ -3,6 +3,7 @@ import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitla import { scrollToElement, backOff } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __, s__, sprintf } from '~/locale'; +import { compactJobLog } from '~/ci/job_details/utils'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -130,17 +131,7 @@ export default { if (!this.searchTerm) return; - const compactedLog = []; - - this.jobLog.forEach((obj) => { - if (obj.lines && obj.lines.length > 0) { - compactedLog.push(...obj.lines); - } - - if (!obj.lines && obj.content.length > 0) { - compactedLog.push(obj); - } - }); + const compactedLog = compactJobLog(this.jobLog); compactedLog.forEach((line) => { const lineText = line.content[0].text; @@ -155,7 +146,7 @@ export default { // BE returns zero based index, we need to add one to match the line numbers in the DOM const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`; - const logLine = document.querySelector(`.js-line ${firstSearchResult}`); + const logLine = document.querySelector(`.log-line ${firstSearchResult}`); if (logLine) { setTimeout(() => scrollToElement(logLine)); @@ -224,7 +215,7 @@ export default { :aria-label="$options.i18n.showRawButtonLabel" :href="rawPath" data-testid="job-raw-link-controller" - icon="doc-text" + icon="doc-code" /> <!-- eo links --> diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue index 13716b4d391..39c612bc600 100644 --- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue +++ b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue @@ -27,11 +27,24 @@ export default { badgeDuration() { return this.section.line && this.section.line.section_duration; }, + highlightedLines() { + return this.searchResults.map((result) => result.lineNumber); + }, + headerIsHighlighted() { + const { + line: { lineNumber }, + } = this.section; + + return this.highlightedLines.includes(lineNumber); + }, }, methods: { handleOnClickCollapsibleLine(section) { this.$emit('onClickCollapsibleLine', section); }, + lineIsHighlighted({ lineNumber }) { + return this.highlightedLines.includes(lineNumber); + }, }, }; </script> @@ -42,6 +55,7 @@ export default { :duration="badgeDuration" :path="jobLogEndpoint" :is-closed="section.isClosed" + :is-highlighted="headerIsHighlighted" @toggleLine="handleOnClickCollapsibleLine(section)" /> <template v-if="!section.isClosed"> @@ -50,7 +64,7 @@ export default { :key="line.offset" :line="line" :path="jobLogEndpoint" - :search-results="searchResults" + :is-highlighted="lineIsHighlighted(line)" /> </template> </div> diff --git a/app/assets/javascripts/jobs/components/log/duration_badge.vue b/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue index 54b76fd9edd..54b76fd9edd 100644 --- a/app/assets/javascripts/jobs/components/log/duration_badge.vue +++ b/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/ci/job_details/components/log/line.vue index 3c9c5097122..fa4a12b3dd3 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line.vue @@ -1,7 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { getLocationHash } from '~/lib/utils/url_utility'; -import { linkRegex } from '../../utils'; +import { linkRegex } from './utils'; import LineNumber from './line_number.vue'; export default { @@ -15,14 +15,14 @@ export default { type: String, required: true, }, - searchResults: { - type: Array, + isHighlighted: { + type: Boolean, required: false, - default: () => [], + default: false, }, }, render(h, { props }) { - const { line, path, searchResults } = props; + const { line, path, isHighlighted } = props; const chars = line.content.map((content) => { return h( @@ -52,31 +52,21 @@ export default { ); }); - let applyHighlight = false; - - if (searchResults.length > 0) { - const linesToHighlight = searchResults.map((searchResultLine) => searchResultLine.lineNumber); - - linesToHighlight.forEach((num) => { - if (num === line.lineNumber) { - applyHighlight = true; - } - }); - } + let applyHashHighlight = false; if (window.location.hash) { const hash = getLocationHash(); const lineToMatch = `L${line.lineNumber + 1}`; if (hash === lineToMatch) { - applyHighlight = true; + applyHashHighlight = true; } } return h( 'div', { - class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-700' : ''], + class: ['js-line', 'log-line', { 'gl-bg-gray-700': isHighlighted || applyHashHighlight }], }, [ h(LineNumber, { diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue index 115b090b32a..e647ab4ac0b 100644 --- a/app/assets/javascripts/jobs/components/log/line_header.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -28,17 +28,29 @@ export default { required: false, default: '', }, + isHighlighted: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + applyHashHighlight: false, + }; }, computed: { iconName() { return this.isClosed ? 'chevron-lg-right' : 'chevron-lg-down'; }, - applyHighlight() { - const hash = getLocationHash(); - const lineToMatch = `L${this.line.lineNumber + 1}`; + }, + mounted() { + const hash = getLocationHash(); + const lineToMatch = `L${this.line.lineNumber + 1}`; - return hash === lineToMatch; - }, + if (hash === lineToMatch) { + this.applyHashHighlight = true; + } }, methods: { handleOnClick() { @@ -50,12 +62,12 @@ export default { <template> <div - class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start" - :class="{ 'gl-bg-gray-700': applyHighlight }" + class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start gl-relative" + :class="{ 'gl-bg-gray-700': isHighlighted || applyHashHighlight }" role="button" @click="handleOnClick" > - <gl-icon :name="iconName" class="arrow position-absolute" /> + <gl-icon :name="iconName" class="arrow gl-absolute gl-top-2" /> <line-number :line-number="line.lineNumber" :path="path" /> <span v-for="(content, i) in line.content" diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/ci/job_details/components/log/line_number.vue index 7ca9154d2fe..7ca9154d2fe 100644 --- a/app/assets/javascripts/jobs/components/log/line_number.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_number.vue diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/ci/job_details/components/log/log.vue index 6a1101bf297..fb6a6a58074 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/ci/job_details/components/log/log.vue @@ -26,6 +26,9 @@ export default { 'isJobLogComplete', 'isScrolledToBottomBeforeReceivingJobLog', ]), + highlightedLines() { + return this.searchResults.map((result) => result.lineNumber); + }, }, updated() { this.$nextTick(() => { @@ -68,11 +71,14 @@ export default { }, 0); } }, + isHighlighted({ lineNumber }) { + return this.highlightedLines.includes(lineNumber); + }, }, }; </script> <template> - <code class="job-log d-block" data-qa-selector="job_log_content"> + <code class="job-log d-block" data-testid="job-log-content"> <template v-for="(section, index) in jobLog"> <collapsible-log-section v-if="section.isHeader" @@ -87,7 +93,7 @@ export default { :key="section.offset" :line="section" :path="jobLogEndpoint" - :search-results="searchResults" + :is-highlighted="isHighlighted(section)" /> </template> diff --git a/app/assets/javascripts/ci/job_details/components/log/utils.js b/app/assets/javascripts/ci/job_details/components/log/utils.js new file mode 100644 index 00000000000..1ccecf3eb53 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/utils.js @@ -0,0 +1,12 @@ +/** + * capture anything starting with http:// or https:// + * https?:\/\/ + * + * up until a disallowed character or whitespace + * [^"<>()\\^`{|}\s]+ + * + * and a disallowed character or whitespace, including non-ending chars .,:;!? + * [^"<>()\\^`{|}\s.,:;!?] + */ +export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g; +export default { linkRegex }; diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue index 356d65e1d14..1232ffffb57 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue @@ -14,16 +14,16 @@ import { fetchPolicies } from '~/lib/graphql'; import { createAlert } from '~/alert'; import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants'; +import { JOB_GRAPHQL_ERRORS } from '~/ci/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { s__ } from '~/locale'; -import { reportMessageToSentry } from '~/jobs/utils'; -import GetJob from './graphql/queries/get_job.query.graphql'; -import playJobWithVariablesMutation from './graphql/mutations/job_play_with_variables.mutation.graphql'; -import retryJobWithVariablesMutation from './graphql/mutations/job_retry_with_variables.mutation.graphql'; +import { reportMessageToSentry } from '~/ci/utils'; +import GetJob from '../graphql/queries/get_job.query.graphql'; +import playJobWithVariablesMutation from '../graphql/mutations/job_play_with_variables.mutation.graphql'; +import retryJobWithVariablesMutation from '../graphql/mutations/job_retry_with_variables.mutation.graphql'; -// This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue +// This component is a port of ~/ci/job_details/components/legacy_manual_variables_form.vue // It is meant to fetch/update the job information via GraphQL instead of REST API. export default { diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue index a78cacf110f..4c81a9bd033 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue @@ -75,7 +75,7 @@ export default { data-testid="artifacts-remove-timeline" > <span v-if="isExpired">{{ $options.i18n.expiredText }}</span> - <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content"> + <span v-if="willExpire" data-testid="artifacts-unlocked-message-content"> {{ $options.i18n.willExpireText }} </span> <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> @@ -89,7 +89,7 @@ export default { </gl-link> </p> <p v-else-if="isLocked" class="build-detail-row"> - <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content"> + <span data-testid="artifacts-locked-message-content"> {{ $options.i18n.lockedText }} </span> </p> @@ -112,8 +112,7 @@ export default { <gl-button v-if="artifact.browse_path" :href="artifact.browse_path" - data-testid="browse-artifacts" - data-qa-selector="browse_artifacts_button" + data-testid="browse-artifacts-button" >{{ $options.i18n.browseText }}</gl-button > </gl-button-group> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue new file mode 100644 index 00000000000..95616a4c706 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue @@ -0,0 +1,54 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + components: { + ClipboardButton, + GlLink, + }, + props: { + commit: { + type: Object, + required: true, + }, + mergeRequest: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <div> + <p class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-gap-2 gl-mb-0"> + <span class="gl-display-flex gl-font-weight-bold">{{ __('Commit') }}</span> + + <gl-link + :href="commit.commit_path" + class="gl-text-blue-500! gl-font-monospace" + data-testid="commit-sha" + > + {{ commit.short_id }} + </gl-link> + + <clipboard-button + :text="commit.id" + :title="__('Copy commit SHA')" + category="tertiary" + size="small" + class="gl-align-self-center" + /> + + <span v-if="mergeRequest"> + {{ __('in') }} + <gl-link :href="mergeRequest.path" class="gl-text-blue-500!" data-testid="link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> + </p> + + <p class="gl-mb-0">{{ commit.title }}</p> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue new file mode 100644 index 00000000000..a87f4b8467e --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue @@ -0,0 +1,34 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + externalLinks: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <div> + <div class="title gl-font-weight-bold">{{ s__('Job|External links') }}</div> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <li v-for="(externalLink, index) in externalLinks" :key="index"> + <gl-link + :href="externalLink.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="gl-text-blue-600!" + > + <gl-icon name="external-link" class="flex-shrink-0" /> + {{ externalLink.label }} + </gl-link> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue index 097ab3b4cf6..8e87f118fa4 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -40,7 +40,7 @@ export default { }, classes() { return { - retried: this.job.retried, + 'retried gl-text-secondary': this.job.retried, 'gl-font-weight-bold': this.isActive, }; }, @@ -57,7 +57,7 @@ export default { v-gl-tooltip.left.viewport :href="job.status.details_path" :title="tooltipText" - class="gl-display-flex gl-align-items-center" + class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" :data-testid="dataTestId" > <gl-icon @@ -67,11 +67,11 @@ export default { :size="14" /> - <ci-icon :status="job.status" class="gl-mr-2" :size="14" /> + <ci-icon :status="job.status" class="gl-mr-3" :size="14" /> <span class="gl-text-truncate gl-w-full">{{ jobName }}</span> - <gl-icon v-if="job.retried" name="retry" /> + <gl-icon v-if="job.retried" name="retry" class="gl-mr-4" /> </gl-link> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue index a3f1a2c4be8..58e49c71830 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlModal } from '@gitlab/ui'; -import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants'; +import { __, s__ } from '~/locale'; export default { name: 'JobRetryForwardDeploymentModal', @@ -9,7 +9,15 @@ export default { GlModal, }, i18n: { - ...JOB_RETRY_FORWARD_DEPLOYMENT_MODAL, + cancel: __('Cancel'), + info: s__( + `Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. + Retrying this job could result in overwriting the environment with the older source code.`, + ), + areYouSure: s__('Jobs|Are you sure you want to proceed?'), + moreInfo: __('More information'), + primaryText: __('Retry job'), + title: s__('Jobs|Are you sure you want to retry this job?'), }, inject: { retryOutdatedJobDocsUrl: { diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue index 87c47f592aa..26676123dc3 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue @@ -2,12 +2,14 @@ import { GlButton, GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; -import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; +import { s__ } from '~/locale'; export default { name: 'JobSidebarRetryButton', i18n: { - ...JOB_SIDEBAR_COPY, + retryJobLabel: s__('Job|Retry'), + runAgainJobButtonLabel: s__('Job|Run again'), + updateVariables: s__('Job|Update CI/CD variables'), }, components: { GlButton, @@ -65,6 +67,7 @@ export default { icon="retry" category="primary" placement="right" + positioning-strategy="fixed" variant="confirm" :items="dropdownItems" /> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue index df64b6422c7..18bd2593c2a 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue @@ -24,7 +24,8 @@ export default { }; </script> <template> - <div class="builds-container"> + <div class="block builds-container"> + <b class="gl-display-flex gl-mb-2 gl-font-weight-semibold">{{ __('Related jobs') }}</b> <job-container-item v-for="job in jobs" :key="job.id" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue index 92e1557ada2..7f2f4fc0331 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -1,11 +1,12 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import { forwardDeploymentFailureModalId } from '~/ci/constants'; +import { filterAnnotations } from '~/ci/job_details/utils'; import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; +import ExternalLinksBlock from './external_links_block.vue'; import JobsContainer from './jobs_container.vue'; import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; @@ -15,22 +16,17 @@ import TriggerBlock from './trigger_block.vue'; export default { name: 'JobSidebar', - i18n: { - ...JOB_SIDEBAR_COPY, - }, - borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], forwardDeploymentFailureModalId, components: { ArtifactsBlock, CommitBlock, - GlButton, - GlIcon, JobsContainer, JobRetryForwardDeploymentModal, JobSidebarDetailsContainer, SidebarHeader, StagesDropdown, TriggerBlock, + ExternalLinksBlock, }, props: { artifactHelpUrl: { @@ -46,6 +42,9 @@ export default { // the artifact object will always have a locked property return Object.keys(this.job.artifact).length > 1; }, + hasExternalLinks() { + return this.externalLinks.length > 0; + }, hasTriggers() { return !isEmpty(this.job.trigger); }, @@ -58,6 +57,9 @@ export default { shouldShowJobRetryForwardDeploymentModal() { return this.job.retry_path && this.hasForwardDeploymentFailure; }, + externalLinks() { + return filterAnnotations(this.job.annotations, 'external_link'); + }, }, watch: { job(value, oldValue) { @@ -77,73 +79,44 @@ export default { <template> <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> - <div class="blocks-container"> + <div class="blocks-container gl-p-4"> <sidebar-header + class="block gl-pb-4! gl-mb-2" :rest-job="job" :job-id="job.id" @updateVariables="$emit('updateVariables')" /> - <div - v-if="job.terminal_path || job.new_issue_path" - class="gl-py-5" - :class="$options.borderTopClass" - > - <gl-button - v-if="job.new_issue_path" - :href="job.new_issue_path" - category="secondary" - variant="confirm" - data-testid="job-new-issue" - > - {{ $options.i18n.newIssue }} - </gl-button> - <gl-button - v-if="job.terminal_path" - :href="job.terminal_path" - target="_blank" - data-testid="terminal-link" - > - {{ $options.i18n.debug }} - <gl-icon name="external-link" /> - </gl-button> - </div> - <job-sidebar-details-container class="gl-py-5" :class="$options.borderTopClass" /> + <job-sidebar-details-container class="block gl-mb-2" /> <artifacts-block v-if="hasArtifact" - class="gl-py-5" - :class="$options.borderTopClass" + class="block gl-mb-2" :artifact="job.artifact" :help-url="artifactHelpUrl" /> - <trigger-block - v-if="hasTriggers" - class="gl-py-5" - :class="$options.borderTopClass" - :trigger="job.trigger" + <external-links-block + v-if="hasExternalLinks" + class="block gl-mb-2" + :external-links="externalLinks" /> - <commit-block - :commit="commit" - class="gl-py-5" - :class="$options.borderTopClass" - :merge-request="job.merge_request" - /> + <trigger-block v-if="hasTriggers" class="block gl-mb-2" :trigger="job.trigger" /> + + <commit-block class="block gl-mb-2" :commit="commit" :merge-request="job.merge_request" /> <stages-dropdown v-if="job.pipeline" - class="gl-py-5" - :class="$options.borderTopClass" + class="block gl-mb-2" :pipeline="job.pipeline" :selected-stage="selectedStage" :stages="stages" @requestSidebarStageDropdown="fetchJobsForStage" /> - </div> - <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" /> + <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" /> + </div> </div> <job-retry-forward-deployment-modal v-if="shouldShowJobRetryForwardDeploymentModal" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue index 0ba34eafa58..5b1bf354fd4 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue @@ -39,21 +39,26 @@ export default { }; </script> <template> - <p class="gl-display-flex gl-justify-content-space-between gl-mb-2"> - <span v-if="hasTitle"> - <b>{{ title }}:</b> + <p class="build-sidebar-item gl-mb-2"> + <b v-if="hasTitle" class="gl-display-flex">{{ title }}:</b> + <gl-link + v-if="path" + :href="path" + class="gl-text-blue-600!" + data-testid="job-sidebar-value-link" + > + {{ value }} + </gl-link> + <span v-else + >{{ value }} <gl-link - v-if="path" - :href="path" - class="gl-text-blue-600!" - data-testid="job-sidebar-value-link" + v-if="hasHelpURL" + :href="helpUrl" + target="_blank" + data-testid="job-sidebar-help-link" > - {{ value }} + <gl-icon name="question-o" class="gl-ml-2 gl-text-blue-500" /> </gl-link> - <span v-else>{{ value }}</span> </span> - <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank" data-testid="job-sidebar-help-link"> - <gl-icon name="question-o" /> - </gl-link> </p> </template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue index 56fcd8738d7..77e3ecb9b3c 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue @@ -5,20 +5,23 @@ import { mapActions } from 'vuex'; import { createAlert } from '~/alert'; import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { - JOB_GRAPHQL_ERRORS, - JOB_SIDEBAR_COPY, - forwardDeploymentFailureModalId, - PASSED_STATUS, -} from '~/jobs/constants'; -import GetJob from '../graphql/queries/get_job.query.graphql'; +import { JOB_GRAPHQL_ERRORS, forwardDeploymentFailureModalId, PASSED_STATUS } from '~/ci/constants'; +import GetJob from '../../graphql/queries/get_job.query.graphql'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; export default { name: 'SidebarHeader', i18n: { - ...JOB_SIDEBAR_COPY, + cancelJobButtonLabel: s__('Job|Cancel'), + debug: __('Debug'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), + eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), + newIssue: __('New issue'), + retryJobLabel: s__('Job|Retry'), + toggleSidebar: __('Toggle Sidebar'), + runAgainJobButtonLabel: s__('Job|Run again'), }, forwardDeploymentFailureModalId, directives: { @@ -90,27 +93,47 @@ export default { </script> <template> - <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <div> <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> + ><h4 class="gl-mt-0 gl-mb-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> </tooltip-on-truncate> - <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <div class="gl-display-flex gl-gap-3"> <gl-button v-if="restJob.erase_path" - v-gl-tooltip.left + v-gl-tooltip.bottom :title="$options.i18n.eraseLogButtonLabel" :aria-label="$options.i18n.eraseLogButtonLabel" :href="restJob.erase_path" :data-confirm="$options.i18n.eraseLogConfirmText" - class="gl-mr-2" data-testid="job-log-erase-link" data-confirm-btn-variant="danger" data-method="post" icon="remove" /> + <gl-button + v-if="restJob.new_issue_path" + v-gl-tooltip.bottom + :href="restJob.new_issue_path" + :title="$options.i18n.newIssue" + :aria-label="$options.i18n.newIssue" + category="secondary" + variant="confirm" + data-testid="job-new-issue" + icon="issue-new" + /> + <gl-button + v-if="restJob.terminal_path" + v-gl-tooltip.bottom + :href="restJob.terminal_path" + :title="$options.i18n.debug" + :aria-label="$options.i18n.debug" + target="_blank" + icon="external-link" + data-testid="terminal-link" + /> <job-sidebar-retry-button v-if="canShowJobRetryButton" - v-gl-tooltip.left + v-gl-tooltip.bottom :title="buttonTitle" :aria-label="buttonTitle" :is-manual-job="isManualJob" @@ -118,13 +141,12 @@ export default { :href="restJob.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" variant="confirm" - data-qa-selector="retry_button" data-testid="retry-button" @updateVariablesClicked="$emit('updateVariables')" /> <gl-button v-if="restJob.cancel_path" - v-gl-tooltip.left + v-gl-tooltip.bottom :title="$options.i18n.cancelJobButtonLabel" :aria-label="$options.i18n.cancelJobButtonLabel" :href="restJob.cancel_path" @@ -136,7 +158,7 @@ export default { /> <gl-button :aria-label="$options.i18n.toggleSidebar" - category="tertiary" + category="secondary" class="gl-md-display-none gl-ml-2" icon="chevron-double-lg-right" @click="toggleSidebar" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue index 09335476008..ebef3ecaa3f 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue @@ -44,10 +44,14 @@ export default { this.job.finished_at || this.job.erased_at || this.job.queued_duration || + this.job.id || this.job.runner || this.job.coverage, ); }, + jobId() { + return this.job?.id ? `#${this.job.id}` : ''; + }, runnerId() { const { id, short_sha: token, description } = this.job.runner; @@ -81,8 +85,9 @@ export default { ERASED: __('Erased'), QUEUED: __('Queued'), RUNNER: __('Runner'), - TAGS: __('Tags:'), + TAGS: __('Tags'), TIMEOUT: __('Timeout'), + ID: __('Job ID'), }, TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { anchor: 'set-a-limit-for-how-long-jobs-can-run', @@ -108,6 +113,7 @@ export default { data-testid="job-timeout" :title="$options.i18n.TIMEOUT" /> + <detail-row v-if="job.id" :value="jobId" :title="$options.i18n.ID" /> <detail-row v-if="job.runner" :value="runnerId" @@ -117,8 +123,8 @@ export default { <detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" /> <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> - <span class="font-weight-bold">{{ $options.i18n.TAGS }}</span> - <gl-badge v-for="(tag, i) in job.tags" :key="i" variant="info">{{ tag }}</gl-badge> + <span class="font-weight-bold">{{ $options.i18n.TAGS }}:</span> + <gl-badge v-for="(tag, i) in job.tags" :key="i" variant="info" size="sm">{{ tag }}</gl-badge> </p> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue index c1f84adf664..7744395734f 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue @@ -1,20 +1,20 @@ <script> import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { Mousetrap } from '~/lib/mousetrap'; import { s__ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings'; export default { components: { - CiIcon, ClipboardButton, GlDisclosureDropdown, GlLink, GlSprintf, + CiBadgeLink, }, props: { pipeline: { @@ -51,11 +51,13 @@ export default { }, pipelineInfo() { if (!this.hasRef) { - return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id}'); - } else if (!this.isTriggeredByMergeRequest) { - return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}'); - } else if (!this.isMergeRequestPipeline) { - return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source}'); + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status}'); + } + if (!this.isTriggeredByMergeRequest) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status} for %{ref}'); + } + if (!this.isMergeRequestPipeline) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status} for %{mrId} with %{source}'); } return s__( @@ -78,7 +80,8 @@ export default { if (!this.hasRef) { return; - } else if (!this.isTriggeredByMergeRequest) { + } + if (!this.isTriggeredByMergeRequest) { button = this.$refs['copy-source-ref-link']; } else { button = this.$refs['copy-source-branch-link']; @@ -91,25 +94,30 @@ export default { </script> <template> <div class="dropdown"> - <div class="js-pipeline-info" data-testid="pipeline-info"> - <ci-icon :status="pipeline.details.status" /> + <div class="gl-display-flex gl-flex-wrap gl-gap-2 js-pipeline-info" data-testid="pipeline-info"> <gl-sprintf :message="pipelineInfo"> <template #bold="{ content }"> - <span class="font-weight-bold">{{ content }}</span> + <span class="gl-display-flex gl-font-weight-bold">{{ content }}</span> </template> <template #id> <gl-link :href="pipeline.path" - class="js-pipeline-path link-commit" + class="js-pipeline-path link-commit gl-text-blue-500!" data-testid="pipeline-path" - data-qa-selector="pipeline_path" >#{{ pipeline.id }}</gl-link > </template> + <template #status> + <ci-badge-link + :status="pipeline.details.status" + size="sm" + data-testid="pipeline-status-link" + /> + </template> <template #mrId> <gl-link :href="pipeline.merge_request.path" - class="link-commit ref-name" + class="link-commit gl-text-blue-500!" data-testid="mr-link" >!{{ pipeline.merge_request.iid }}</gl-link > @@ -117,7 +125,7 @@ export default { <template #ref> <gl-link :href="pipeline.ref.path" - class="link-commit ref-name" + class="link-commit ref-name gl-mt-1" data-testid="source-ref-link" >{{ pipeline.ref.name }}</gl-link ><clipboard-button @@ -132,7 +140,7 @@ export default { <template #source> <gl-link :href="pipeline.merge_request.source_branch_path" - class="link-commit ref-name" + class="link-commit ref-name gl-mt-1" data-testid="source-branch-link" >{{ pipeline.merge_request.source_branch }}</gl-link ><clipboard-button @@ -147,7 +155,7 @@ export default { <template #target> <gl-link :href="pipeline.merge_request.target_branch_path" - class="link-commit ref-name" + class="link-commit ref-name gl-mt-1" data-testid="target-branch-link" >{{ pipeline.merge_request.target_branch }}</gl-link ><clipboard-button @@ -165,7 +173,7 @@ export default { :toggle-text="selectedStage" :items="dropdownItems" block - class="gl-mt-3" + class="gl-mt-2" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue index c9172fe0322..315587a3376 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue @@ -68,7 +68,7 @@ export default { <template v-if="hasVariables"> <p class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> - <span class="gl-font-weight-bold">{{ __('Trigger variables:') }}</span> + <span class="gl-display-flex gl-font-weight-bold">{{ __('Trigger variables') }}</span> <gl-button v-if="hasValues" diff --git a/app/assets/javascripts/jobs/components/job/stuck_block.vue b/app/assets/javascripts/ci/job_details/components/stuck_block.vue index 1a678ce69a8..8c73f09daea 100644 --- a/app/assets/javascripts/jobs/components/job/stuck_block.vue +++ b/app/assets/javascripts/ci/job_details/components/stuck_block.vue @@ -43,7 +43,8 @@ export default { dataTestId: 'job-stuck-with-tags', showTags: true, }; - } else if (this.hasOfflineRunnersForProject) { + } + if (this.hasOfflineRunnersForProject) { return { text: s__(`Job|This job is stuck because the project doesn't have any runners online assigned to it.`), diff --git a/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue b/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue index c9747ca9f02..c9747ca9f02 100644 --- a/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue +++ b/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue diff --git a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql index f4a0b10672e..7fb887b2dd4 100644 --- a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql @@ -1,4 +1,4 @@ -#import "~/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/job_details/graphql/fragments/ci_variable.fragment.graphql" fragment BaseCiJob on CiJob { id diff --git a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql index 0479df7bc4c..0479df7bc4c 100644 --- a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql index 520deef5136..5d8a7b4c6f6 100644 --- a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql" +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" mutation playJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { jobPlay(input: { id: $id, variables: $variables }) { diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql index e35d603ea71..cd66a30ce63 100644 --- a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql" +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { jobRetry(input: { id: $id, variables: $variables }) { diff --git a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql b/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql index 95e3521091d..a521ec2bb72 100644 --- a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql @@ -1,4 +1,4 @@ -#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql" +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" query getJob($fullPath: ID!, $id: JobID!) { project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/ci/job_details/index.js index 8cd69f25218..5a1ecf2fff3 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/ci/job_details/index.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import JobApp from './components/job/job_app.vue'; +import JobApp from './job_app.vue'; import createStore from './store'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue index 52030a0f830..5137ebfeaa8 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -4,25 +4,25 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { throttle, isEmpty } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState, mapActions } from 'vuex'; -import LogTopBar from 'ee_else_ce/jobs/components/job/job_log_controllers.vue'; +import LogTopBar from 'ee_else_ce/ci/job_details/components/job_log_controllers.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { __, sprintf } from '~/locale'; -import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; -import Log from '~/jobs/components/log/log.vue'; -import { MANUAL_STATUS } from '~/jobs/constants'; -import EmptyState from './empty_state.vue'; -import EnvironmentsBlock from './environments_block.vue'; -import ErasedBlock from './erased_block.vue'; -import StuckBlock from './stuck_block.vue'; -import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; -import Sidebar from './sidebar/sidebar.vue'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import Log from '~/ci/job_details/components/log/log.vue'; +import { MANUAL_STATUS } from '~/ci/constants'; +import EmptyState from './components/empty_state.vue'; +import EnvironmentsBlock from './components/environments_block.vue'; +import ErasedBlock from './components/erased_block.vue'; +import JobHeader from './components/job_header.vue'; +import StuckBlock from './components/stuck_block.vue'; +import UnmetPrerequisitesBlock from './components/unmet_prerequisites_block.vue'; +import Sidebar from './components/sidebar/sidebar.vue'; export default { name: 'JobPageApp', components: { - CiHeader, + JobHeader, EmptyState, EnvironmentsBlock, ErasedBlock, @@ -33,7 +33,7 @@ export default { UnmetPrerequisitesBlock, Sidebar, GlLoadingIcon, - SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), + SharedRunner: () => import('ee_component/ci/runner/components/shared_runner_limit_block.vue'), GlAlert, }, directives: { @@ -129,7 +129,7 @@ export default { return Boolean(this.job.retry_path); }, - itemName() { + jobName() { return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); }, }, @@ -225,13 +225,12 @@ export default { <!-- Header Section --> <header> <div class="build-header top-area"> - <ci-header + <job-header :status="job.status" :time="headerTime" :user="job.user" - :has-sidebar-button="true" :should-render-triggered-label="shouldRenderTriggeredLabel" - :item-name="itemName" + :name="jobName" @clickedSidebarButton="toggleSidebar" /> </div> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js index b348478ccda..33d83689e61 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -12,7 +12,7 @@ import { scrollUp, } from '~/lib/utils/scroll_utils'; import { __ } from '~/locale'; -import { reportToSentry } from '../utils'; +import { reportToSentry } from '~/ci/utils'; import * as types from './mutation_types'; export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/ci/job_details/store/getters.js index a0f9db7409d..a0f9db7409d 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/ci/job_details/store/getters.js diff --git a/app/assets/javascripts/jobs/store/index.js b/app/assets/javascripts/ci/job_details/store/index.js index b9d76765d8d..b9d76765d8d 100644 --- a/app/assets/javascripts/jobs/store/index.js +++ b/app/assets/javascripts/ci/job_details/store/index.js diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/ci/job_details/store/mutation_types.js index 4915a826b84..4915a826b84 100644 --- a/app/assets/javascripts/jobs/store/mutation_types.js +++ b/app/assets/javascripts/ci/job_details/store/mutation_types.js diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/ci/job_details/store/mutations.js index b7d7006ee61..b7d7006ee61 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/ci/job_details/store/mutations.js diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/ci/job_details/store/state.js index dfff65c364d..dfff65c364d 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/ci/job_details/store/state.js diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js index bc76901026d..bc76901026d 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/ci/job_details/store/utils.js diff --git a/app/assets/javascripts/ci/job_details/utils.js b/app/assets/javascripts/ci/job_details/utils.js new file mode 100644 index 00000000000..4d06c241b4f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/utils.js @@ -0,0 +1,29 @@ +export const compactJobLog = (jobLog) => { + const compactedLog = []; + + jobLog.forEach((obj) => { + // push header section line + if (obj.line && obj.isHeader) { + compactedLog.push(obj.line); + } + + // push lines within section header + if (obj.lines?.length > 0) { + compactedLog.push(...obj.lines); + } + + // push lines from plain log + if (!obj.lines && obj.content.length > 0) { + compactedLog.push(obj); + } + }); + + return compactedLog; +}; + +export const filterAnnotations = (annotations, type) => { + return [...annotations] + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((annotationList) => annotationList.data) + .flatMap((annotation) => annotation[type] ?? []); +}; diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue index d97f6f6ff8c..609f2790869 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue @@ -7,6 +7,7 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +import { reportMessageToSentry } from '~/ci/utils'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { @@ -23,13 +24,12 @@ import { PLAY_JOB_CONFIRMATION_MESSAGE, RUN_JOB_NOW_HEADER_TITLE, FILE_TYPE_ARCHIVE, -} from '../constants'; -import eventHub from '../event_hub'; -import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql'; -import playJobMutation from '../graphql/mutations/job_play.mutation.graphql'; -import retryJobMutation from '../graphql/mutations/job_retry.mutation.graphql'; -import unscheduleJobMutation from '../graphql/mutations/job_unschedule.mutation.graphql'; -import { reportMessageToSentry } from '../../../utils'; +} from '../../constants'; +import eventHub from '../../event_hub'; +import cancelJobMutation from '../../graphql/mutations/job_cancel.mutation.graphql'; +import playJobMutation from '../../graphql/mutations/job_play.mutation.graphql'; +import retryJobMutation from '../../graphql/mutations/job_retry.mutation.graphql'; +import unscheduleJobMutation from '../../graphql/mutations/job_unschedule.mutation.graphql'; export default { ACTIONS_DOWNLOAD_ARTIFACTS, diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue index 11593fa355a..dbf1dfe7a29 100644 --- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue @@ -27,6 +27,9 @@ export default { durationFormatted() { return formatTime(this.duration * 1000); }, + hasDurationAndFinishedTime() { + return this.finishedTime && this.duration; + }, }, }; </script> @@ -37,7 +40,11 @@ export default { <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> {{ durationFormatted }} </div> - <div v-if="finishedTime" data-testid="job-finished-time"> + <div + v-if="finishedTime" + :class="{ 'gl-mt-2': hasDurationAndFinishedTime }" + data-testid="job-finished-time" + > <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> <time-ago-tooltip :time="finishedTime" /> </div> diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue index 27d286fc766..b435eb283fd 100644 --- a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue @@ -71,7 +71,7 @@ export default { <template> <div> - <div class="gl-text-truncate gl-mb-2"> + <div class="gl-text-truncate gl-p-3 gl-mt-n3 gl-mx-n3 gl-mb-n2"> <gl-link v-if="canReadJob" class="gl-text-blue-600!" @@ -96,7 +96,7 @@ export default { > <div v-if="jobRef" - class="gl-px-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" + class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" > <gl-icon v-if="createdByTag" @@ -114,7 +114,7 @@ export default { </div> <span v-else>{{ __('none') }}</span> - <div class="gl-ml-2 gl-rounded-base gl-px-2 gl-bg-gray-50"> + <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> <gl-link class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" diff --git a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue index 1a6d1a341b0..18d68ee8a29 100644 --- a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue @@ -36,12 +36,16 @@ export default { <template> <div> - <div class="gl-text-truncate"> - <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id"> + <div class="gl-p-3 gl-mt-n3"> + <gl-link + class="gl-text-truncate gl-ml-n3 gl-text-gray-500!" + :href="pipelinePath" + data-testid="pipeline-id" + > {{ pipelineId }} </gl-link> </div> - <div> + <div class="gl-font-sm gl-text-secondary gl-mt-n2"> <span>{{ __('created by') }}</span> <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> <gl-avatar :src="pipelineUserAvatar" :size="16" /> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue index 84479ec421e..23100a3f3db 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue @@ -2,13 +2,13 @@ import { GlTable } from '@gitlab/ui'; import { s__ } from '~/locale'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; -import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue'; -import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue'; -import ActionsCell from './cells/actions_cell.vue'; -import DurationCell from './cells/duration_cell.vue'; -import JobCell from './cells/job_cell.vue'; -import PipelineCell from './cells/pipeline_cell.vue'; -import { DEFAULT_FIELDS } from './constants'; +import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; +import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; +import { DEFAULT_FIELDS } from '../constants'; +import ActionsCell from './job_cells/actions_cell.vue'; +import DurationCell from './job_cells/duration_cell.vue'; +import JobCell from './job_cells/job_cell.vue'; +import PipelineCell from './job_cells/pipeline_cell.vue'; export default { i18n: { @@ -85,7 +85,7 @@ export default { <template #cell(stage)="{ item }"> <div class="gl-text-truncate"> - <span data-testid="job-stage-name">{{ item.stage.name }}</span> + <span v-if="item.stage" data-testid="job-stage-name">{{ item.stage.name }}</span> </div> </template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue index fcdd52b719c..d2cd27be034 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue @@ -31,5 +31,6 @@ export default { :svg-path="emptyStateSvgPath" :primary-button-link="pipelineEditorPath" :primary-button-text="$options.i18n.buttonText" + data-testid="jobs-empty-state" /> </template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue index 797facb1eb8..b753195da9a 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue @@ -1,7 +1,7 @@ <script> import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; -import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; +import CancelJobs from '~/ci/admin/jobs_table/components/cancel_jobs.vue'; import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; export default { @@ -63,7 +63,7 @@ export default { <template> <div class="gl-display-flex align-items-lg-center"> - <gl-tabs content-class="gl-py-0"> + <gl-tabs content-class="gl-py-0" class="gl-w-full"> <gl-tab v-for="tab in tabs" :key="tab.text" diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/ci/jobs_page/constants.js index 1b572e60c58..1b572e60c58 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/ci/jobs_page/constants.js diff --git a/app/assets/javascripts/pipelines/event_hub.js b/app/assets/javascripts/ci/jobs_page/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/pipelines/event_hub.js +++ b/app/assets/javascripts/ci/jobs_page/event_hub.js diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js index 5390c023da4..5390c023da4 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js +++ b/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js diff --git a/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql b/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql index 3038216fdfc..3038216fdfc 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql index 20935514d51..20935514d51 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_play.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql index c94b045ac40..c94b045ac40 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_play.mutation.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_retry.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql index 6e51f9a20fa..6e51f9a20fa 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_retry.mutation.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql index 8be8c42f3c3..8be8c42f3c3 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql index 69719011079..69719011079 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql index a4e02ae721a..a4e02ae721a 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/ci/jobs_page/index.js index 88da1169e01..7e99157289b 100644 --- a/app/assets/javascripts/jobs/components/table/index.js +++ b/app/assets/javascripts/ci/jobs_page/index.js @@ -1,7 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; +import JobsTableApp from '~/ci/jobs_page/jobs_page_app.vue'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import cacheConfig from './graphql/cache_config'; diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue index 09fa006cb88..03e0f2dadc8 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue @@ -3,14 +3,14 @@ import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { createAlert } from '~/alert'; import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; -import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue'; -import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; -import { validateQueryString } from '../filtered_search/utils'; +import JobsSkeletonLoader from '~/ci/admin/jobs_table/components/jobs_skeleton_loader.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import GetJobsCount from './graphql/queries/get_jobs_count.query.graphql'; -import JobsTable from './jobs_table.vue'; -import JobsTableEmptyState from './jobs_table_empty_state.vue'; -import JobsTableTabs from './jobs_table_tabs.vue'; +import JobsTable from './components/jobs_table.vue'; +import JobsTableEmptyState from './components/jobs_table_empty_state.vue'; +import JobsTableTabs from './components/jobs_table_tabs.vue'; import { RAW_TEXT_WARNING } from './constants'; export default { diff --git a/app/assets/javascripts/ci/merge_requests/components/pipelines_table_wrapper.vue b/app/assets/javascripts/ci/merge_requests/components/pipelines_table_wrapper.vue new file mode 100644 index 00000000000..ee911d716e4 --- /dev/null +++ b/app/assets/javascripts/ci/merge_requests/components/pipelines_table_wrapper.vue @@ -0,0 +1,60 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils'; +import { graphqlEtagMergeRequestPipelines } from '~/ci/pipeline_details/utils'; +import getMergeRequestPipelines from '../graphql/queries/get_merge_request_pipelines.query.graphql'; + +export default { + components: { + GlLoadingIcon, + }, + inject: ['graphqlPath', 'mergeRequestId', 'targetProjectFullPath'], + data() { + return { + pipelines: [], + }; + }, + apollo: { + pipelines: { + query: getMergeRequestPipelines, + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + pollInterval: 10000, + variables() { + return { + fullPath: this.targetProjectFullPath, + mergeRequestIid: String(this.mergeRequestId), + }; + }, + update(data) { + return data?.project?.mergeRequest?.pipelines?.nodes || []; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + computed: { + graphqlResourceEtag() { + return graphqlEtagMergeRequestPipelines(this.graphqlPath, this.mergeRequestId); + }, + isLoading() { + return this.$apollo.queries.pipelines.loading; + }, + }, + i18n: { + fetchError: __("There was an error fetching this merge request's pipelines."), + }, +}; +</script> +<template> + <div class="gl-mt-3"> + <gl-loading-icon v-if="isLoading" size="lg" /> + <ul v-else> + <li v-for="pipeline in pipelines" :key="pipeline.id">{{ pipeline.path }}</li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql b/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql index 022d461dbec..022d461dbec 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql +++ b/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql diff --git a/app/assets/javascripts/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql b/app/assets/javascripts/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql new file mode 100644 index 00000000000..8c235032e6c --- /dev/null +++ b/app/assets/javascripts/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql @@ -0,0 +1,16 @@ +query getMergeRequestPipelines($mergeRequestIid: String!, $fullPath: ID!) { + project(fullPath: $fullPath) { + id + mergeRequest(iid: $mergeRequestIid) { + id + pipelines { + count + nodes { + id + iid + path + } + } + } + } +} diff --git a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js b/app/assets/javascripts/ci/mixins/delayed_job_mixin.js index 7b17dc7f693..7b17dc7f693 100644 --- a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js +++ b/app/assets/javascripts/ci/mixins/delayed_job_mixin.js diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js index 93ca3738ff0..bf312e66144 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/constants.js @@ -1,22 +1,11 @@ -import { s__, __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const CANCEL_REQUEST = 'CANCEL_REQUEST'; -export const FILTER_PIPELINES_SEARCH_DELAY = 200; -export const ANY_TRIGGER_AUTHOR = 'Any'; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source']; -export const FILTER_TAG_IDENTIFIER = 'tag'; export const SCHEDULE_ORIGIN = 'schedule'; export const NEEDS_PROPERTY = 'needs'; export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; -export const ICONS = { - TAG: 'tag', - MR: 'git-merge', - BRANCH: 'branch', - RETRY: 'retry', - SUCCESS: 'success', -}; - export const TestStatus = { FAILED: 'failed', SKIPPED: 'skipped', @@ -25,13 +14,6 @@ export const TestStatus = { UNKNOWN: 'unknown', }; -export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.'); -export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.'); -export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.'); -export const RAW_TEXT_WARNING = s__( - 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', -); - /* Error constants shared across graphs */ export const DEFAULT = 'default'; export const DELETE_FAILURE = 'delete_pipeline_failure'; @@ -64,25 +46,8 @@ export const validPipelineTabNames = [ codeQualityTabName, ]; -// Constants for the ID and IID selection dropdown -export const PipelineKeyOptions = [ - { - text: __('Show Pipeline ID'), - label: __('Pipeline ID'), - value: 'id', - }, - { - text: __('Show Pipeline IID'), - label: __('Pipeline IID'), - value: 'iid', - }, -]; - export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.'); -export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs'); -export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline'); - export const DEFAULT_FIELDS = [ { key: 'name', @@ -107,14 +72,6 @@ export const DEFAULT_FIELDS = [ }, ]; -export const TRACKING_CATEGORIES = { - table: 'pipelines_table_component', - tabs: 'pipelines_filter_tabs', - search: 'pipelines_filtered_search', - failed: 'pipeline_failed_jobs_tab', - tests: 'pipeline_tests_tab', -}; - // Pipeline Mini Graph export const PIPELINE_MINI_GRAPH_POLL_INTERVAL = 5000; diff --git a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_annotations.vue index a1500166cdc..a1500166cdc 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue +++ b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_annotations.vue diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_graph.vue index 7646c11773c..6e975d55a7f 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_graph.vue @@ -1,10 +1,10 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; +import { getMaxNodes, removeOrphanNodes } from '~/ci/pipeline_details/utils/parsing_utils'; import { PARSE_FAILURE } from '../../constants'; -import { getMaxNodes, removeOrphanNodes } from '../parsing_utils'; -import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; -import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; +import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '../constants'; +import { calculateClip, createLinkPath, createSankey, labelPosition } from '../utils/drawing_utils'; import { currentIsLive, getLiveLinksAsDict, @@ -12,7 +12,7 @@ import { restoreLinks, toggleLinkHighlight, togglePathHighlights, -} from './interactions'; +} from '../utils/interactions'; export default { viewOptions: { diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/ci/pipeline_details/dag/constants.js index cd89055737f..cd89055737f 100644 --- a/app/assets/javascripts/pipelines/components/dag/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/dag/constants.js diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/ci/pipeline_details/dag/dag.vue index afb5aa05098..5415340c956 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/ci/pipeline_details/dag/dag.vue @@ -4,12 +4,17 @@ import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { fetchPolicies } from '~/lib/graphql'; import { __ } from '~/locale'; -import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants'; -import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; -import { parseData } from '../parsing_utils'; +import { + DEFAULT, + PARSE_FAILURE, + LOAD_FAILURE, + UNSUPPORTED_DATA, +} from '~/ci/pipeline_details/constants'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import getDagVisData from './graphql/queries/get_dag_vis_data.query.graphql'; import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; -import DagAnnotations from './dag_annotations.vue'; -import DagGraph from './dag_graph.vue'; +import DagAnnotations from './components/dag_annotations.vue'; +import DagGraph from './components/dag_graph.vue'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/ci/pipeline_details/dag/graphql/queries/get_dag_vis_data.query.graphql index 2a0b13dd0cc..2a0b13dd0cc 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/dag/graphql/queries/get_dag_vis_data.query.graphql diff --git a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js b/app/assets/javascripts/ci/pipeline_details/dag/utils/drawing_utils.js index 3cd09d57ffb..3cd09d57ffb 100644 --- a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js +++ b/app/assets/javascripts/ci/pipeline_details/dag/utils/drawing_utils.js diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/ci/pipeline_details/dag/utils/interactions.js index 69f36feeee4..d2b7b7f9069 100644 --- a/app/assets/javascripts/pipelines/components/dag/interactions.js +++ b/app/assets/javascripts/ci/pipeline_details/dag/utils/interactions.js @@ -1,5 +1,5 @@ import * as d3 from 'd3'; -import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from './constants'; +import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from '../constants'; export const highlightIn = 1; export const highlightOut = 0.2; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/ci/pipeline_details/graph/api_utils.js index 0fe7d9ffda3..f9f47d1ea15 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/api.js +++ b/app/assets/javascripts/ci/pipeline_details/graph/api_utils.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import { reportToSentry } from '../../utils'; +import { reportToSentry } from '~/ci/utils'; export const reportPerformance = (path, stats) => { // FIXME: https://gitlab.com/gitlab-org/gitlab/-/issues/330245 diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue index 49df71beeec..f098d790736 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue @@ -1,15 +1,15 @@ <script> -import { reportToSentry } from '../../utils'; -import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; -import LinksLayer from '../graph_shared/links_layer.vue'; +import { reportToSentry } from '~/ci/utils'; import { generateColumnsFromLayersListMemoized, keepLatestDownstreamPipelines, -} from '../parsing_utils'; -import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; +} from '~/ci/pipeline_details/utils/parsing_utils'; +import LinksLayer from '../../../common/private/job_links_layer.vue'; +import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from '../constants'; +import { validateConfigPaths } from '../utils'; +import LinkedGraphWrapper from './linked_graph_wrapper.vue'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; -import { validateConfigPaths } from './utils'; export default { name: 'PipelineGraph', diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue index 73143c981ed..fb7dcb300f1 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue @@ -1,7 +1,7 @@ <script> import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { STAGE_VIEW, LAYER_VIEW } from './constants'; +import { STAGE_VIEW, LAYER_VIEW } from '../constants'; export default { name: 'GraphViewSelector', diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue index d4852224df5..7538ad87af8 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue @@ -1,6 +1,6 @@ <script> -import { reportToSentry } from '../../utils'; -import { JOB_DROPDOWN, SINGLE_JOB } from './constants'; +import { reportToSentry } from '~/ci/utils'; +import { JOB_DROPDOWN, SINGLE_JOB } from '../constants'; import JobItem from './job_item.vue'; /** diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue index 22895a31082..4298052d1c0 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -1,14 +1,14 @@ <script> import { GlBadge, GlForm, GlFormCheckbox, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { reportToSentry } from '~/ci/utils'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { reportToSentry } from '../../utils'; -import ActionComponent from '../jobs_shared/action_component.vue'; -import JobNameComponent from '../jobs_shared/job_name_component.vue'; -import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from './constants'; +import ActionComponent from '../../../common/private/job_action_component.vue'; +import JobNameComponent from '../../../common/private/job_name_component.vue'; +import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from '../constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. diff --git a/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue index fb2280d971a..fb2280d971a 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue index d8b843bdfb0..d6adaf78da4 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue @@ -11,11 +11,11 @@ import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; -import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; -import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; +import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { reportToSentry } from '../../utils'; -import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants'; +import { reportToSentry } from '~/ci/utils'; +import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from '../constants'; export default { directives: { @@ -72,7 +72,8 @@ export default { method: this.cancelPipeline, ariaLabel: __('Cancel downstream pipeline'), }; - } else if (this.isRetryable) { + } + if (this.isRetryable) { return { icon: 'retry', method: this.retryPipeline, @@ -141,7 +142,8 @@ export default { label() { if (this.parentPipeline) { return __('Parent'); - } else if (this.childPipeline) { + } + if (this.childPipeline) { return __('Child'); } return __('Multi-project'); diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue index 02e426064c9..2de7e43c9b1 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue @@ -1,9 +1,8 @@ <script> import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { reportToSentry } from '~/ci/utils'; import { LOAD_FAILURE } from '../../constants'; -import { reportToSentry } from '../../utils'; -import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from './constants'; -import LinkedPipeline from './linked_pipeline.vue'; +import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from '../constants'; import { calculatePipelineLayersInfo, getQueryHeaders, @@ -11,7 +10,8 @@ import { toggleQueryPollingByVisibility, unwrapPipelineData, validateConfigPaths, -} from './utils'; +} from '../utils'; +import LinkedPipeline from './linked_pipeline.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/links_inner.vue index 1189c2ebad8..09285525c38 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/links_inner.vue @@ -1,9 +1,10 @@ <script> import { isEmpty } from 'lodash'; +import { STAGE_VIEW } from '~/ci/pipeline_details/graph/constants'; +import { createJobsHash, generateJobNeedsDict } from '~/ci/pipeline_details/utils'; +import { reportToSentry } from '~/ci/utils'; import { DRAW_FAILURE } from '../../constants'; -import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils'; -import { STAGE_VIEW } from '../graph/constants'; -import { generateLinksData } from './drawing_utils'; +import { generateLinksData } from '../../utils/drawing_utils'; export default { name: 'LinksInner', diff --git a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue index bcd7705669e..bcd7705669e 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue index ffd0fec2ca8..1401bdba5ca 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue @@ -1,9 +1,9 @@ <script> import { escape, isEmpty } from 'lodash'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { reportToSentry } from '../../utils'; -import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue'; -import ActionComponent from '../jobs_shared/action_component.vue'; +import { reportToSentry } from '~/ci/utils'; +import RootGraphLayout from './root_graph_layout.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; import JobItem from './job_item.vue'; @@ -12,7 +12,7 @@ export default { ActionComponent, JobGroupDropdown, JobItem, - MainGraphWrapper, + RootGraphLayout, }, mixins: [glFeatureFlagMixin()], props: { @@ -135,7 +135,7 @@ export default { }; </script> <template> - <main-graph-wrapper :class="columnSpacingClass" data-testid="stage-column"> + <root-graph-layout :class="columnSpacingClass" data-testid="stage-column"> <template #stages> <div data-testid="stage-column-title" @@ -192,5 +192,5 @@ export default { </div> </div> </template> - </main-graph-wrapper> + </root-graph-layout> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/ci/pipeline_details/graph/constants.js index e650a48bc2a..e650a48bc2a 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/graph/constants.js diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue index b2cef7c37b9..bd7325f7925 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue @@ -4,10 +4,10 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; -import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; -import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql'; -import { reportToSentry, reportMessageToSentry } from '../../utils'; +import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '~/ci/pipeline_details/constants'; +import getPipelineQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; +import { reportToSentry, reportMessageToSentry } from '~/ci/utils'; +import DismissPipelineGraphCallout from './graphql/mutations/dismiss_pipeline_notification.graphql'; import { ACTION_FAILURE, IID_FAILURE, @@ -16,8 +16,8 @@ import { STAGE_VIEW, VIEW_TYPE_KEY, } from './constants'; -import PipelineGraph from './graph_component.vue'; -import GraphViewSelector from './graph_view_selector.vue'; +import PipelineGraph from './components/graph_component.vue'; +import GraphViewSelector from './components/graph_view_selector.vue'; import { calculatePipelineLayersInfo, getQueryHeaders, diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/ci/pipeline_details/graph/graphql/mutations/dismiss_pipeline_notification.graphql index e8af1db9592..e8af1db9592 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql +++ b/app/assets/javascripts/ci/pipeline_details/graph/graphql/mutations/dismiss_pipeline_notification.graphql diff --git a/app/assets/javascripts/pipelines/components/graph/perf_utils.js b/app/assets/javascripts/ci/pipeline_details/graph/perf_utils.js index 3737a209f5c..511dcbe6889 100644 --- a/app/assets/javascripts/pipelines/components/graph/perf_utils.js +++ b/app/assets/javascripts/ci/pipeline_details/graph/perf_utils.js @@ -8,7 +8,7 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import { reportPerformance } from '../graph_shared/api'; +import { reportPerformance } from './api_utils'; export const beginPerfMeasure = () => { performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/ci/pipeline_details/graph/utils.js index c888c8a5537..9a8d6440d4d 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/ci/pipeline_details/graph/utils.js @@ -1,8 +1,9 @@ import { isEmpty } from 'lodash'; import { getIdFromGraphQLId, etagQueryHeaders } from '~/graphql_shared/utils'; -import { reportToSentry } from '../../utils'; -import { listByLayers } from '../parsing_utils'; -import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; +import { reportToSentry } from '~/ci/utils'; + +import { listByLayers } from '~/ci/pipeline_details/utils/parsing_utils'; +import { unwrapStagesWithNeedsAndLookup } from '~/ci/pipeline_details/utils/unwrapping_utils'; import { beginPerfMeasure, finishPerfMeasureAndSend } from './perf_utils'; export { toggleQueryPollingByVisibility } from '~/graphql_shared/utils'; diff --git a/app/assets/javascripts/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql index f93908aeb04..f93908aeb04 100644 --- a/app/assets/javascripts/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql +++ b/app/assets/javascripts/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql diff --git a/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql index 9afb474cb17..9afb474cb17 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql diff --git a/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql index a52cecafcaf..a52cecafcaf 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql index 2b3b0822653..2b3b0822653 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql diff --git a/app/assets/javascripts/pipelines/graphql/provider.js b/app/assets/javascripts/ci/pipeline_details/graphql/provider.js index ef96b443da8..ef96b443da8 100644 --- a/app/assets/javascripts/pipelines/graphql/provider.js +++ b/app/assets/javascripts/ci/pipeline_details/graphql/provider.js diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql index 9257cc7de7b..9257cc7de7b 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql index eb5643126a2..eb5643126a2 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql diff --git a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index c53321f82bd..3a6a655bfa6 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -11,6 +11,7 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { __, s__, sprintf, formatNumber } from '~/locale'; @@ -19,19 +20,12 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { - LOAD_FAILURE, - POST_FAILURE, - DELETE_FAILURE, - DEFAULT, - BUTTON_TOOLTIP_RETRY, - BUTTON_TOOLTIP_CANCEL, -} from '../constants'; +import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql'; import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; -import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; -import { getQueryHeaders } from './graph/utils'; +import { getQueryHeaders } from '../graph/utils'; +import getPipelineQuery from './graphql/queries/get_pipeline_header_data.query.graphql'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; const POLL_INTERVAL = 10000; @@ -63,7 +57,7 @@ export default { }, i18n: { scheduleBadgeText: s__('Pipelines|Scheduled'), - scheduleBadgeTooltip: __('This pipeline was triggered by a schedule'), + scheduleBadgeTooltip: __('This pipeline was created by a schedule'), childBadgeText: s__('Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})'), childBadgeTooltip: __('This is a child pipeline within the parent pipeline'), latestBadgeText: s__('Pipelines|latest'), @@ -272,7 +266,7 @@ export default { }); }, triggeredText() { - return sprintf(__('triggered pipeline for commit %{linkStart}%{shortId}%{linkEnd}'), { + return sprintf(__('created pipeline for commit %{linkStart}%{shortId}%{linkEnd}'), { shortId: this.shortId, }); }, diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue index f84ae13180d..4752fbb3e96 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue @@ -6,8 +6,9 @@ import { createAlert } from '~/alert'; import Tracking from '~/tracking'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; -import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql'; -import { DEFAULT_FIELDS, TRACKING_CATEGORIES } from '../../constants'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import RetryFailedJobMutation from '../graphql/mutations/retry_failed_job.mutation.graphql'; +import { DEFAULT_FIELDS } from '../../constants'; export default { fields: DEFAULT_FIELDS, diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/ci/pipeline_details/jobs/failed_jobs_app.vue index c24862f828b..b946a40e590 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue +++ b/app/assets/javascripts/ci/pipeline_details/jobs/failed_jobs_app.vue @@ -2,8 +2,8 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; -import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql'; -import FailedJobsTable from './failed_jobs_table.vue'; +import GetFailedJobsQuery from './graphql/queries/get_failed_jobs.query.graphql'; +import FailedJobsTable from './components/failed_jobs_table.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_failed_job.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql index 1955cc9b0ac..1955cc9b0ac 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/retry_failed_job.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql index c1f994ece24..c1f994ece24 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql index b0f875160d4..b0f875160d4 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue index 61748860983..81b6152347d 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue @@ -3,10 +3,10 @@ import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab import produce from 'immer'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; -import eventHub from '~/jobs/components/table/event_hub'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants'; -import getPipelineJobs from '../../graphql/queries/get_pipeline_jobs.query.graphql'; +import eventHub from '~/ci/jobs_page/event_hub'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import { JOBS_TAB_FIELDS } from '~/ci/jobs_page/constants'; +import getPipelineJobs from './graphql/queries/get_pipeline_jobs.query.graphql'; export default { fields: JOBS_TAB_FIELDS, diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js index 481953608e9..53f755fda37 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js @@ -1,13 +1,13 @@ import Visibility from 'visibilityjs'; import { createAlert } from '~/alert'; +import eventHub from '~/ci/event_hub'; import { helpPagePath } from '~/helpers/help_page_helper'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; -import { validateParams } from '~/pipelines/utils'; +import { validateParams } from '~/ci/pipeline_details/utils'; import { CANCEL_REQUEST, TOAST_MESSAGE } from '../constants'; -import eventHub from '../event_hub'; export default { data() { diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/ci/pipeline_details/pipeline_details_bundle.js index f9c027539f2..da09852a7f4 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_details_bundle.js @@ -33,9 +33,15 @@ export default async function initPipelineDetailsBundle() { if (tabsEl) { const { dataset } = tabsEl; - const { createAppOptions } = await import('ee_else_ce/pipelines/pipeline_tabs'); + const dismissalDescriptions = JSON.parse(dataset.dismissalDescriptions || '{}'); + const { createAppOptions } = await import('ee_else_ce/ci/pipeline_details/pipeline_tabs'); const { createPipelineTabs } = await import('./pipeline_tabs'); - const { routes } = await import('ee_else_ce/pipelines/routes'); + const { routes } = await import('ee_else_ce/ci/pipeline_details/routes'); + + const securityRoute = routes.find((route) => route.path === '/security'); + if (securityRoute) { + securityRoute.props = { dismissalDescriptions }; + } const router = new VueRouter({ mode: 'history', diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js index c79aaef23e8..067ec3f305e 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; -import PipelineDetailsHeader from './components/pipeline_details_header.vue'; +import PipelineDetailsHeader from './header/pipeline_details_header.vue'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js index c3be487caae..c3be487caae 100644 --- a/app/assets/javascripts/pipelines/pipeline_shared_client.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/ci/pipeline_details/pipeline_tabs.js index 00a1810926c..0ca9a68e70d 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_tabs.js @@ -4,10 +4,11 @@ import VueRouter from 'vue-router'; import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import { GlToast } from '@gitlab/ui'; -import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue'; +import PipelineTabs from 'ee_else_ce/ci/pipeline_details/tabs/pipeline_tabs.vue'; +import { reportToSentry } from '~/ci/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import createTestReportsStore from './stores/test_reports'; -import { getPipelineDefaultTab, reportToSentry } from './utils'; +import { getPipelineDefaultTab } from './utils'; Vue.use(GlToast); Vue.use(VueApollo); diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js index 20fd0915e28..d38397e7479 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js @@ -10,7 +10,7 @@ import { import { doesHashExistInUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; -import Pipelines from './components/pipelines_list/pipelines.vue'; +import Pipelines from '~/ci/pipelines_page/pipelines.vue'; import PipelinesStore from './stores/pipelines_store'; Vue.use(Translate); diff --git a/app/assets/javascripts/pipelines/routes.js b/app/assets/javascripts/ci/pipeline_details/routes.js index 0e1414ec390..84207f3ab0c 100644 --- a/app/assets/javascripts/pipelines/routes.js +++ b/app/assets/javascripts/ci/pipeline_details/routes.js @@ -1,8 +1,8 @@ -import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; -import Dag from './components/dag/dag.vue'; -import FailedJobsApp from './components/jobs/failed_jobs_app.vue'; -import JobsApp from './components/jobs/jobs_app.vue'; -import TestReports from './components/test_reports/test_reports.vue'; +import PipelineGraphWrapper from './graph/graph_component_wrapper.vue'; +import Dag from './dag/dag.vue'; +import FailedJobsApp from './jobs/failed_jobs_app.vue'; +import JobsApp from './jobs/jobs_app.vue'; +import TestReports from './test_reports/test_reports.vue'; import { pipelineTabName, needsTabName, diff --git a/app/assets/javascripts/pipelines/stores/pipelines_store.js b/app/assets/javascripts/ci/pipeline_details/stores/pipelines_store.js index 765441560d8..765441560d8 100644 --- a/app/assets/javascripts/pipelines/stores/pipelines_store.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/pipelines_store.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/actions.js index 1b51bb804d0..1b51bb804d0 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/actions.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/constants.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/constants.js index 83d14e1a109..83d14e1a109 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/constants.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/getters.js index e6a88bb4175..e6a88bb4175 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/getters.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/index.js index f45a53f47b7..f45a53f47b7 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/index.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/index.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutation_types.js index 7651a2f4327..7651a2f4327 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutation_types.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutations.js index 466574157f5..466574157f5 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutations.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/state.js index 3ec9418c14e..3ec9418c14e 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/state.js diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js index 6b616601bc5..6b616601bc5 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue index 35dde6379dd..9783a9b5937 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue @@ -1,5 +1,6 @@ <script> import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; import { __ } from '~/locale'; import Tracking from '~/tracking'; import { @@ -8,7 +9,6 @@ import { needsTabName, pipelineTabName, testReportTabName, - TRACKING_CATEGORIES, } from '../constants'; export default { diff --git a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue index 3e7827dc416..055b6742ae1 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue @@ -54,6 +54,7 @@ export default { :title="emptyStateText.title" :description="emptyStateText.description" :svg-path="emptyStateImagePath" + :svg-height="150" :primary-button-link="testReportDocPath" :primary-button-text="emptyStateText.button" /> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue index 10db3e1c56b..3e6faa6b346 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue @@ -29,6 +29,11 @@ export default { return {}; }, }, + visible: { + type: Boolean, + required: false, + default: false, + }, }, computed: { failureHistoryMessage() { @@ -77,6 +82,8 @@ export default { :modal-id="modalId" :title="testCase.classname" :action-primary="$options.modalCloseButton" + :visible="visible" + @hidden="$emit('hidden')" > <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> <strong class="gl-text-right col-sm-3">{{ $options.text.name }}</strong> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue index a7737d33285..a7737d33285 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_suite_table.vue index d8af926a796..d8af926a796 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_suite_table.vue diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary.vue index 6b723ad5481..f6090678ca4 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlProgressBar } from '@gitlab/ui'; import { __ } from '~/locale'; -import { formattedTime } from '../../stores/test_reports/utils'; +import { formattedTime } from '../stores/test_reports/utils'; export default { name: 'TestSummary', diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary_table.vue index 9141947ea04..9141947ea04 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary_table.vue diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/drawing_utils.js index d6d9ea94c13..d6d9ea94c13 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/ci/pipeline_details/utils/drawing_utils.js diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/ci/pipeline_details/utils/index.js index 38be5becfb8..9109342707e 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/ci/pipeline_details/utils/index.js @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/browser'; import { pickBy } from 'lodash'; import { parseUrlPathname } from '~/lib/utils/url_utility'; import { @@ -6,7 +5,7 @@ import { SUPPORTED_FILTER_PARAMETERS, validPipelineTabNames, pipelineTabName, -} from './constants'; +} from '../constants'; /* The following functions are the main engine in transforming the data as received from the endpoint into the format the d3 graph expects. @@ -128,22 +127,6 @@ export const generateJobNeedsDict = (jobs = {}) => { }, {}); }; -export const reportToSentry = (component, failureType) => { - Sentry.withScope((scope) => { - scope.setTag('component', component); - Sentry.captureException(failureType); - }); -}; - -export const reportMessageToSentry = (component, message, context) => { - Sentry.withScope((scope) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - scope.setContext('Vue data', context); - scope.setTag('component', component); - Sentry.captureMessage(message); - }); -}; - export const getPipelineDefaultTab = (url) => { const strippedUrl = parseUrlPathname(url); const regexp = /\w*$/; @@ -154,3 +137,11 @@ export const getPipelineDefaultTab = (url) => { return null; }; + +export const graphqlEtagPipelinePath = (graphqlPath, pipelineId) => { + return `${graphqlPath}pipelines/id/${pipelineId}`; +}; + +export const graphqlEtagMergeRequestPipelines = (graphqlPath, mergeRequestId) => { + return `${graphqlPath}merge_requests/id/${mergeRequestId}`; +}; diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js index e158f8809b5..0a2a6d16498 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js @@ -1,7 +1,7 @@ import { memoize } from 'lodash'; -import { createNodeDict } from '../utils'; import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; -import { createSankey } from './dag/drawing_utils'; +import { createSankey } from '../dag/utils/drawing_utils'; +import { createNodeDict } from './index'; /* A peformant alternative to lodash's isEqual. Because findIndex always finds diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/unwrapping_utils.js index d42a11c3aba..7ac813bd527 100644 --- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js +++ b/app/assets/javascripts/ci/pipeline_details/utils/unwrapping_utils.js @@ -1,4 +1,4 @@ -import { reportToSentry } from '../utils'; +import { reportToSentry } from '~/ci/utils'; import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; const unwrapGroups = (stages) => { diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue index 3fe9103c2b3..baf3dbfa090 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue @@ -108,7 +108,6 @@ export default { <gl-form-group id="commit-group" :label="$options.i18n.commitMessage" - label-cols-sm="2" label-for="commit-message" > <gl-form-textarea @@ -122,7 +121,6 @@ export default { <gl-form-group id="source-branch-group" :label="$options.i18n.sourceBranch" - label-cols-sm="2" label-for="source-branch-field" > <gl-form-input @@ -130,13 +128,12 @@ export default { v-model="sourceBranch" class="gl-font-monospace!" required - data-qa-selector="source_branch_field" + data-testid="source-branch-field" /> <gl-form-checkbox v-if="!isCurrentBranchSourceBranch" v-model="openMergeRequest" data-testid="new-mr-checkbox" - data-qa-selector="new_mr_checkbox" class="gl-mt-3" > <gl-sprintf :message="$options.i18n.startMergeRequest"> @@ -152,7 +149,7 @@ export default { class="js-no-auto-disable gl-mr-3" category="primary" variant="confirm" - data-qa-selector="commit_changes_button" + data-testid="commit-changes-button" :disabled="isSubmitDisabled" :loading="isSaving" > diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index bc0cad75c60..8f4d566e7e6 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -89,7 +89,6 @@ export default { icon="external-link" target="_blank" data-testid="template-repo-link" - data-qa-selector="template_repo_link" @click="trackTemplateBrowsing" > {{ $options.i18n.browseTemplates }} @@ -98,7 +97,6 @@ export default { icon="information-o" size="small" data-testid="drawer-toggle" - data-qa-selector="drawer_toggle" @click="toggleHelpDrawer" > {{ $options.i18n.help }} @@ -107,7 +105,6 @@ export default { v-if="glFeatures.ciJobAssistantDrawer" icon="bulb" size="small" - data-qa-selector="job_assistant_drawer_toggle" @click="toggleJobAssistantDrawer" > {{ $options.i18n.jobAssistant }} @@ -117,7 +114,6 @@ export default { icon="bulb" size="small" data-testid="ai-assistant-drawer-toggle" - data-qa-selector="ai_assistant_drawer_toggle" @click="toggleAiAssistantDrawer" > {{ $options.i18n.aiAssistant }} diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index a410e4c933c..221a45d4d9a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -218,7 +218,6 @@ export default { :text="currentBranch" :disabled="!enableBranchSwitcher" icon="branch" - data-qa-selector="branch_selector_button" data-testid="branch-selector" > <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 7368d1a3a91..20b42e26f08 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <div class="gl-mb-4 gl-display-flex gl-flex-wrap gl-gap-3"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3 gl-mb-4"> <gl-button v-if="showFileTreeToggle" id="file-tree-toggle" diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/ci/pipeline_editor/components/graph/job_pill.vue index 3f1d7255a2b..3f1d7255a2b 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/graph/job_pill.vue diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/graph/pipeline_graph.vue index 8daf85e2b2e..eb906cfc486 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/graph/pipeline_graph.vue @@ -1,8 +1,8 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -import { DRAW_FAILURE, DEFAULT } from '../../constants'; -import LinksLayer from '../graph_shared/links_layer.vue'; +import { DRAW_FAILURE, DEFAULT } from '~/ci/pipeline_details/constants'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; import JobPill from './job_pill.vue'; import StageName from './stage_name.vue'; @@ -132,7 +132,6 @@ export default { :ref="$options.CONTAINER_REF" class="gl-bg-gray-10 gl-overflow-auto" data-testid="graph-container" - data-qa-selector="pipeline_graph_container" > <links-layer :pipeline-data="pipelineStages" @@ -148,10 +147,7 @@ export default { :key="`${stage.name}-${index}`" class="gl-flex-direction-column" > - <div - class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5" - data-qa-selector="stage_container" - > + <div class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5"> <stage-name :stage-name="stage.name" /> </div> <div :class="$options.jobWrapperClasses"> @@ -162,7 +158,6 @@ export default { :pipeline-id="$options.PIPELINE_ID" :is-hovered="highlightedJob === group.name" :is-faded-out="isFadedOut(group.name)" - data-qa-selector="job_container" @on-mouse-enter="setHoveredJob" @on-mouse-leave="removeHoveredJob" /> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/ci/pipeline_editor/components/graph/stage_name.vue index 600832b7633..600832b7633 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/graph/stage_name.vue diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue index ec6ee52b6b2..665ca907ed9 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue @@ -1,30 +1,11 @@ <script> +import { GlCard } from '@gitlab/ui'; import PipelineStatus from './pipeline_status.vue'; import ValidationSegment from './validation_segment.vue'; -const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100']; - -const pipelineStatusClasses = [ - ...baseClasses, - 'gl-border-1', - 'gl-border-b-0!', - 'gl-rounded-top-base', -]; - -const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base']; - -const validationSegmentWithPipelineStatusClasses = [ - ...baseClasses, - 'gl-border-1', - 'gl-rounded-bottom-left-base', - 'gl-rounded-bottom-right-base', -]; - export default { - pipelineStatusClasses, - validationSegmentClasses, - validationSegmentWithPipelineStatusClasses, components: { + GlCard, PipelineStatus, ValidationSegment, }, @@ -47,24 +28,19 @@ export default { showPipelineStatus() { return !this.isNewCiConfigFile; }, - // make sure corners are rounded correctly depending on if - // pipeline status is rendered - validationStyling() { - return this.showPipelineStatus - ? this.$options.validationSegmentWithPipelineStatusClasses - : this.$options.validationSegmentClasses; - }, }, }; </script> <template> - <div class="gl-mb-5"> - <pipeline-status - v-if="showPipelineStatus" - :commit-sha="commitSha" - :class="$options.pipelineStatusClasses" - v-on="$listeners" - /> - <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> - </div> + <gl-card + class="gl-new-card gl-mb-3 gl-mt-0" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-py-4 gl-px-5" + > + <template v-if="showPipelineStatus" #header> + <pipeline-status :commit-sha="commitSha" v-on="$listeners" /> + </template> + + <validation-segment :ci-config="ciConfigData" /> + </gl-card> </template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index f1c9770714a..f00098105d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -1,8 +1,8 @@ <script> import { __ } from '~/locale'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '../../constants'; export default { diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 3bce50224d9..58b5c0004e0 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -5,13 +5,10 @@ import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; @@ -141,7 +138,9 @@ export default { </script> <template> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap"> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap gl-w-full" + > <template v-if="showLoadingState"> <div> <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> @@ -149,20 +148,20 @@ export default { </div> </template> <template v-else-if="hasError"> - <gl-icon class="gl-mr-auto" name="warning-solid" /> - <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + <div> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </div> </template> <template v-else> <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" /> + <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" class="gl-mr-2" /> </a> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> <template #id="{ content }"> - <span data-testid="pipeline-id" data-qa-selector="pipeline_id_content"> - {{ content }}{{ pipelineId }} - </span> + <span data-testid="pipeline-id"> {{ content }}{{ pipelineId }} </span> </template> <template #status>{{ status.text }}</template> <template #commit> @@ -187,9 +186,8 @@ export default { /> <pipeline-editor-mini-graph v-else :pipeline="pipeline" v-on="$listeners" /> <gl-button - class="gl-ml-3" - category="secondary" - variant="confirm" + class="gl-ml-3 gl-align-self-center" + size="small" :href="status.detailsPath" data-testid="pipeline-view-btn" > diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue index 8553256f13a..d54ad78b3d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue @@ -112,8 +112,8 @@ export default { {{ $options.i18n.loading }} </template> <span v-else data-testid="validation-segment"> - <span class="gl-max-w-full" data-qa-selector="validation_message_content"> - <gl-icon :name="icon" /> + <span class="gl-max-w-full"> + <gl-icon :name="icon" class="gl-mr-2" /> <gl-sprintf :message="message"> <template v-if="hasLink" #link="{ content }"> <gl-link :href="helpPath">{{ content }}</gl-link> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js index a604d79259d..32eda355e66 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js @@ -10,7 +10,8 @@ const trimText = (val) => (isString(val) ? trim(val) : val); export const removeEmptyObj = (obj) => { if (isArray(obj)) { return reject(map(obj, removeEmptyObj), isEmptyValue); - } else if (isObject(obj)) { + } + if (isObject(obj)) { return omitBy(mapValues(obj, removeEmptyObj), isEmptyValue); } return obj; @@ -19,7 +20,8 @@ export const removeEmptyObj = (obj) => { export const trimFields = (data) => { if (isArray(data)) { return data.map(trimFields); - } else if (isObject(data)) { + } + if (isObject(data)) { return mapValues(data, trimFields); } return trimText(data); diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index a954615ca8a..c7c15cdd76e 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -2,7 +2,7 @@ import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import CiEditorHeader from 'ee_else_ce/ci/pipeline_editor/components/editor/ci_editor_header.vue'; import { s__, __ } from '~/locale'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import PipelineGraph from '~/ci/pipeline_editor/components/graph/pipeline_graph.vue'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { CREATE_TAB, @@ -182,7 +182,7 @@ export default { <template> <gl-tabs class="file-editor gl-mb-3" - data-qa-selector="file_editor_container" + data-testid="file-editor-container" :query-param-name="$options.query.TAB_QUERY_PARAM" sync-active-tab-with-query-params > diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue index efa6a54c638..57694bbcd77 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue @@ -42,7 +42,6 @@ export default { target="file-tree-toggle" triggers="manual" placement="right" - data-qa-selector="file_tree_popover" @close-button-clicked="dismissPermanently" > <div v-outside="dismissPermanently" class="gl-font-base gl-mb-3"> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index 25e4e99bf54..90402a89280 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -61,8 +61,7 @@ export default { <gl-button variant="confirm" class="gl-mt-3" - data-testid="create_new_ci_button" - data-qa-selector="create_new_ci_button" + data-testid="create-new-ci-button" @click="createEmptyConfigFile" > {{ $options.i18n.btnText }} 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 7583fa7a3b5..617088f303b 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 @@ -246,7 +246,6 @@ export default { class="gl-mt-3" :disabled="isInitialCiContentLoading" data-testid="simulate-pipeline-button" - data-qa-selector="simulate_pipeline_button" @click="validateYaml" > {{ $options.i18n.cta }} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql index 5354ed7c2d5..3570fc1f008 100644 --- a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql @@ -1,4 +1,4 @@ -#import "~/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql" +#import "~/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql" query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) { ciConfig(projectPath: $projectPath, sha: $sha, content: $content) { diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index de8e5a1a284..49562b0be28 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue @@ -4,7 +4,7 @@ import { fetchPolicies } from '~/lib/graphql'; import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { __, s__ } from '~/locale'; -import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import { unwrapStagesWithNeeds } from '~/ci/pipeline_details/utils/unwrapping_utils'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; @@ -394,7 +394,7 @@ export default { </script> <template> - <div class="gl-mt-4 gl-relative" data-qa-selector="pipeline_editor_app"> + <div class="gl-mt-4 gl-relative"> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> <pipeline-editor-empty-state v-else-if="showStartScreen || usesExternalConfig" diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js b/app/assets/javascripts/ci/pipeline_mini_graph/accessors/linked_pipelines_accessors.js index 1ca9e35c008..1ca9e35c008 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js +++ b/app/assets/javascripts/ci/pipeline_mini_graph/accessors/linked_pipelines_accessors.js diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql index 64a5964dbeb..64a5964dbeb 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql +++ b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql index 69a29947b16..69a29947b16 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql +++ b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/job_item.vue index 7f97097def6..7f97097def6 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/job_item.vue diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue index d6e585d093b..d20d4aec59d 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue @@ -1,11 +1,11 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; +import JobNameComponent from '~/ci/common/private/job_name_component.vue'; +import { ICONS } from '~/ci/constants'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { s__, sprintf } from '~/locale'; -import { reportToSentry } from '../../utils'; -import ActionComponent from '../jobs_shared/action_component.vue'; -import JobNameComponent from '../jobs_shared/job_name_component.vue'; -import { ICONS } from '../../constants'; +import { reportToSentry } from '~/ci/utils'; /** * Renders the badge for the pipeline graph and the job's dropdown. diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue index 8c0e65d1d39..8c0e65d1d39 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue index 048e42731c7..bbe0f1fbefc 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue @@ -15,9 +15,9 @@ import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { createAlert } from '~/alert'; +import eventHub from '~/ci/event_hub'; import axios from '~/lib/utils/axios_utils'; import { __, s__, sprintf } from '~/locale'; -import eventHub from '../../event_hub'; import LegacyJobItem from './legacy_job_item.vue'; export default { diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue index a5c6dc98694..8567654a89e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue @@ -105,7 +105,7 @@ export default { v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }" :href="pipeline.path" :class="triggerButtonClass(pipeline)" - class="linked-pipeline-mini-item gl-display-inline-block gl-h-6 gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle" + class="linked-pipeline-mini-item gl-display-inline-flex gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle" data-testid="linked-pipeline-mini-item" > <ci-icon diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_mini_graph.vue index 7cdaec81466..358d3dc826e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_mini_graph.vue @@ -2,14 +2,11 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; -import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; +import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from './graphql/queries/get_pipeline_stages.query.graphql'; import LegacyPipelineMiniGraph from './legacy_pipeline_mini_graph.vue'; export default { diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stage.vue index 8e22f440089..747b5d33b1a 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stage.vue @@ -1,12 +1,9 @@ <script> import { createAlert } from '~/alert'; import { __ } from '~/locale'; -import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; -import getPipelineStageQuery from '~/pipelines/graphql/queries/get_pipeline_stage.query.graphql'; +import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; +import getPipelineStageQuery from './graphql/queries/get_pipeline_stage.query.graphql'; import JobItem from './job_item.vue'; export default { diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stages.vue index 02dba9ba30f..f883833f7ea 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stages.vue @@ -42,7 +42,7 @@ export default { <div v-for="stage in stages" :key="stage.name" - class="pipeline-mini-graph-stage-container dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle" + class="pipeline-mini-graph-stage-container dropdown gl-display-inline-flex gl-mr-2 gl-my-2 gl-vertical-align-middle" > <pipeline-stage v-if="isGraphql" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue index fbdb60f61f1..f701bedc74d 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -41,6 +41,7 @@ export default { <template> <gl-empty-state :svg-path="$options.SCHEDULE_MD_SVG_URL" + :svg-height="150" :primary-button-text="$options.i18n.createNew" :primary-button-link="newSchedulePath" > diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 396ff9808f2..0c3ede47015 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -1,8 +1,7 @@ <script> import { GlButton, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlFormCheckbox, GlForm, GlFormGroup, @@ -27,8 +26,7 @@ const scheduleId = queryToObject(window.location.search).id; export default { components: { GlButton, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlForm, GlFormCheckbox, GlFormGroup, @@ -81,7 +79,7 @@ export default { this.description = schedule.description; this.cron = schedule.cron; this.cronTimezone = schedule.cronTimezone; - this.scheduleRef = schedule.ref; + this.scheduleRef = schedule.ref || this.defaultBranch; this.variables = variables.map((variable) => { return { id: variable.id, @@ -144,10 +142,6 @@ export default { revealText: __('Reveal values'), hideText: __('Hide values'), }, - typeOptions: { - [VARIABLE_TYPE]: __('Variable'), - [FILE_TYPE]: __('File'), - }, formElementClasses: 'gl-md-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', computed: { dropdownTranslations() { @@ -155,7 +149,7 @@ export default { dropdownHeader: this.$options.i18n.targetBranchTag, }; }, - typeOptionsListbox() { + typeOptions() { return [ { text: __('Variable'), @@ -232,9 +226,9 @@ export default { empty: true, }); }, - setVariableAttribute(key, attribute, value) { + setVariableType(typeValue, key) { const variable = this.variables.find((v) => v.key === key); - variable[attribute] = value; + variable.variableType = typeValue; }, removeVariable(index) { this.variables[index].destroy = true; @@ -387,19 +381,15 @@ export default { class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2" data-testid="ci-variable-row" > - <gl-dropdown - :text="$options.typeOptions[variable.variableType]" + <gl-collapsible-listbox + :items="typeOptions" + :selected="variable.variableType" :class="$options.formElementClasses" + block data-testid="pipeline-form-ci-variable-type" - > - <gl-dropdown-item - v-for="type in Object.keys($options.typeOptions)" - :key="type" - @click="setVariableAttribute(variable.key, 'variableType', type)" - > - {{ $options.typeOptions[type] }} - </gl-dropdown-item> - </gl-dropdown> + @select="setVariableType($event, variable.key)" + /> + <gl-form-input v-model="variable.key" :placeholder="s__('CiVariables|Input variable key')" @@ -414,7 +404,6 @@ export default { value="*****************" disabled class="gl-mb-3 gl-h-7!" - :style="$options.textAreaStyle" :no-resize="false" data-testid="pipeline-form-ci-variable-hidden-value" /> @@ -424,7 +413,6 @@ export default { v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3 gl-h-7!" - :style="$options.textAreaStyle" :no-resize="false" data-testid="pipeline-form-ci-variable-value" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue index 08efa794bcc..56d50026f17 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue @@ -27,10 +27,13 @@ export default { </script> <template> - <div> - <gl-icon :name="iconName" /> + <div data-testid="pipeline-schedule-target"> <span v-if="refPath"> + <gl-icon :name="iconName" /> <gl-link :href="refPath" class="gl-text-gray-900">{{ refDisplay }}</gl-link> </span> + <span v-else> + {{ s__('PipelineSchedules|None') }} + </span> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue deleted file mode 100644 index b4d84309c5f..00000000000 --- a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue +++ /dev/null @@ -1,50 +0,0 @@ -<script> -import { GlModal } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; - -export default { - components: { - GlModal, - }, - props: { - ownershipUrl: { - type: String, - required: true, - }, - }, - modalId: 'pipeline-take-ownership-modal', - i18n: { - takeOwnership: s__('PipelineSchedules|Take ownership'), - ownershipMessage: s__( - 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', - ), - cancelLabel: __('Cancel'), - }, - computed: { - actionCancel() { - return { text: this.$options.i18n.cancelLabel }; - }, - actionPrimary() { - return { - text: this.$options.i18n.takeOwnership, - attributes: { - variant: 'confirm', - category: 'primary', - href: this.ownershipUrl, - 'data-method': 'post', - }, - }; - }, - }, -}; -</script> -<template> - <gl-modal - :modal-id="$options.modalId" - :action-primary="actionPrimary" - :action-cancel="actionCancel" - :title="$options.i18n.takeOwnership" - > - <p>{{ $options.i18n.ownershipMessage }}</p> - </gl-modal> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue index 439dc0eb253..439dc0eb253 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue index 5208f9a3ce7..1a2021df9c8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue @@ -4,7 +4,7 @@ import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import { mergeUrlParams, DOCS_URL } from '~/lib/utils/url_utility'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; -import apolloProvider from '~/pipelines/graphql/provider'; +import apolloProvider from '~/ci/pipeline_details/graphql/provider'; import CiTemplates from './ci_templates.vue'; export default { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue index 3bbdfc73e1b..6e7d6908cd9 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue @@ -2,8 +2,8 @@ import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import PipelinesCiTemplates from './empty_state/pipelines_ci_templates.vue'; -import IosTemplates from './empty_state/ios_templates.vue'; +import PipelinesCiTemplates from './pipelines_ci_templates.vue'; +import IosTemplates from './ios_templates.vue'; export default { i18n: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue index a6297213402..a6297213402 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue index edf4cc87a87..82f1d57912a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue @@ -5,8 +5,8 @@ import { __, s__, sprintf } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { BRIDGE_KIND } from '~/pipelines/components/graph/constants'; -import RetryMrFailedJobMutation from '../../../graphql/mutations/retry_mr_failed_job.mutation.graphql'; +import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants'; +import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue index 2c5aa84bc4f..138269bdb8a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue @@ -2,9 +2,10 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __, s__, sprintf } from '~/locale'; -import { getQueryHeaders } from '~/pipelines/components/graph/utils'; -import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; -import { graphqlEtagPipelinePath, sortJobsByStatus } from './utils'; +import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils'; +import { graphqlEtagPipelinePath } from '~/ci/pipeline_details/utils'; +import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { sortJobsByStatus } from './utils'; import FailedJobDetails from './failed_job_details.vue'; const POLL_INTERVAL = 10000; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue index 60c429459bf..c01037e9791 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue @@ -91,7 +91,7 @@ export default { <template #header> <gl-button variant="link" - class="gl-text-gray-700! gl-font-weight-semibold" + class="gl-text-gray-500! gl-font-weight-semibold" @click="toggleWidget" > <gl-icon :name="iconName" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js index 2d0c467c54f..3f395fff7e0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js @@ -13,7 +13,3 @@ export const sortJobsByStatus = (jobs = []) => { return 1; }); }; - -export const graphqlEtagPipelinePath = (graphqlPath, pipelineId) => { - return `${graphqlPath}pipelines/id/${pipelineId}`; -}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue index 235126fea0c..235126fea0c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue index 40b2454b8c1..082ede60244 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_labels.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { SCHEDULE_ORIGIN } from '../../constants'; +import { SCHEDULE_ORIGIN } from '~/ci/pipeline_details/constants'; export default { components: { @@ -54,7 +54,7 @@ export default { v-gl-tooltip :href="pipelineScheduleUrl" target="__blank" - :title="__('This pipeline was triggered by a schedule.')" + :title="__('This pipeline was created by a schedule.')" variant="info" size="sm" data-testid="pipeline-url-scheduled" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue index 747d94d92f2..78acead95f4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue @@ -1,8 +1,7 @@ <script> import { GlAlert, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlTooltipDirective, @@ -14,6 +13,7 @@ import Tracking from '~/tracking'; import { TRACKING_CATEGORIES } from '../../constants'; export const i18n = { + searchPlaceholder: __('Search artifacts'), downloadArtifacts: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), artifactsFetchWarningMessage: s__( @@ -29,8 +29,7 @@ export default { }, components: { GlAlert, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, }, @@ -67,6 +66,17 @@ export default { ? fuzzaldrinPlus.filter(this.artifacts, this.searchQuery, { key: 'name' }) : this.artifacts; }, + items() { + return this.filteredArtifacts.map(({ name, path }) => ({ + text: name, + href: path, + extraAttrs: { + download: '', + rel: 'nofollow', + 'data-testid': 'artifact-item', + }, + })); + }, }, watch: { pipelineId() { @@ -107,66 +117,73 @@ export default { this.isLoading = false; }); }, - handleDropdownShown() { - if (this.hasArtifacts) { - this.$refs.searchInput.focusInput(); - } + onDisclosureDropdownShown() { + this.fetchArtifacts(); + }, + onDisclosureDropdownHidden() { + this.searchQuery = ''; }, }, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown v-gl-tooltip + class="gl-text-left" :title="$options.i18n.downloadArtifacts" - :text="$options.i18n.downloadArtifacts" + :toggle-text="$options.i18n.downloadArtifacts" :aria-label="$options.i18n.downloadArtifacts" - :header-text="$options.i18n.downloadArtifacts" + :items="items" icon="download" - data-testid="pipeline-multi-actions-dropdown" - right - lazy + placement="right" text-sr-only - @show="fetchArtifacts" - @shown="handleDropdownShown" + data-testid="pipeline-multi-actions-dropdown" + @shown="onDisclosureDropdownShown" + @hidden="onDisclosureDropdownHidden" > - <gl-alert v-if="hasError && !hasArtifacts" variant="danger" :dismissible="false"> - {{ $options.i18n.artifactsFetchErrorMessage }} - </gl-alert> - - <gl-loading-icon v-else-if="isLoading" size="sm" /> - - <gl-dropdown-item v-else-if="!hasArtifacts" data-testid="artifacts-empty-message"> - {{ $options.i18n.emptyArtifactsMessage }} - </gl-dropdown-item> - <template #header> - <gl-search-box-by-type v-if="hasArtifacts" ref="searchInput" v-model.trim="searchQuery" /> + <div + aria-hidden="true" + class="gl-display-flex gl-align-items-center gl-p-4! gl-min-h-8 gl-font-sm gl-font-weight-bold gl-text-gray-900 gl-border-b-1 gl-border-b-solid gl-border-b-gray-200" + > + {{ $options.i18n.downloadArtifacts }} + </div> + <div v-if="hasArtifacts" class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> + <gl-search-box-by-type + ref="searchInput" + v-model.trim="searchQuery" + :placeholder="$options.i18n.searchPlaceholder" + borderless + autofocus + /> + </div> + <gl-alert + v-if="hasError && !hasArtifacts" + variant="danger" + :dismissible="false" + data-testid="artifacts-fetch-error" + > + {{ $options.i18n.artifactsFetchErrorMessage }} + </gl-alert> </template> - <gl-dropdown-item - v-for="(artifact, i) in filteredArtifacts" - :key="i" - :href="artifact.path" - rel="nofollow" - download - class="gl-word-break-word" - data-testid="artifact-item" + <gl-loading-icon v-if="isLoading" class="gl-m-3" size="sm" /> + <p + v-else-if="filteredArtifacts.length === 0" + class="gl-px-4 gl-py-3 gl-m-0 gl-text-gray-600" + data-testid="artifacts-empty-message" > - {{ artifact.name }} - </gl-dropdown-item> + {{ $options.i18n.emptyArtifactsMessage }} + </p> <template #footer> - <gl-dropdown-item + <p v-if="hasError && hasArtifacts" - class="gl-list-style-none" - disabled + class="gl-font-sm gl-text-secondary gl-py-4 gl-px-5 gl-mb-0 gl-border-t" data-testid="artifacts-fetch-warning" > - <span class="gl-font-sm"> - {{ $options.i18n.artifactsFetchWarningMessage }} - </span> - </gl-dropdown-item> + {{ $options.i18n.artifactsFetchWarningMessage }} + </p> </template> - </gl-dropdown> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue index caeee7edefe..b05bdae65c4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue @@ -1,8 +1,8 @@ <script> import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import Tracking from '~/tracking'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '~/ci/constants'; import eventHub from '../../event_hub'; -import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '../../constants'; import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue index 9f38be668f2..9f38be668f2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue index 2a73795db0a..2a73795db0a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue index ff1a01d5037..edaeb481d7b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue @@ -4,7 +4,7 @@ import { __ } from '~/locale'; import Tracking from '~/tracking'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { ICONS, TRACKING_CATEGORIES } from '../../constants'; +import { ICONS, TRACKING_CATEGORIES } from '~/ci/constants'; import PipelineLabels from './pipeline_labels.vue'; export default { @@ -151,7 +151,10 @@ export default { <div v-if="!pipelineName" class="commit-title gl-mb-2" data-testid="commit-title-container"> <span v-if="commitTitle" class="gl-display-flex"> - <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate"> + <tooltip-on-truncate + :title="commitTitle" + class="gl-flex-grow-1 gl-text-truncate gl-p-3 gl-ml-n3 gl-mr-n3 gl-mt-n3 gl-mb-n3" + > <gl-link :href="commitUrl" class="commit-row-message gl-text-blue-600!" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue index 4452db64b0a..3021b4a2ef8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue @@ -60,7 +60,7 @@ export default { <gl-disclosure-dropdown v-if="shouldShowDropdown" v-gl-tooltip - class="build-artifacts js-pipeline-dropdown-download" + class="gl-text-left" :title="$options.i18n.artifacts" :toggle-text="$options.i18n.artifacts" :aria-label="$options.i18n.artifacts" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue index 7dc1e60610e..6aadb6b73c8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue @@ -5,11 +5,11 @@ import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { TRACKING_CATEGORIES } from '../../constants'; -import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; -import PipelineSourceToken from './tokens/pipeline_source_token.vue'; -import PipelineStatusToken from './tokens/pipeline_status_token.vue'; -import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; -import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue'; +import PipelineBranchNameToken from '../tokens/pipeline_branch_name_token.vue'; +import PipelineSourceToken from '../tokens/pipeline_source_token.vue'; +import PipelineStatusToken from '../tokens/pipeline_status_token.vue'; +import PipelineTagNameToken from '../tokens/pipeline_tag_name_token.vue'; +import PipelineTriggerAuthorToken from '../tokens/pipeline_trigger_author_token.vue'; export default { userType: 'username', diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue index 262e82677a7..4dacd474bde 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue @@ -8,7 +8,7 @@ import Tracking from '~/tracking'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; -import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql'; +import getPipelineActionsQuery from '../graphql/queries/get_pipeline_actions.query.graphql'; export default { name: 'PipelinesManualActions', diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue index 00ab8a25ca1..2da9141df8e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue @@ -1,5 +1,6 @@ <script> -import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { CHILD_VIEW } from '~/ci/pipeline_details/constants'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue index 70343544638..70343544638 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue diff --git a/app/assets/javascripts/ci/pipelines_page/constants.js b/app/assets/javascripts/ci/pipelines_page/constants.js new file mode 100644 index 00000000000..aa6ef8a25ee --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/constants.js @@ -0,0 +1,2 @@ +export const ANY_TRIGGER_AUTHOR = 'Any'; +export const FILTER_PIPELINES_SEARCH_DELAY = 200; diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql index d1878c01e91..d1878c01e91 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql +++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql index 6b553866f63..6b553866f63 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql +++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql index b70e95deab6..b70e95deab6 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql +++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/ci/pipelines_page/pipelines.vue index 574d291a767..87ee5463bb0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/ci/pipelines_page/pipelines.vue @@ -7,29 +7,29 @@ import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; -import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { isLoggedIn } from '~/lib/utils/common_utils'; -import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; import { - ANY_TRIGGER_AUTHOR, - RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER, PipelineKeyOptions, + RAW_TEXT_WARNING, TRACKING_CATEGORIES, -} from '../../constants'; -import PipelinesMixin from '../../mixins/pipelines_mixin'; -import PipelinesService from '../../services/pipelines_service'; -import { validateParams } from '../../utils'; -import EmptyState from './empty_state.vue'; -import NavigationControls from './nav_controls.vue'; -import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; -import PipelinesTableComponent from './pipelines_table.vue'; +} from '~/ci/constants'; +import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; +import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin'; +import { validateParams } from '~/ci/pipeline_details/utils'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import PipelinesService from './services/pipelines_service'; +import { ANY_TRIGGER_AUTHOR } from './constants'; +import NoCiEmptyState from './components/empty_state/no_ci_empty_state.vue'; +import NavigationControls from './components/nav_controls.vue'; +import PipelinesFilteredSearch from './components/pipelines_filtered_search.vue'; export default { PipelineKeyOptions, components: { - EmptyState, + NoCiEmptyState, GlCollapsibleListbox, GlEmptyState, GlIcon, @@ -409,7 +409,7 @@ export default { class="prepend-top-20" /> - <empty-state + <no-ci-empty-state v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" @@ -426,6 +426,7 @@ export default { <gl-empty-state v-else-if="stateToRender === $options.stateMap.emptyTab" :svg-path="noPipelinesSvgPath" + :svg-height="150" :title="emptyTabMessage" /> diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js index 3ec563c95bb..c38fa07c7e3 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js @@ -1,6 +1,6 @@ import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; -import { validateParams } from '../utils'; +import { validateParams } from '../../pipeline_details/utils'; export default class PipelinesService { /** diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js b/app/assets/javascripts/ci/pipelines_page/tokens/constants.js index d8f15cfde91..d8f15cfde91 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js +++ b/app/assets/javascripts/ci/pipelines_page/tokens/constants.js diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue index 81f46d5f2f9..45b6fb380a9 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue @@ -3,7 +3,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from import { debounce } from 'lodash'; import Api from '~/api'; import { createAlert } from '~/alert'; -import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; +import { __ } from '~/locale'; +import { FILTER_PIPELINES_SEARCH_DELAY } from '../constants'; export default { components: { @@ -46,7 +47,7 @@ export default { }) .catch((err) => { createAlert({ - message: FETCH_BRANCH_ERROR_MESSAGE, + message: __('There was a problem fetching project branches.'), }); this.loading = false; throw err; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue index 9643ddfbd21..b4b5c5c1b37 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { PIPELINE_SOURCES } from 'ee_else_ce/pipelines/components/pipelines_list/tokens/constants'; +import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants'; export default { PIPELINE_SOURCES, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue index 020a08b8cee..020a08b8cee 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue index b32f5de2d7e..a6034e78b6d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue @@ -3,7 +3,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from import { debounce } from 'lodash'; import Api from '~/api'; import { createAlert } from '~/alert'; -import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; +import { __ } from '~/locale'; +import { FILTER_PIPELINES_SEARCH_DELAY } from '../constants'; export default { components: { @@ -39,7 +40,7 @@ export default { }) .catch((err) => { createAlert({ - message: FETCH_TAG_ERROR_MESSAGE, + message: __('There was a problem fetching project tags.'), }); this.loading = false; throw err; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue index a89354c671a..20c5e1557a7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue @@ -9,11 +9,8 @@ import { import { debounce } from 'lodash'; import Api from '~/api'; import { createAlert } from '~/alert'; -import { - ANY_TRIGGER_AUTHOR, - FETCH_AUTHOR_ERROR_MESSAGE, - FILTER_PIPELINES_SEARCH_DELAY, -} from '../../../constants'; +import { __ } from '~/locale'; +import { ANY_TRIGGER_AUTHOR, FILTER_PIPELINES_SEARCH_DELAY } from '../constants'; export default { anyTriggerAuthor: ANY_TRIGGER_AUTHOR, @@ -62,7 +59,7 @@ export default { }) .catch((err) => { createAlert({ - message: FETCH_AUTHOR_ERROR_MESSAGE, + message: __('There was a problem fetching project users.'), }); this.loading = false; throw err; diff --git a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue index bd41b8d23f1..f2346a5512e 100644 --- a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue @@ -22,7 +22,8 @@ export default { iconName() { if (this.isStatusFailed) { return 'status_failed_borderless'; - } else if (this.isStatusSuccess) { + } + if (this.isStatusSuccess) { return 'status_success_borderless'; } @@ -49,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <gl-icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" /> + <gl-icon :name="iconName" :size="statusIconSize" /> </div> </template> diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue index a4ec7b6a325..fd6c6cca6b7 100644 --- a/app/assets/javascripts/ci/reports/components/report_section.vue +++ b/app/assets/javascripts/ci/reports/components/report_section.vue @@ -159,7 +159,8 @@ export default { slotName() { if (this.isSuccess) { return SLOT_SUCCESS; - } else if (this.isLoading) { + } + if (this.isLoading) { return SLOT_LOADING; } diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index e6813211fe9..0ec94dc865f 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -178,6 +178,22 @@ export default { </script> <template> <div> + <header class="gl-my-5 gl-display-flex gl-justify-content-space-between"> + <h2 class="gl-my-0 header-title"> + {{ s__('Runners|Runners') }} + </h2> + <div class="gl-display-flex gl-gap-3"> + <runner-dashboard-link /> + <gl-button :href="newRunnerPath" variant="confirm"> + {{ s__('Runners|New instance runner') }} + </gl-button> + <registration-dropdown + :registration-token="registrationToken" + :type="$options.INSTANCE_TYPE" + placement="right" + /> + </div> + </header> <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -189,18 +205,6 @@ export default { content-class="gl-display-none" nav-class="gl-border-none!" /> - - <div class="gl-w-full gl-md-w-auto gl-display-flex gl-gap-3"> - <runner-dashboard-link /> - <gl-button :href="newRunnerPath" variant="confirm"> - {{ s__('Runners|New instance runner') }} - </gl-button> - <registration-dropdown - :registration-token="registrationToken" - :type="$options.INSTANCE_TYPE" - placement="right" - /> - </div> </div> <runner-filtered-search-bar diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue index cb43760b2d6..8f1c7234b84 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue @@ -50,12 +50,7 @@ export default { <template> <div> - <gl-link - v-if="cell.href" - v-gl-tooltip="cell.tooltip" - :href="cell.href" - class="gl-text-body gl-text-decoration-underline" - > + <gl-link v-if="cell.href" v-gl-tooltip="cell.tooltip" :href="cell.href" class="gl-text-body"> {{ cell.text }} </gl-link> <span v-else>{{ cell.text }}</span> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index cc31afea88c..a80d6207be8 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -12,7 +12,6 @@ import RunnerManagersBadge from '../runner_managers_badge.vue'; import { formatJobCount } from '../../utils'; import { - I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -73,7 +72,6 @@ export default { formatNumber, }, i18n: { - I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -100,7 +98,10 @@ export default { <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" /> </div> - <div class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full"> + <div + v-if="runner.version || runner.description" + class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full gl-font-sm gl-align-items-center" + > <template v-if="runner.version"> <div class="gl-flex-shrink-0"> <runner-upgrade-status-icon :upgrade-status="runner.upgradeStatus" /> @@ -108,19 +109,20 @@ export default { <template #version>{{ runner.version }}</template> </gl-sprintf> </div> - <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> + <div v-if="runner.description" class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> </template> <tooltip-on-truncate + v-if="runner.description" class="gl-text-truncate gl-display-block" :class="{ 'gl-text-secondary': !runner.description }" :title="runner.description" > - {{ runner.description || $options.i18n.I18N_NO_DESCRIPTION }} + {{ runner.description }} </tooltip-on-truncate> </div> - <div> - <runner-summary-field icon="clock"> + <div class="gl-font-sm"> + <runner-summary-field icon="clock" icon-size="sm"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue index 742259ee491..b1b61e03eec 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -25,7 +25,7 @@ export default { <template> <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-mb-3 gl-mr-4"> - <gl-icon v-if="icon" :name="icon" /> + <gl-icon v-if="icon" :name="icon" :size="12" /> <!-- display tooltip as a label for screen readers --> <span class="gl-sr-only">{{ tooltip }}</span> <slot></slot> diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue index 1b363174d28..adaed77055a 100644 --- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue @@ -120,7 +120,7 @@ export default { </script> <template> <gl-form @submit.prevent="onSubmit"> - <runner-form-fields v-model="runner" /> + <runner-form-fields v-model="runner" :runner-type="runnerType" /> <div class="gl-display-flex gl-mt-6"> <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="saving"> diff --git a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue index 3634dcf1c93..81b2a17631e 100644 --- a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue @@ -92,7 +92,6 @@ export default { :initial-filter-value="initialFilterValue" :tokens="validTokens" :initial-sort-by="initialSortBy" - :search-input-placeholder="__('Search or filter results...')" :search-text-option-label="s__('Runners|Search description...')" terms-as-tokens data-testid="runners-filtered-search" diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue index d090a562ff7..38e36733045 100644 --- a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue +++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue @@ -10,7 +10,12 @@ import { GlSkeletonLoader, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; +import { + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, + PROJECT_TYPE, + RUNNER_TYPES, +} from '../constants'; export default { name: 'RunnerFormFields', @@ -26,6 +31,12 @@ export default { import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), }, props: { + runnerType: { + type: String, + required: false, + default: null, + validator: (t) => RUNNER_TYPES.includes(t), + }, value: { type: Object, default: null, @@ -44,7 +55,7 @@ export default { }, computed: { canBeLockedToProject() { - return this.value?.runnerType === PROJECT_TYPE; + return this.runnerType === PROJECT_TYPE; }, }, watch: { diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_table.vue b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue index 10790c398b0..58244e1f2df 100644 --- a/app/assets/javascripts/ci/runner/components/runner_managers_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue @@ -6,6 +6,7 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; import { I18N_STATUS_NEVER_CONTACTED } from '../constants'; import RunnerStatusBadge from './runner_status_badge.vue'; +import RunnerJobStatusBadge from './runner_job_status_badge.vue'; export default { name: 'RunnerManagersTable', @@ -15,6 +16,7 @@ export default { HelpPopover, GlIntersperse, RunnerStatusBadge, + RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), }, @@ -52,7 +54,15 @@ export default { </help-popover> </template> <template #cell(status)="{ item = {} }"> - <runner-status-badge :contacted-at="item.contactedAt" :status="item.status" /> + <runner-status-badge + class="gl-vertical-align-middle" + :contacted-at="item.contactedAt" + :status="item.status" + /> + <runner-job-status-badge + class="gl-vertical-align-middle" + :job-status="item.jobExecutionStatus" + /> </template> <template #cell(version)="{ item = {} }"> {{ item.version }} diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue index a49641194a7..a841f66b566 100644 --- a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue +++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue @@ -55,7 +55,7 @@ export default { <div class="gl-mt-3 gl-mb-6"> <label>{{ s__('Runners|Operating systems') }}</label> - <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3"> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <runner-platforms-radio v-model="model" @@ -76,7 +76,7 @@ export default { <div class="gl-mt-3 gl-mb-6"> <label>{{ s__('Runners|Containers') }}</label> - <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3"> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <runner-platforms-radio :image="$options.DOCKER_LOGO_URL"> <gl-link :href="$options.DOCKER_HELP_URL" target="_blank"> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue index 70226074993..3af10c59e31 100644 --- a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -112,7 +112,12 @@ export default { :scope="countScope" :variables="tabBadgeCountVariables(tab.runnerType)" > - <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm"> + <gl-badge + v-if="tabCount(count)" + class="gl-ml-1" + size="sm" + :data-testid="`runner-count-${tab.title.toLowerCase()}`" + > {{ tabCount(count) }} </gl-badge> </runner-count> diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue index 6b94e594f1c..4278615ba66 100644 --- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue @@ -96,7 +96,7 @@ export default { </script> <template> <gl-form @submit.prevent="onSubmit"> - <runner-form-fields v-model="model" :loading="loading" /> + <runner-form-fields v-model="model" :loading="loading" :runner-type="runnerType" /> <runner-update-cost-factor-fields v-model="model" :runner-type="runnerType" /> <div class="gl-mt-6"> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue index c33c42f3afe..cee1088d90b 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -55,7 +55,8 @@ export default { query() { if (this.scope === INSTANCE_TYPE) { return allRunnersCountQuery; - } else if (this.scope === GROUP_TYPE) { + } + if (this.scope === GROUP_TYPE) { return groupRunnersCountQuery; } return null; @@ -74,7 +75,8 @@ export default { update(data) { if (this.scope === INSTANCE_TYPE) { return data?.runners?.count; - } else if (this.scope === GROUP_TYPE) { + } + if (this.scope === GROUP_TYPE) { return data?.group?.runners?.count; } return null; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index e0a6f4b1e67..6c49263ac82 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -90,6 +90,7 @@ export default { :scope="scope" v-bind="stat.props" class="gl-px-5" + :data-testid="`runner-stats-${stat.key.toLowerCase()}`" /> <runner-upgrade-status-stats diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 203f97876de..3293c68ddb8 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -15,7 +15,7 @@ export const I18N_CREATE_ERROR = s__( ); export const FILTER_CSS_CLASSES = - 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1'; + 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1'; // Type @@ -96,7 +96,6 @@ export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); // List -export const I18N_NO_DESCRIPTION = s__('Runners|No description'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql index ead005d1252..84d32e24f24 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql @@ -9,4 +9,5 @@ fragment CiRunnerManagerShared on CiRunnerManager { platformName ipAddress contactedAt + jobExecutionStatus } diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 71584c40a38..dcaf8635f5c 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -212,6 +212,27 @@ export default { <template> <div> + <header class="gl-my-5 gl-display-flex gl-justify-content-space-between"> + <h2 class="gl-my-0 header-title"> + {{ s__('Runners|Runners') }} + </h2> + <div class="gl-display-flex gl-gap-3"> + <gl-button + v-if="newRunnerPath" + :href="newRunnerPath" + variant="confirm" + data-testid="new-group-runner-button" + > + {{ s__('Runners|New group runner') }} + </gl-button> + <registration-dropdown + v-if="registrationToken" + :registration-token="registrationToken" + :type="$options.GROUP_TYPE" + placement="right" + /> + </div> + </header> <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -225,19 +246,6 @@ export default { content-class="gl-display-none" nav-class="gl-border-none!" /> - - <div class="gl-w-full gl-md-w-auto gl-display-flex"> - <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> - {{ s__('Runners|New group runner') }} - </gl-button> - <registration-dropdown - v-if="registrationToken" - class="gl-ml-3" - :registration-token="registrationToken" - :type="$options.GROUP_TYPE" - placement="right" - /> - </div> </div> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3" diff --git a/app/assets/javascripts/ci/runner/project_runners/index.js b/app/assets/javascripts/ci/runner/project_runners/index.js deleted file mode 100644 index 3be2b4a7422..00000000000 --- a/app/assets/javascripts/ci/runner/project_runners/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import ProjectRunnersApp from './project_runners_app.vue'; - -export const initProjectRunners = (selector = '#js-project-runners') => { - const el = document.querySelector(selector); - - if (!el) { - return null; - } - - const { projectFullPath } = el.dataset; - - return new Vue({ - el, - render(h) { - return h(ProjectRunnersApp, { - props: { - projectFullPath, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue deleted file mode 100644 index c7bf5e521a1..00000000000 --- a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue +++ /dev/null @@ -1,19 +0,0 @@ -<script> -export default { - props: { - projectFullPath: { - required: true, - type: String, - }, - }, -}; -</script> -<template> - <div> - <!-- - Under development - Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/33803 - Feature rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/386573 - --> - </div> -</template> diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js index 3dc99baa329..8915198350f 100644 --- a/app/assets/javascripts/ci/runner/runner_search_utils.js +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -97,7 +97,8 @@ const outdatedStatusParams = (status) => { [PARAM_KEY_PAUSED]: ['false'], [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! }; - } else if (status === STATUS_PAUSED) { + } + if (status === STATUS_PAUSED) { return { [PARAM_KEY_PAUSED]: ['true'], [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/ci/utils.js index a4e695518f1..eb9e9538b75 100644 --- a/app/assets/javascripts/jobs/utils.js +++ b/app/assets/javascripts/ci/utils.js @@ -1,18 +1,5 @@ import * as Sentry from '@sentry/browser'; -/** - * capture anything starting with http:// or https:// - * https?:\/\/ - * - * up until a disallowed character or whitespace - * [^"<>()\\^`{|}\s]+ - * - * and a disallowed character or whitespace, including non-ending chars .,:;!? - * [^"<>()\\^`{|}\s.,:;!?] - */ -export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g; -export default { linkRegex }; - export const reportToSentry = (component, failureType) => { Sentry.withScope((scope) => { scope.setTag('component', component); diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue index fdf720a5f94..cbb9c31b54b 100644 --- a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue +++ b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue @@ -101,10 +101,12 @@ export default { if (this.fileExtension === 'cer') { this.track('load_secure_file_metadata_cer'); return this.cerItems(); - } else if (this.fileExtension === 'p12') { + } + if (this.fileExtension === 'p12') { this.track('load_secure_file_metadata_p12'); return this.p12Items(); - } else if (this.fileExtension === 'mobileprovision') { + } + if (this.fileExtension === 'mobileprovision') { this.track('load_secure_file_metadata_mobileprovision'); return this.mobileprovisionItems(this.metadata); } diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 529be7169db..7482aaca36e 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -191,9 +191,11 @@ export default { getVersionPopoverTitle(agent) { if (this.isVersionMismatch(agent) && this.isVersionOutdated(agent)) { return this.$options.i18n.versionMismatchOutdatedTitle; - } else if (this.isVersionMismatch(agent)) { + } + if (this.isVersionMismatch(agent)) { return this.$options.i18n.versionMismatchTitle; - } else if (this.isVersionOutdated(agent)) { + } + if (this.isVersionOutdated(agent)) { return this.$options.i18n.versionOutdatedTitle; } diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index 75850cbb108..96ecbe9fa12 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -30,7 +30,8 @@ export default { dropdownText() { if (this.isRegistering) { return this.$options.i18n.registeringAgent; - } else if (this.selectedAgent === null) { + } + if (this.selectedAgent === null) { return this.$options.i18n.selectAgent; } diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 590fdb947b3..0258d8e0da0 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -125,9 +125,11 @@ export default { k8sQuantityToGb(quantity) { if (!quantity) { return 0; - } else if (quantity.endsWith(__('Ki'))) { + } + if (quantity.endsWith(__('Ki'))) { return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.000001024; - } else if (quantity.endsWith(__('Mi'))) { + } + if (quantity.endsWith(__('Mi'))) { return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.001048576; } @@ -138,9 +140,11 @@ export default { k8sQuantityToCpu(quantity) { if (!quantity) { return 0; - } else if (quantity.endsWith('m')) { + } + if (quantity.endsWith('m')) { return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000.0; - } else if (quantity.endsWith('n')) { + } + if (quantity.endsWith('n')) { return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000000000.0; } diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index c388d3fee71..e92b98946d0 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -37,7 +37,8 @@ export default { if (!this.displayClusterAgents) { return connectClusterDeprecated; - } else if (!this.certificateBasedClustersEnabled) { + } + if (!this.certificateBasedClustersEnabled) { return connectCluster; } return connectWithAgent; diff --git a/app/assets/javascripts/commit/constants.js b/app/assets/javascripts/commit/constants.js index 4f865e99e46..e28009ab996 100644 --- a/app/assets/javascripts/commit/constants.js +++ b/app/assets/javascripts/commit/constants.js @@ -80,14 +80,14 @@ export const typeConfig = { keyNamespace: 'gpgKeyPrimaryKeyid', helpLink: { label: __('Learn about signing commits'), - path: 'user/project/repository/gpg_signed_commits/index.md', + path: 'user/project/repository/signed_commits/index.md', }, }, [signatureTypes.X509]: { keyLabel: '', helpLink: { label: __('Learn more about X.509 signed commits'), - path: '/user/project/repository/x509_signed_commits/index.md', + path: '/user/project/repository/signed_commits/x509.md', }, subjectTitle: __('Certificate Subject'), issuerTitle: __('Certificate Issuer'), @@ -98,7 +98,7 @@ export const typeConfig = { keyNamespace: 'keyFingerprintSha256', helpLink: { label: __('Learn about signing commits with SSH keys.'), - path: '/user/project/repository/ssh_signed_commits/index.md', + path: '/user/project/repository/signed_commits/ssh.md', }, }, }; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/legacy_pipelines_table_wrapper.vue index 8c8293eb09e..5e84dcbe48e 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/legacy_pipelines_table_wrapper.vue @@ -2,12 +2,12 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { getParameterByName } from '~/lib/utils/url_utility'; -import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; -import { PipelineKeyOptions } from '~/pipelines/constants'; -import eventHub from '~/pipelines/event_hub'; -import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin'; -import PipelinesService from '~/pipelines/services/pipelines_service'; -import PipelineStore from '~/pipelines/stores/pipelines_store'; +import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; +import { PipelineKeyOptions } from '~/ci/constants'; +import eventHub from '~/ci/event_hub'; +import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin'; +import PipelinesService from '~/ci/pipelines_page/services/pipelines_service'; +import PipelineStore from '~/ci/pipeline_details/stores/pipelines_store'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 96c274225d8..beeb9b9ada4 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -26,7 +26,8 @@ export default () => { if (pipelineTableViewEl.dataset.disableInitialization === undefined) { const table = new Vue({ components: { - CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'), + CommitPipelinesTable: () => + import('~/commit/pipelines/legacy_pipelines_table_wrapper.vue'), }, apolloProvider, provide: { diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue b/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue +++ /dev/null diff --git a/app/assets/javascripts/commons/gitlab_ui.js b/app/assets/javascripts/commons/gitlab_ui.js new file mode 100644 index 00000000000..4a9f79460da --- /dev/null +++ b/app/assets/javascripts/commons/gitlab_ui.js @@ -0,0 +1,10 @@ +import applyGitLabUIConfig from '@gitlab/ui/dist/config'; +import { __ } from '~/locale'; + +applyGitLabUIConfig({ + translations: { + 'GlSearchBoxByType.input.placeholder': __('Search'), + 'GlSearchBoxByType.clearButtonTitle': __('Clear'), + 'ClearIconButton.title': __('Clear'), + }, +}); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 77c85d85e27..d2a5ef83faf 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,6 +1,7 @@ import './polyfills'; import './bootstrap'; import './vue'; +import './gitlab_ui'; import '../lib/utils/axios_utils'; import { openUserCountsBroadcast } from './nav/user_merge_requests'; diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue index 7915cd6679d..fe9b2f81f85 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue @@ -24,7 +24,7 @@ export default { return this.event.resource_parent; }, message() { - if (!this.target) { + if (!this.target.type) { return EVENT_CREATED_I18N[this.resourceParent.type] || EVENT_CREATED_I18N[TYPE_FALLBACK]; } diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_destroyed.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_destroyed.vue new file mode 100644 index 00000000000..11b6affb944 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_destroyed.vue @@ -0,0 +1,28 @@ +<script> +import { EVENT_DESTROYED_I18N, EVENT_DESTROYED_ICONS } from '../../constants'; +import { getValueByEventTarget } from '../../utils'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventDestroyed', + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + message() { + return getValueByEventTarget(EVENT_DESTROYED_I18N, this.event); + }, + iconName() { + return getValueByEventTarget(EVENT_DESTROYED_ICONS, this.event); + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" :message="message" :icon-name="iconName" /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue index 557f2912f17..a2516f37c92 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue @@ -53,7 +53,8 @@ export default { message() { if (this.ref.is_new) { return this.$options.i18n.new[this.ref.type]; - } else if (this.ref.is_removed) { + } + if (this.ref.is_removed) { return this.$options.i18n.removed[this.ref.type]; } diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_updated.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_updated.vue new file mode 100644 index 00000000000..e795e611ee5 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_updated.vue @@ -0,0 +1,25 @@ +<script> +import { EVENT_UPDATED_I18N } from '../../constants'; +import { getValueByEventTarget } from '../../utils'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventUpdated', + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + message() { + return getValueByEventTarget(EVENT_UPDATED_I18N, this.event); + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" :message="message" icon-name="pencil" /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue index 8b42d77675f..f3116717783 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_events.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue @@ -12,6 +12,8 @@ import { EVENT_TYPE_CLOSED, EVENT_TYPE_REOPENED, EVENT_TYPE_COMMENTED, + EVENT_TYPE_UPDATED, + EVENT_TYPE_DESTROYED, } from '../constants'; import ContributionEventApproved from './contribution_event/contribution_event_approved.vue'; import ContributionEventExpired from './contribution_event/contribution_event_expired.vue'; @@ -24,6 +26,8 @@ import ContributionEventCreated from './contribution_event/contribution_event_cr import ContributionEventClosed from './contribution_event/contribution_event_closed.vue'; import ContributionEventReopened from './contribution_event/contribution_event_reopened.vue'; import ContributionEventCommented from './contribution_event/contribution_event_commented.vue'; +import ContributionEventUpdated from './contribution_event/contribution_event_updated.vue'; +import ContributionEventDestroyed from './contribution_event/contribution_event_destroyed.vue'; export default { props: { @@ -151,6 +155,12 @@ export default { case EVENT_TYPE_COMMENTED: return ContributionEventCommented; + case EVENT_TYPE_UPDATED: + return ContributionEventUpdated; + + case EVENT_TYPE_DESTROYED: + return ContributionEventDestroyed; + default: return EmptyComponent; } diff --git a/app/assets/javascripts/contribution_events/components/target_link.vue b/app/assets/javascripts/contribution_events/components/target_link.vue index a14574ed826..86fddbb063e 100644 --- a/app/assets/javascripts/contribution_events/components/target_link.vue +++ b/app/assets/javascripts/contribution_events/components/target_link.vue @@ -27,5 +27,5 @@ export default { </script> <template> - <gl-link v-if="target" v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link> + <gl-link v-if="target.type" v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link> </template> diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js index b5eddbf7e25..c0224ce94ff 100644 --- a/app/assets/javascripts/contribution_events/constants.js +++ b/app/assets/javascripts/contribution_events/constants.js @@ -119,14 +119,31 @@ export const EVENT_COMMENTED_I18N = Object.freeze({ [COMMIT_NOTEABLE_TYPE]: s__( 'ContributionEvent|Commented on commit %{noteableLink} in %{resourceParentLink}.', ), - fallback: s__('ContributionEvent|Commented on %{noteableLink}.'), + [TYPE_FALLBACK]: s__('ContributionEvent|Commented on %{noteableLink}.'), }); export const EVENT_COMMENTED_SNIPPET_I18N = Object.freeze({ [RESOURCE_PARENT_TYPE_PROJECT]: s__( 'ContributionEvent|Commented on snippet %{noteableLink} in %{resourceParentLink}.', ), - fallback: s__('ContributionEvent|Commented on snippet %{noteableLink}.'), + [TYPE_FALLBACK]: s__('ContributionEvent|Commented on snippet %{noteableLink}.'), +}); + +export const EVENT_UPDATED_I18N = Object.freeze({ + [TARGET_TYPE_DESIGN]: s__( + 'ContributionEvent|Updated design %{targetLink} in %{resourceParentLink}.', + ), + [TARGET_TYPE_WIKI]: s__( + 'ContributionEvent|Updated wiki page %{targetLink} in %{resourceParentLink}.', + ), + [TYPE_FALLBACK]: s__('ContributionEvent|Updated resource.'), +}); + +export const EVENT_DESTROYED_I18N = Object.freeze({ + [TARGET_TYPE_DESIGN]: s__('ContributionEvent|Archived design in %{resourceParentLink}.'), + [TARGET_TYPE_WIKI]: s__('ContributionEvent|Deleted wiki page in %{resourceParentLink}.'), + [TARGET_TYPE_MILESTONE]: s__('ContributionEvent|Deleted milestone in %{resourceParentLink}.'), + [TYPE_FALLBACK]: s__('ContributionEvent|Deleted resource.'), }); export const EVENT_CLOSED_ICONS = Object.freeze({ @@ -139,3 +156,8 @@ export const EVENT_REOPENED_ICONS = Object.freeze({ [TARGET_TYPE_MERGE_REQUEST]: 'merge-request-open', [TYPE_FALLBACK]: 'status_open', }); + +export const EVENT_DESTROYED_ICONS = Object.freeze({ + [TARGET_TYPE_DESIGN]: 'archive', + [TYPE_FALLBACK]: 'remove', +}); diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index b39720c6094..08c2e235819 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -114,4 +114,8 @@ export default class CreateItemDropdown { toggleFooter(toggleState) { this.$dropdownFooter.toggleClass('hidden', toggleState); } + + close() { + this.$dropdown.data('deprecatedJQueryDropdown')?.close(); + } } diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue index a56fce98f85..ccf4b064fa4 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -249,7 +249,12 @@ export default { :description="$options.translations.addTokenNameDescription" label-for="deploy_token_name" > - <gl-form-input id="deploy_token_name" v-model="name" name="deploy_token_name" /> + <gl-form-input + id="deploy_token_name" + v-model="name" + class="gl-form-input-xl" + name="deploy_token_name" + /> </gl-form-group> <gl-form-group :label="$options.translations.addTokenExpiryLabel" @@ -258,6 +263,7 @@ export default { > <gl-form-input id="deploy_token_expires_at" + class="gl-form-input-xl" name="deploy_token_expires_at" :value="formattedExpiryDate" data-qa-selector="deploy_token_expires_at_field" @@ -277,7 +283,7 @@ export default { </template> </gl-sprintf> </template> - <gl-form-input id="deploy_token_username" v-model="username" /> + <gl-form-input id="deploy_token_username" v-model="username" class="gl-form-input-xl" /> </gl-form-group> <gl-form-group :label="$options.translations.addTokenScopesLabel" diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js index 69f1d62539a..3f14a8a8a26 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js @@ -94,6 +94,7 @@ export class GitLabDropdown { dataType: this.options.dataType, beforeSend: this.toggleLoading.bind(this), success: (data) => { + this.dropdown.trigger('done.remote.loading.gl.dropdown'); this.fullData = data; this.parseData(this.fullData); this.focusTextInput(); @@ -220,7 +221,12 @@ export class GitLabDropdown { } toggleLoading() { - return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); + const menu = this.dropdown[0].querySelector('.dropdown-menu'); + const isLoading = menu.classList.contains(LOADING_CLASS); + + this.dropdown.trigger(`toggle.${isLoading ? 'off' : 'on'}.loading.gl.dropdown`); + + menu.classList.toggle(LOADING_CLASS); } togglePage() { @@ -719,4 +725,8 @@ export class GitLabDropdown { clearField(field, isInput) { return isInput ? field.val('') : field.remove(); } + + close() { + this.dropdown.find('.dropdown-menu-toggle').dropdown('hide'); + } } diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js index 2cb9e9a56a3..271347feebd 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js @@ -17,9 +17,11 @@ export class GitLabDropdownFilter { const $inputContainer = this.input.parent(); const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); const filterRemoteDebounced = debounce(() => { + options.instance.dropdown.trigger('filtering.gl.dropdown'); $inputContainer.parent().addClass('is-loading'); return this.options.query(this.input.val(), (data) => { + options.instance.dropdown.trigger('done.filtering.gl.dropdown'); $inputContainer.parent().removeClass('is-loading'); return this.options.callback(data); }); diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js index ae5d3298b62..bacd84048af 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_remote.js @@ -11,7 +11,8 @@ export class GitLabDropdownRemote { execute() { if (typeof this.dataEndpoint === 'string') { return this.fetchData(); - } else if (typeof this.dataEndpoint === 'function') { + } + if (typeof this.dataEndpoint === 'function') { if (this.options.beforeSend) { this.options.beforeSend(); } diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/index.js b/app/assets/javascripts/deprecated_jquery_dropdown/index.js index 6a3d2026192..38236707e06 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/index.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/index.js @@ -5,7 +5,10 @@ export default function initDeprecatedJQueryDropdown($el, opts) { // eslint-disable-next-line func-names return $el.each(function () { if (!$.data(this, 'deprecatedJQueryDropdown')) { - $.data(this, 'deprecatedJQueryDropdown', new GitLabDropdown(this, opts)); + const instance = new GitLabDropdown(this, opts); + + $.data(this, 'deprecatedJQueryDropdown', instance); + this.GitLabDropdownInstance = instance; } }); } diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js index 97698d55011..1b133c57302 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js @@ -89,7 +89,8 @@ function checkSelected(data, options) { if (!options.parent) { return !data.id; - } else if (value) { + } + if (value) { return ( options.parent.querySelector(`input[name='${options.fieldName}'][value='${value}']`) != null ); diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 4e5e07c57e4..008e12abbcd 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -458,7 +458,8 @@ export default class Notes { this.setupNewNote($newNote); this.refresh(); return this.updateNotesCount(1); - } else if (Notes.isUpdatedNote(noteEntity, $note)) { + } + if (Notes.isUpdatedNote(noteEntity, $note)) { // The server can send the same update multiple times so we need to make sure to only update once per actual update. const isEditing = $note.hasClass('is-editing'); const initialContent = normalizeNewlines($note.find('.original-note-content').text().trim()); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index e3882ce42c2..08306312c2e 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -19,7 +19,6 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import { Mousetrap } from '~/lib/mousetrap'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '~/notes/event_hub'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; @@ -76,7 +75,6 @@ export default { GenerateTestFileDrawer: () => import('ee_component/ai/components/generate_test_file_drawer.vue'), }, - mixins: [glFeatureFlagsMixin()], alerts: { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, @@ -580,11 +578,7 @@ export default { <template> <div v-show="shouldShow"> - <findings-drawer - v-if="glFeatures.codeQualityInlineDrawer" - :drawer="activeDrawer" - @close="closeDrawer" - /> + <findings-drawer :drawer="activeDrawer" @close="closeDrawer" /> <div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> <compare-versions :diff-files-count-text="numTotalFiles" /> @@ -631,6 +625,7 @@ export default { <template #default="{ item, index, active }"> <dynamic-scroller-item :item="item" :active="active" :class="{ active }"> <diff-file + v-if="active" :file="item" :reviewed="fileReviews[item.id]" :is-first-file="index === 0" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index d62d0e11bff..a9e63ad53bb 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -337,7 +337,7 @@ export default { :title="filePath" class="file-title-name" data-container="body" - data-qa-selector="file_name_content" + data-testid="file-name-content" > {{ filePath }} </strong> diff --git a/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue b/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue index 5cc2a3079b0..8a30d6ee7f8 100644 --- a/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue +++ b/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue @@ -1,23 +1,15 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapActions } from 'vuex'; +import { GlIcon } from '@gitlab/ui'; import { getSeverity } from '~/ci/reports/utils'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { - components: { GlLink, GlIcon }, - mixins: [glFeatureFlagsMixin()], + components: { GlIcon }, + props: { finding: { type: Object, required: true, }, - link: { - type: Boolean, - required: false, - default: true, - }, }, computed: { enhancedFinding() { @@ -27,12 +19,6 @@ export default { return `${this.finding.severity} - ${this.finding.description}`; }, }, - methods: { - toggleDrawer() { - this.setDrawer(this.finding); - }, - ...mapActions('findingsDrawer', ['setDrawer']), - }, }; </script> @@ -46,17 +32,7 @@ export default { class="inline-findings-severity-icon" /> </span> - <span - v-if="glFeatures.codeQualityInlineDrawer" - data-testid="description-button-section" - class="gl-display-flex" - > - <gl-link v-if="link" category="primary" variant="link" @click="toggleDrawer"> - {{ listText }}</gl-link - > - <span v-else>{{ listText }}</span> - </span> - <span v-else data-testid="description-plain-text" class="gl-display-flex"> + <span data-testid="description-plain-text" class="gl-display-flex"> {{ listText }} </span> </li> diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 318ecc89d14..ee6e9a2fc94 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -24,8 +24,8 @@ import * as utils from './diff_row_utils'; export default { DiffGutterAvatars, - InlineFindingsGutterIcon: () => - import('ee_component/diffs/components/inline_findings_gutter_icon.vue'), + InlineFindingsFlagSwitcher: () => + import('ee_component/diffs/components/inline_findings_flag_switcher.vue'), // Temporary mixin for migration from Vue.js 2 to @vue/compat mixins: [compatFunctionalMixin], @@ -338,10 +338,11 @@ export default { :class="$options.parallelViewLeftLineType(props)" > <component - :is="$options.InlineFindingsGutterIcon" + :is="$options.InlineFindingsFlagSwitcher" v-if="$options.showCodequalityLeft(props) || $options.showSecurityLeft(props)" :inline-findings-expanded="props.inlineFindingsExpanded" - :codequality="props.line.left.codequality" + :code-quality="props.line.left.codequality" + :sast="props.line.left.sast" :file-path="props.filePath" @showInlineFindings=" listeners.toggleCodeQualityFindings( @@ -479,9 +480,10 @@ export default { :class="$options.classNameMapCellRight(props)" > <component - :is="$options.InlineFindingsGutterIcon" + :is="$options.InlineFindingsFlagSwitcher" v-if="$options.showCodequalityRight(props) || $options.showSecurityRight(props)" - :codequality="props.line.right.codequality" + :code-quality="props.line.right.codequality" + :sast="props.line.right.sast" :file-path="props.filePath" data-testid="inlineFindingsIcon" @showInlineFindings=" diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 88ea4e15552..641616a34f5 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -186,7 +186,8 @@ export default { getCountBetweenIndex(index) { if (index === 0) { return -1; - } else if (!this.diffLines[index + 1]) { + } + if (!this.diffLines[index + 1]) { return -1; } diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index bbc602aedf6..7c68b5f69f1 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -910,7 +910,8 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) { if (!commitId) { return Promise.reject(new Error('`commitId` is a required argument')); - } else if (!state.commit) { + } + if (!state.commit) { return Promise.reject(new Error('`state` must already contain a valid `commit`')); // eslint-disable-line @gitlab/require-i18n-strings } diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index d82959daa9d..bfafb4d281d 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -139,7 +139,8 @@ export const fileLineCoverage = (state) => (file, line) => { if (lineCoverage === 0) { return { text: __('No test coverage'), class: 'no-coverage' }; - } else if (lineCoverage >= 0) { + } + if (lineCoverage >= 0) { return { text: n__('Test coverage: %d hit', 'Test coverage: %d hits', lineCoverage), class: 'coverage', diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js index 3c411d8093c..37bfde0ed9f 100644 --- a/app/assets/javascripts/drawio/drawio_editor.js +++ b/app/assets/javascripts/drawio/drawio_editor.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { base64DecodeUnicode } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import { setAttributes } from '~/lib/utils/dom_utils'; import { @@ -28,7 +29,7 @@ function disposeDrawioEditor(drawIOEditorState) { } function getSvg(data) { - const svgPath = atob(data.substring(data.indexOf(',') + 1)); + const svgPath = base64DecodeUnicode(data.substring(data.indexOf(',') + 1)); return `<?xml version="1.0" encoding="UTF-8"?>\n\ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n\ diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 65091487c93..2dba919cf58 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -845,6 +845,9 @@ "if": { "$ref": "#/definitions/if" }, + "changes": { + "$ref": "#/definitions/changes" + }, "exists": { "$ref": "#/definitions/exists" }, @@ -2084,6 +2087,10 @@ "publish": { "description": "A path to a directory that contains the files to be published with Pages", "type": "string" + }, + "pages_path_prefix": { + "description": "The path prefix identifier for this version of pages. Allows creation of multiple versions of the same site with different path prefixes", + "type": "string" } }, "oneOf": [ diff --git a/app/assets/javascripts/entrypoints/analytics.js b/app/assets/javascripts/entrypoints/analytics.js new file mode 100644 index 00000000000..8eb265cb1e8 --- /dev/null +++ b/app/assets/javascripts/entrypoints/analytics.js @@ -0,0 +1,17 @@ +import { glClientSDK } from '@gitlab/application-sdk-browser'; + +const { analytics_id: appId, analytics_url: host } = window.gon; + +if (appId && host) { + window.glClient = glClientSDK({ + appId, + host, + hasCookieConsent: true, + plugins: { + clientHints: false, + linkTracking: false, + performanceTiming: false, + errorTracking: false, + }, + }); +} diff --git a/app/assets/javascripts/entrypoints/jira_connect_app.js b/app/assets/javascripts/entrypoints/jira_connect_app.js new file mode 100644 index 00000000000..90ad39ea487 --- /dev/null +++ b/app/assets/javascripts/entrypoints/jira_connect_app.js @@ -0,0 +1 @@ +import '../jira_connect/subscriptions'; diff --git a/app/assets/javascripts/entrypoints/main.js b/app/assets/javascripts/entrypoints/main.js new file mode 100644 index 00000000000..6d59e89cfd0 --- /dev/null +++ b/app/assets/javascripts/entrypoints/main.js @@ -0,0 +1,6 @@ +import '../main'; +import { runModules } from '~/run_modules'; + +const modules = import.meta.glob('../pages/**/index.js'); + +runModules(modules, '../pages/'); diff --git a/app/assets/javascripts/entrypoints/main_ee.js b/app/assets/javascripts/entrypoints/main_ee.js new file mode 100644 index 00000000000..4a83be6be94 --- /dev/null +++ b/app/assets/javascripts/entrypoints/main_ee.js @@ -0,0 +1,5 @@ +import { runModules } from '~/run_modules'; + +const modules = import.meta.glob('../../../../ee/app/assets/javascripts/pages/**/index.js'); + +runModules(modules, '../../../../ee/app/assets/javascripts/pages/'); diff --git a/app/assets/javascripts/entrypoints/main_jh.js b/app/assets/javascripts/entrypoints/main_jh.js new file mode 100644 index 00000000000..92a42a9ac70 --- /dev/null +++ b/app/assets/javascripts/entrypoints/main_jh.js @@ -0,0 +1,5 @@ +import { runModules } from '~/run_modules'; + +const modules = import.meta.glob('../../../../jh/app/assets/javascripts/pages/**/index.js'); + +runModules(modules, '../../../../jh/app/assets/javascripts/pages/'); diff --git a/app/assets/javascripts/entrypoints/performance_bar.js b/app/assets/javascripts/entrypoints/performance_bar.js new file mode 100644 index 00000000000..3f6fc6272d0 --- /dev/null +++ b/app/assets/javascripts/entrypoints/performance_bar.js @@ -0,0 +1 @@ +import '../performance_bar'; diff --git a/app/assets/javascripts/entrypoints/redirect_listbox.js b/app/assets/javascripts/entrypoints/redirect_listbox.js new file mode 100644 index 00000000000..811a73fbf2f --- /dev/null +++ b/app/assets/javascripts/entrypoints/redirect_listbox.js @@ -0,0 +1 @@ +import './behaviors/redirect_listbox'; diff --git a/app/assets/javascripts/entrypoints/sandboxed_mermaid.js b/app/assets/javascripts/entrypoints/sandboxed_mermaid.js new file mode 100644 index 00000000000..d3dd144ffba --- /dev/null +++ b/app/assets/javascripts/entrypoints/sandboxed_mermaid.js @@ -0,0 +1 @@ +import '../lib/mermaid'; diff --git a/app/assets/javascripts/entrypoints/sentry.js b/app/assets/javascripts/entrypoints/sentry.js new file mode 100644 index 00000000000..debafc6fab3 --- /dev/null +++ b/app/assets/javascripts/entrypoints/sentry.js @@ -0,0 +1 @@ +import '../sentry/index'; diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index 96d2a8d9ba2..ef6d3b79198 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -209,7 +209,7 @@ export default { </div> <time-ago-tooltip v-if="createdAt" :time="createdAt" class="gl-display-flex"> <template #default="{ timeAgo }"> - <gl-icon name="calendar" /> + <gl-icon name="calendar" class="gl-mr-2" /> <span class="gl-mr-2 gl-white-space-nowrap">{{ timeAgo }}</span> </template> </time-ago-tooltip> diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index f90a1dcd193..0eebd81b671 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -4,7 +4,6 @@ import { createAlert } from '~/alert'; import { visitUrl } from '~/lib/utils/url_utility'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getEnvironment from '../graphql/queries/environment.query.graphql'; -import getEnvironmentWithFluxResource from '../graphql/queries/environment_with_flux_resource.query.graphql'; import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql'; import EnvironmentForm from './environment_form.vue'; @@ -17,11 +16,7 @@ export default { inject: ['projectEnvironmentsPath', 'projectPath', 'environmentName'], apollo: { environment: { - query() { - return this.glFeatures?.fluxResourceForEnvironment - ? getEnvironmentWithFluxResource - : getEnvironment; - }, + query: getEnvironment, variables() { return { environmentName: this.environmentName, diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index d49598d2f21..926c556966c 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -87,7 +87,7 @@ export default { </script> <template> <gl-disclosure-dropdown - :text="title" + :toggle-text="title" :title="title" :loading="isLoading" :aria-label="title" diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index d89dcf56b7c..846f2cf73b2 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -181,13 +181,8 @@ export default { namespaceDropdownToggleText() { return this.selectedNamespace || this.$options.i18n.namespaceHelpText; }, - isKasFluxResourceAvailable() { - return this.glFeatures?.fluxResourceForEnvironment; - }, showFluxResourceSelector() { - return Boolean( - this.isKasFluxResourceAvailable && this.selectedNamespace && this.selectedAgentId, - ); + return Boolean(this.selectedNamespace && this.selectedAgentId); }, k8sAccessConfiguration() { if (!this.showNamespaceSelector) { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index b02142c24cf..08a1eacec7a 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,6 +1,6 @@ <script> import { - GlDropdown, + GlDisclosureDropdown, GlTooltipDirective, GlIcon, GlLink, @@ -24,7 +24,6 @@ import PinComponent from './environment_pin.vue'; import RollbackComponent from './environment_rollback.vue'; import StopComponent from './environment_stop.vue'; import TerminalButtonComponent from './environment_terminal_button.vue'; - /** * Environment Item Component * @@ -36,7 +35,7 @@ export default { ActionsComponent, CommitComponent, ExternalUrlComponent, - GlDropdown, + GlDisclosureDropdown, GlBadge, GlIcon, GlLink, @@ -820,13 +819,13 @@ export default { data-track-label="environment_stop" /> - <gl-dropdown - v-if="hasExtraActions" - icon="ellipsis_v" + <gl-disclosure-dropdown text-sr-only - :text="__('More actions')" - category="secondary" no-caret + icon="ellipsis_v" + category="secondary" + placement="right" + :toggle-text="__('More actions')" > <rollback-component v-if="canRetry" @@ -857,7 +856,7 @@ export default { data-track-action="click_button" data-track-label="environment_delete" /> - </gl-dropdown> + </gl-disclosure-dropdown> </div> </div> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue index e8857dfe459..c603d83db9c 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -145,15 +145,20 @@ export default { syncStatusBadge() { if (!this.fluxCRD.length && this.fluxApiError) { return { ...SYNC_STATUS_BADGES.unavailable, popoverText: this.fluxApiError }; - } else if (!this.fluxCRD.length) { + } + if (!this.fluxCRD.length) { return SYNC_STATUS_BADGES.unavailable; - } else if (this.fluxAnyFailed) { + } + if (this.fluxAnyFailed) { return { ...SYNC_STATUS_BADGES.failed, popoverText: this.fluxAnyFailed.message }; - } else if (this.fluxAnyStalled) { + } + if (this.fluxAnyStalled) { return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message }; - } else if (this.fluxAnyReconciling) { + } + if (this.fluxAnyReconciling) { return SYNC_STATUS_BADGES.reconciling; - } else if (this.fluxAnyReconciled) { + } + if (this.fluxAnyReconciled) { return SYNC_STATUS_BADGES.reconciled; } return SYNC_STATUS_BADGES.unknown; diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 2148343f690..149cab21acd 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -14,7 +14,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql'; import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql'; -import getEnvironmentClusterAgentWithFluxResource from '../graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql'; import ExternalUrl from './environment_external_url.vue'; import Actions from './environment_actions.vue'; import StopComponent from './environment_stop.vue'; @@ -165,9 +164,6 @@ export default { rolloutStatus() { return this.environment?.rolloutStatus; }, - isFluxResourceAvailable() { - return this.glFeatures?.fluxResourceForEnvironment; - }, }, methods: { toggleEnvironmentCollapse() { @@ -185,9 +181,7 @@ export default { return { environmentName: this.environment.name, projectFullPath: this.projectPath }; }, query() { - return this.isFluxResourceAvailable - ? getEnvironmentClusterAgentWithFluxResource - : getEnvironmentClusterAgent; + return getEnvironmentClusterAgent; }, update(data) { this.clusterAgent = data?.project?.environment?.clusterAgent; diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue index f37f93798ae..261d8106438 100644 --- a/app/assets/javascripts/environments/environment_details/deployments_table.vue +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -36,7 +36,7 @@ export default { <deployment-status-link :deployment-job="item.job" :status="item.status" /> </template> <template #cell(id)="{ item }"> - <strong>{{ item.id }}</strong> + <strong data-testid="deployment-id">{{ item.id }}</strong> </template> <template #cell(triggerer)="{ item }"> <deployment-triggerer :triggerer="item.triggerer" /> diff --git a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql index 53dfe5303f3..2d6faed5c88 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql @@ -6,6 +6,7 @@ query getEnvironment($projectFullPath: ID!, $environmentName: String) { name externalUrl kubernetesNamespace + fluxResourcePath clusterAgent { id name diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql index 19374ae7a81..3f8874f2a8d 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql @@ -3,6 +3,7 @@ query getEnvironmentClusterAgent($projectFullPath: ID!, $environmentName: String id environment(name: $environmentName) { id + fluxResourcePath kubernetesNamespace clusterAgent { id diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql deleted file mode 100644 index 80363a06d42..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql +++ /dev/null @@ -1,21 +0,0 @@ -query getEnvironmentClusterAgentWithFluxResource($projectFullPath: ID!, $environmentName: String) { - project(fullPath: $projectFullPath) { - id - environment(name: $environmentName) { - id - kubernetesNamespace - fluxResourcePath - clusterAgent { - id - name - webPath - tokens { - nodes { - id - lastUsedAt - } - } - } - } - } -} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql deleted file mode 100644 index 166cd64189f..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query getEnvironmentWithFluxResource($projectFullPath: ID!, $environmentName: String) { - project(fullPath: $projectFullPath) { - id - environment(name: $environmentName) { - id - name - externalUrl - kubernetesNamespace - fluxResourcePath - clusterAgent { - id - name - } - } - } -} diff --git a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js index 55e2536e283..a64cc0405bb 100644 --- a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js @@ -3,7 +3,7 @@ * * Components need to have `scope`, `page` and `requestData` */ -import { validateParams } from '~/pipelines/utils'; +import { validateParams } from '~/ci/pipeline_details/utils'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; export default { diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index fb9a7a02d07..9924e1c7d7b 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -59,9 +59,6 @@ export const initHeader = () => { }; export const initPage = async () => { - if (!gon.features.environmentDetailsVue) { - return null; - } const EnvironmentsDetailPageModule = await import('./environment_details/index.vue'); const EnvironmentsDetailPage = EnvironmentsDetailPageModule.default; const dataElement = document.getElementById('environments-detail-view'); diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue index 420c34a88f1..ad80ee099ad 100644 --- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -1,25 +1,15 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlIcon, - GlLoadingIcon, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlCollapsibleListbox, GlButton } from '@gitlab/ui'; +import { debounce, memoize } from 'lodash'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; -import { __, sprintf } from '~/locale'; +import { __, n__, sprintf } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; export default { components: { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlSearchBoxByType, - GlIcon, - GlLoadingIcon, + GlButton, + GlCollapsibleListbox, }, inject: ['environmentsEndpoint'], data() { @@ -34,69 +24,96 @@ export default { noResultsLabel: __('No matching results'), }, computed: { + srOnlyResultsCount() { + return n__('%d environment found', '%d environments found', this.results.length); + }, createEnvironmentLabel() { return sprintf(__('Create %{environment}'), { environment: this.environmentSearch }); }, + isCreateEnvironmentShown() { + return !this.isLoading && this.results.length === 0 && Boolean(this.environmentSearch); + }, + }, + mounted() { + this.fetchEnvironments(); + }, + unmounted() { + // cancel debounce if the component is unmounted to avoid unnecessary fetches + this.fetchEnvironments.cancel(); + }, + created() { + this.fetch = memoize(async function fetchEnvironmentsFromApi(query) { + this.isLoading = true; + try { + const { data } = await axios.get(this.environmentsEndpoint, { params: { query } }); + + return data; + } catch { + createAlert({ + message: __('Something went wrong on our end. Please try again.'), + }); + return []; + } finally { + this.isLoading = false; + } + }); + + this.fetchEnvironments = debounce(function debouncedFetchEnvironments(query = '') { + this.fetch(query) + .then((data) => { + this.results = data.map((item) => ({ text: item, value: item })); + }) + .catch(() => { + this.results = []; + }); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { + onSelect(selected) { + this.$emit('add', selected[0]); + }, addEnvironment(newEnvironment) { this.$emit('add', newEnvironment); - this.environmentSearch = ''; this.results = []; }, - fetchEnvironments: debounce(function debouncedFetchEnvironments() { - this.isLoading = true; - axios - .get(this.environmentsEndpoint, { params: { query: this.environmentSearch } }) - .then(({ data }) => { - this.results = data || []; - }) - .catch(() => { - createAlert({ - message: __('Something went wrong on our end. Please try again.'), - }); - }) - .finally(() => { - this.isLoading = false; - }); - }, 250), - setFocus() { - this.$refs.searchBox.focusInput(); + onSearch(query) { + this.environmentSearch = query; + this.fetchEnvironments(query); }, }, }; </script> <template> - <gl-dropdown class="js-new-environments-dropdown" @shown="setFocus"> - <template #button-content> - <span class="d-md-none mr-1"> - {{ $options.translations.addEnvironmentsLabel }} - </span> - <gl-icon class="d-none d-md-inline-flex gl-mr-1" name="plus" /> + <gl-collapsible-listbox + icon="plus" + data-testid="new-environments-dropdown" + :toggle-text="$options.translations.addEnvironmentsLabel" + :items="results" + :searching="isLoading" + :header-text="$options.translations.addEnvironmentsLabel" + searchable + multiple + @search="onSearch" + @select="onSelect" + > + <template #footer> + <div + v-if="isCreateEnvironmentShown" + class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-p-2" + > + <gl-button + category="tertiary" + block + class="gl-justify-content-start!" + data-testid="add-environment-button" + @click="addEnvironment(environmentSearch)" + > + {{ createEnvironmentLabel }} + </gl-button> + </div> </template> - <gl-search-box-by-type - ref="searchBox" - v-model.trim="environmentSearch" - @focus="fetchEnvironments" - @keyup="fetchEnvironments" - /> - <gl-loading-icon v-if="isLoading" size="sm" /> - <gl-dropdown-item - v-for="environment in results" - v-else-if="results.length" - :key="environment" - @click="addEnvironment(environment)" - > - {{ environment }} - </gl-dropdown-item> - <template v-else-if="environmentSearch.length"> - <span ref="noResults" class="text-secondary gl-p-3"> - {{ $options.translations.noMatchingResults }} - </span> - <gl-dropdown-divider /> - <gl-dropdown-item @click="addEnvironment(environmentSearch)"> - {{ createEnvironmentLabel }} - </gl-dropdown-item> + <template #search-summary-sr-only> + {{ srOnlyResultsCount }} </template> - </gl-dropdown> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 684375177bb..8ccf7ba92a5 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -42,7 +42,7 @@ export default class FilteredSearchManager { useDefaultState = false, filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', - placeholder = __('Search or filter results...'), + placeholder = __('Search or filter results…'), anchor = null, }) { this.isGroup = isGroup; diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index 1c33c8b1084..f71405a5bc4 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -24,7 +24,8 @@ export const getTopFrequentItems = (items) => { // and then by lastAccessedOn with recent most first if (itemA.frequency !== itemB.frequency) { return itemB.frequency - itemA.frequency; - } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { + } + if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { return itemB.lastAccessedOn - itemA.lastAccessedOn; } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 9e7006bb6e7..264427f5806 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -393,13 +393,16 @@ class GfmAutoComplete { if (command === MEMBER_COMMAND.ASSIGN) { // Only include members which are not assigned to Issuable currently return data.filter((member) => !assignees.includes(member.search)); - } else if (command === MEMBER_COMMAND.UNASSIGN) { + } + if (command === MEMBER_COMMAND.UNASSIGN) { // Only include members which are assigned to Issuable currently return data.filter((member) => assignees.includes(member.search)); - } else if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) { + } + if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) { // Only include members which are not assigned as a reviewer to Issuable currently return data.filter((member) => !reviewers.includes(member.search)); - } else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) { + } + if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) { // Only include members which are not assigned as a reviewer to Issuable currently return data.filter((member) => reviewers.includes(member.search)); } @@ -617,11 +620,6 @@ class GfmAutoComplete { if (labels.find((label) => label.title.startsWith(lastCandidate))) { return lastCandidate; } - } else { - // Load all labels into the autocompleter. - // This needs to happen if e.g. editing a label in an existing comment, because normally - // label data would only be loaded only once you type `~`. - fetchData(this.$inputor, this.at); } const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); @@ -642,7 +640,8 @@ class GfmAutoComplete { if (command === LABEL_COMMAND.LABEL || command === LABEL_COMMAND.LABELS) { // Return labels with set: undefined. return data.filter((label) => !label.set); - } else if (command === LABEL_COMMAND.UNLABEL) { + } + if (command === LABEL_COMMAND.UNLABEL) { // Return labels with set: true. return data.filter((label) => label.set); } @@ -751,7 +750,8 @@ class GfmAutoComplete { if (command === CONTACTS_ADD_COMMAND) { // Return contacts that are active and not already on the issue return data.filter((contact) => contact.state === CONTACT_STATE_ACTIVE && !contact.set); - } else if (command === CONTACTS_REMOVE_COMMAND) { + } + if (command === CONTACTS_REMOVE_COMMAND) { // Return contacts already on the issue return data.filter((contact) => contact.set); } @@ -779,10 +779,8 @@ class GfmAutoComplete { if (GfmAutoComplete.isLoading(data)) { self.fetchData(this.$inputor, this.at); return data; - } else if ( - GfmAutoComplete.isTypeWithBackendFiltering(this.at) && - self.previousQuery !== query - ) { + } + if (GfmAutoComplete.isTypeWithBackendFiltering(this.at) && self.previousQuery !== query) { self.fetchData(this.$inputor, this.at, query); self.previousQuery = query; return data; diff --git a/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue b/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue index 1536a9a525b..25ddfc911f3 100644 --- a/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue +++ b/app/assets/javascripts/gitlab_version_check/components/gitlab_version_check_badge.vue @@ -30,9 +30,11 @@ export default { title() { if (this.status === STATUS_TYPES.SUCCESS) { return s__('VersionCheck|Up to date'); - } else if (this.status === STATUS_TYPES.WARNING) { + } + if (this.status === STATUS_TYPES.WARNING) { return s__('VersionCheck|Update available'); - } else if (this.status === STATUS_TYPES.DANGER) { + } + if (this.status === STATUS_TYPES.DANGER) { return s__('VersionCheck|Update ASAP'); } diff --git a/app/assets/javascripts/google_cloud/deployments/service_table.vue b/app/assets/javascripts/google_cloud/deployments/service_table.vue index 26c9fd14dc6..9388b41127e 100644 --- a/app/assets/javascripts/google_cloud/deployments/service_table.vue +++ b/app/assets/javascripts/google_cloud/deployments/service_table.vue @@ -34,7 +34,7 @@ export default { methods: { actionUrl(key) { if (key === cloudRun) return this.cloudRunUrl; - else if (key === cloudStorage) return this.cloudStorageUrl; + if (key === cloudStorage) return this.cloudStorageUrl; return '#'; }, }, diff --git a/app/assets/javascripts/google_cloud/service_accounts/list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue index 635b185d207..ebba9332c53 100644 --- a/app/assets/javascripts/google_cloud/service_accounts/list.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue @@ -63,6 +63,7 @@ export default { :primary-button-link="createUrl" :primary-button-text="$options.i18n.createServiceAccount" :svg-path="emptyIllustrationUrl" + :svg-height="150" /> <div v-else> diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index e6c0b86d9a6..37c1674cc5a 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -48,6 +48,10 @@ "ExternalAuditEventDestination", "InstanceExternalAuditEventDestination" ], + "GoogleCloudLoggingConfigurationInterface": [ + "GoogleCloudLoggingConfigurationType", + "InstanceGoogleCloudLoggingConfigurationType" + ], "Issuable": [ "Epic", "Issue", @@ -139,6 +143,7 @@ "WorkItem" ], "User": [ + "AddOnUser", "AutocompletedUser", "MergeRequestAssignee", "MergeRequestAuthor", diff --git a/app/assets/javascripts/graphql_shared/queries/project_autocomplete_users.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_autocomplete_users.query.graphql new file mode 100644 index 00000000000..ed318ef1b8d --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/project_autocomplete_users.query.graphql @@ -0,0 +1,12 @@ +#import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query projectAutocompleteUsersSearch($search: String!, $fullPath: ID!) { + workspace: project(fullPath: $fullPath) { + id + users: autocompleteUsers(search: $search) { + ...User + ...UserAvailability + } + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/project_autocomplete_users_with_mr_permissions.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_autocomplete_users_with_mr_permissions.query.graphql new file mode 100644 index 00000000000..8155451fb7c --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/project_autocomplete_users_with_mr_permissions.query.graphql @@ -0,0 +1,19 @@ +#import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query projectAutocompleteUsersSearchWithMRPermissions( + $search: String! + $fullPath: ID! + $mergeRequestId: MergeRequestID! +) { + workspace: project(fullPath: $fullPath) { + id + users: autocompleteUsers(search: $search) { + ...User + ...UserAvailability + mergeRequestInteraction(id: $mergeRequestId) { + canMerge + } + } + } +} diff --git a/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue new file mode 100644 index 00000000000..470ff45f47a --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue @@ -0,0 +1,24 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + inject: ['groupsEmptyStateIllustration'], + i18n: { + title: s__('GroupsEmptyState|A group is a collection of several projects'), + description: s__( + "GroupsEmptyState|If you organize your projects under a group, it works like a folder. You can manage your group member's permissions and access to each project in the group.", + ), + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :description="$options.i18n.description" + :svg-path="groupsEmptyStateIllustration" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue new file mode 100644 index 00000000000..f524b769802 --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue @@ -0,0 +1,17 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { __ } from '~/locale'; + +export default { + components: { GlEmptyState }, + inject: ['groupsEmptyStateIllustration'], + i18n: { + title: __('No public groups'), + }, +}; +</script> + +<template> + <gl-empty-state :title="$options.i18n.title" :svg-path="groupsEmptyStateIllustration" /> +</template> diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index e71ff6d9107..2539d899865 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -13,7 +13,7 @@ import GroupsStore from './store/groups_store'; Vue.use(Translate); -export default () => { +export default (EmptyStateComponent) => { const el = document.getElementById('js-groups-tree'); // eslint-disable-next-line no-new @@ -36,16 +36,19 @@ export default () => { components: { GroupsApp, }, + provide() { + const { groupsEmptyStateIllustration } = dataset; + + return { groupsEmptyStateIllustration }; + }, data() { const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); - const renderEmptyState = parseBoolean(dataset.renderEmptyState); const service = new GroupsService(dataset.endpoint); const store = new GroupsStore({ hideProjects: true, showSchemaMarkup }); return { store, service, - renderEmptyState, loading: true, }; }, @@ -74,7 +77,9 @@ export default () => { props: { store: this.store, service: this.service, - renderEmptyState: this.renderEmptyState, + }, + scopedSlots: { + 'empty-state': () => createElement(EmptyStateComponent), }, }); }, diff --git a/app/assets/javascripts/helpers/avatar_helper.js b/app/assets/javascripts/helpers/avatar_helper.js deleted file mode 100644 index 35b09e9b027..00000000000 --- a/app/assets/javascripts/helpers/avatar_helper.js +++ /dev/null @@ -1,38 +0,0 @@ -import { escape } from 'lodash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; - -export const DEFAULT_SIZE_CLASS = 's40'; -export const IDENTICON_BG_COUNT = 7; - -export function getIdenticonBackgroundClass(entityId) { - // If a GraphQL string id is passed in, convert it to the entity number - const id = typeof entityId === 'string' ? getIdFromGraphQLId(entityId) : entityId; - const type = (id % IDENTICON_BG_COUNT) + 1; - return `bg${type}`; -} - -export function getIdenticonTitle(entityName) { - return getFirstCharacterCapitalized(entityName) || ' '; -} - -export function renderIdenticon(entity, options = {}) { - const { sizeClass = DEFAULT_SIZE_CLASS } = options; - - const bgClass = getIdenticonBackgroundClass(entity.id); - const title = getIdenticonTitle(entity.name); - - return `<div class="avatar identicon ${escape(sizeClass)} ${escape(bgClass)}">${escape( - title, - )}</div>`; -} - -export function renderAvatar(entity, options = {}) { - if (!entity.avatar_url) { - return renderIdenticon(entity, options); - } - - const { sizeClass = DEFAULT_SIZE_CLASS } = options; - - return `<img src="${escape(entity.avatar_url)}" class="avatar ${escape(sizeClass)}" />`; -} diff --git a/app/assets/javascripts/ide/commit_icon.js b/app/assets/javascripts/ide/commit_icon.js index 70ee9cff22b..07eef897910 100644 --- a/app/assets/javascripts/ide/commit_icon.js +++ b/app/assets/javascripts/ide/commit_icon.js @@ -3,7 +3,8 @@ import { commitItemIconMap } from './constants'; export default (file) => { if (file.deleted) { return commitItemIconMap.deleted; - } else if (file.tempFile && !file.prevPath) { + } + if (file.tempFile && !file.prevPath) { return commitItemIconMap.addition; } diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue deleted file mode 100644 index f58a35e7624..00000000000 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ /dev/null @@ -1,103 +0,0 @@ -<!-- eslint-disable vue/multi-word-component-names --> -<script> -import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; -import $ from 'jquery'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; -import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; - -export default { - components: { - DropdownButton, - GlIcon, - GlLoadingIcon, - }, - props: { - data: { - type: Array, - required: false, - default: () => [], - }, - label: { - type: String, - required: true, - }, - title: { - type: String, - required: false, - default: null, - }, - isAsyncData: { - type: Boolean, - required: false, - default: false, - }, - searchable: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - search: '', - }; - }, - computed: { - ...mapState('fileTemplates', ['templates', 'isLoading']), - outputData() { - return (this.isAsyncData ? this.templates : this.data).filter((t) => { - if (!this.searchable) return true; - - return t.name.toLowerCase().indexOf(this.search.toLowerCase()) >= 0; - }); - }, - showLoading() { - return this.isAsyncData ? this.isLoading : false; - }, - }, - mounted() { - $(this.$el).on('show.bs.dropdown', this.fetchTemplatesIfAsync); - }, - beforeDestroy() { - $(this.$el).off('show.bs.dropdown', this.fetchTemplatesIfAsync); - }, - methods: { - ...mapActions('fileTemplates', ['fetchTemplateTypes']), - fetchTemplatesIfAsync() { - if (this.isAsyncData) { - this.fetchTemplateTypes(); - } - }, - clickItem(item) { - this.$emit('click', item); - }, - }, -}; -</script> - -<template> - <div class="dropdown"> - <dropdown-button :toggle-text="label" data-display="static" /> - <div class="dropdown-menu pb-0"> - <div v-if="title" class="dropdown-title ml-0 mr-0">{{ title }}</div> - <div v-if="!showLoading && searchable" class="dropdown-input"> - <input - v-model="search" - :placeholder="__('Filter...')" - type="search" - class="dropdown-input-field" - /> - <gl-icon name="search" class="dropdown-input-search" /> - </div> - <div class="dropdown-content"> - <gl-loading-icon v-if="showLoading" size="lg" /> - <ul v-else> - <li v-for="(item, index) in outputData" :key="index"> - <button type="button" @click="clickItem(item)">{{ item.name }}</button> - </li> - </ul> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 854daa20628..741845e3325 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -32,7 +32,8 @@ export default { if (this.modalType === modalTypes.tree) { return __('Create new directory'); - } else if (this.modalType === modalTypes.rename) { + } + if (this.modalType === modalTypes.rename) { return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } @@ -43,7 +44,8 @@ export default { if (this.modalType === modalTypes.tree) { return __('Create directory'); - } else if (this.modalType === modalTypes.rename) { + } + if (this.modalType === modalTypes.rename) { return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue index 9e8b3d87397..b2e90a64758 100644 --- a/app/assets/javascripts/ide/components/terminal/terminal.vue +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -37,7 +37,8 @@ export default { loadingText() { if (isStartingStatus(this.status)) { return __('Starting...'); - } else if (this.status === STOPPING) { + } + if (this.status === STOPPING) { return __('Stopping...'); } diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue index 38e53b64503..332408b9ecf 100644 --- a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue @@ -31,12 +31,14 @@ export default { icon: '', text: this.isStarted ? MSG_TERMINAL_SYNC_UPLOADING : MSG_TERMINAL_SYNC_CONNECTING, }; - } else if (this.isError) { + } + if (this.isError) { return { icon: 'warning', text: this.message, }; - } else if (this.isStarted) { + } + if (this.isStarted) { return { icon: 'mobile-issue-close', text: MSG_TERMINAL_SYNC_RUNNING, diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 11a0095db92..2e113003f8a 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -62,9 +62,8 @@ export const initGitlabWebIDE = async (el) => { filePath, mrId, mrTargetProject: getMRTargetProject(), - // note: At the time of writing this, forkInfo isn't expected by `@gitlab/web-ide`, - // but it will be soon. forkInfo, + username: gon.current_username, links: { feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, userPreferences: el.dataset.userPreferencesPath, diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 7595a1cedf1..ec28845d805 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -7,9 +7,11 @@ import DirtyDiffWorker from './diff_worker?worker'; export const getDiffChangeType = (change) => { if (change.modified) { return 'modified'; - } else if (change.added) { + } + if (change.added) { return 'added'; - } else if (change.removed) { + } + if (change.removed) { return 'removed'; } diff --git a/app/assets/javascripts/ide/lib/errors.js b/app/assets/javascripts/ide/lib/errors.js index a8a048e588f..5063cf5fd4f 100644 --- a/app/assets/javascripts/ide/lib/errors.js +++ b/app/assets/javascripts/ide/lib/errors.js @@ -55,9 +55,11 @@ export const parseCommitError = (e) => { if (CODEOWNERS_REGEX.test(message)) { return createCodeownersCommitError(message); - } else if (BRANCH_CHANGED_REGEX.test(message)) { + } + if (BRANCH_CHANGED_REGEX.test(message)) { return createBranchChangedCommitError(message); - } else if (BRANCH_ALREADY_EXISTS.test(message)) { + } + if (BRANCH_ALREADY_EXISTS.test(message)) { return branchAlreadyExistsCommitError(message); } diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index 3fdf012bbb2..415e34f56b8 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -35,7 +35,8 @@ export const decorateFiles = ({ const insertParent = (path) => { if (!path) { return null; - } else if (entries[path]) { + } + if (entries[path]) { return entries[path]; } diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js index f437965b25a..286798d7560 100644 --- a/app/assets/javascripts/ide/lib/mirror.js +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -32,7 +32,8 @@ const isErrorPayload = (payload) => payload && payload.status_code !== HTTP_STAT const getErrorFromResponse = (data) => { if (isErrorResponse(data.error)) { return { message: data.error.Message }; - } else if (isErrorPayload(data.payload)) { + } + if (isErrorPayload(data.payload)) { return { message: data.payload.error_message }; } diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index f4fa52b2d4d..11e3d8260f7 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -133,7 +133,8 @@ export const loadBranch = ({ dispatch, getters, state }, { projectId, branchId } if (currentProject?.branches?.[branchId]) { return Promise.resolve(); - } else if (getters.emptyRepo) { + } + if (getters.emptyRepo) { return dispatch('loadEmptyBranch', { projectId, branchId }); } diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index c0f666c6652..74fe61b6e2f 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -30,7 +30,8 @@ const getCannotPushCodeViewModel = (state) => { text: MSG_GO_TO_FORK, }, }; - } else if (forkPath) { + } + if (forkPath) { return { message: MSG_CANNOT_PUSH_CODE_SHOULD_FORK, action: { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js index ad7ad35a98c..a2b45f9dc62 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/messages.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -39,7 +39,8 @@ export const configCheckError = (status, helpUrl) => { }, false, ); - } else if (status === HTTP_STATUS_FORBIDDEN) { + } + if (status === HTTP_STATUS_FORBIDDEN) { return ERROR_PERMISSION; } diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index ec661fdb0d6..bac3803e68f 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -81,9 +81,11 @@ export const setPageTitleForFile = (state, file) => { export const commitActionForFile = (file) => { if (file.prevPath) { return commitActionTypes.move; - } else if (file.deleted) { + } + if (file.deleted) { return commitActionTypes.delete; - } else if (file.tempFile) { + } + if (file.tempFile) { return commitActionTypes.create; } @@ -131,7 +133,8 @@ export const createNewMergeRequestUrl = (projectUrl, source, target) => const sortTreesByTypeAndName = (a, b) => { if (a.type === 'tree' && b.type === 'blob') { return -1; - } else if (a.type === 'blob' && b.type === 'tree') { + } + if (a.type === 'blob' && b.type === 'tree') { return 1; } if (a.name < b.name) return -1; diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 94c04123112..91436457b03 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -133,9 +133,11 @@ export default { if (fetched === imported) { return { name: 'status-success', class: 'gl-text-green-400' }; - } else if (imported === 0) { + } + if (imported === 0) { return { name: 'status-scheduled', class: 'gl-text-gray-400' }; - } else if (this.status === STATUSES.FINISHED) { + } + if (this.status === STATUSES.FINISHED) { return { name: 'status-alert', class: 'gl-text-orange-400' }; } diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index e5a88cf9510..782f417a989 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -11,9 +11,9 @@ import { GlIcon, GlEmptyState, } from '@gitlab/ui'; -import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; import { STATUS_CLOSED } from '~/issues/constants'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; +import { isValidDateString } from '~/lib/utils/datetime_range'; import { s__, n__ } from '~/locale'; import { INCIDENT_SEVERITY } from '~/sidebar/constants'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; @@ -330,7 +330,7 @@ export default { item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES, ); }, - isValidSlaDueAt, + isValidDateString, }, }; </script> @@ -357,8 +357,7 @@ export default { <gl-button v-if="isHeaderButtonVisible" class="gl-my-3 gl-mr-5 create-incident-button" - data-testid="createIncidentBtn" - data-qa-selector="create_incident_button" + data-testid="create-incident-button" :loading="redirecting" :disabled="redirecting" category="primary" @@ -406,7 +405,6 @@ export default { > <gl-link data-testid="incident-link" - data-qa-selector="incident_link" :href="showIncidentLink(item)" class="gl-min-w-0" > @@ -443,7 +441,7 @@ export default { <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> <service-level-agreement-cell - v-if="isValidSlaDueAt(item.slaDueAt)" + v-if="isValidDateString(item.slaDueAt)" :issue-iid="item.iid" :project-path="projectPath" :sla-due-at="item.slaDueAt" diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue index fe3f4ed4bf9..0a5bb82f151 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -25,7 +25,7 @@ export default { <template> <section id="incident-management-settings" - data-qa-selector="incidents_settings_content" + data-testid="incidents-settings-content" class="settings no-animate" > <div class="settings-header"> diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue index eff64ed7c42..c94c509e811 100644 --- a/app/assets/javascripts/integrations/index/components/integrations_table.vue +++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue @@ -1,13 +1,22 @@ <script> -import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlAvatarLink, + GlButton, + GlIcon, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { + GlAvatarLabeled, + GlAvatarLink, + GlButton, GlIcon, - GlLink, GlTable, TimeAgoTooltip, }, @@ -49,17 +58,12 @@ export default { key: 'active', label: '', thClass: 'gl-w-7', + tdClass: 'gl-vertical-align-middle!', }, { key: 'title', label: __('Integration'), - thClass: 'gl-w-quarter gl-xs-w-full', - }, - { - key: 'description', - label: __('Description'), - thClass: 'gl-display-none d-sm-table-cell', - tdClass: 'gl-display-none d-sm-table-cell', + thClass: 'd-sm-table-cell', }, ); @@ -67,11 +71,17 @@ export default { fields.push({ key: 'updated_at', label: this.showUpdatedAt ? __('Last updated') : '', - thClass: 'gl-w-20 gl-text-right', - tdClass: 'gl-text-right', + thClass: 'gl-display-none d-sm-table-cell gl-text-right', + tdClass: 'gl-text-right gl-display-none d-sm-table-cell gl-vertical-align-middle!', }); } + fields.push({ + key: 'edit_path', + label: '', + thClass: 'gl-w-15', + }); + return fields; }, filteredIntegrations() { @@ -83,8 +93,11 @@ export default { }, methods: { getStatusTooltipTitle(integration) { - return sprintf(s__('Integrations|%{integrationTitle}: active'), { + const status = integration.active ? 'active' : 'inactive'; + + return sprintf(s__('Integrations|%{integrationTitle}: %{status}'), { integrationTitle: integration.title, + status, }); }, }, @@ -95,26 +108,41 @@ export default { <gl-table :items="filteredIntegrations" :fields="fields" :empty-text="emptyText" show-empty fixed> <template #cell(active)="{ item }"> <gl-icon - v-if="item.active" + v-if="item.configured" v-gl-tooltip - name="check" - class="gl-text-green-500" + :name="item.active ? 'status-success' : 'status-paused'" + :class="item.active ? 'gl-text-green-500' : 'gl-text-gray-500'" :title="getStatusTooltipTitle(item)" /> </template> <template #cell(title)="{ item }"> - <gl-link + <gl-avatar-link :href="item.edit_path" - class="gl-font-weight-bold" + :title="item.title" :data-qa-selector="`${item.name}_link`" > - {{ item.title }} - </gl-link> + <gl-avatar-labeled + :label="item.title" + :sub-label="item.description" + :entity-id="item.id" + :entity-name="item.title" + :src="item.icon" + :size="32" + shape="rect" + :label-link="item.edit_path" + /> + </gl-avatar-link> </template> <template #cell(updated_at)="{ item }"> <time-ago-tooltip v-if="showUpdatedAt && item.updated_at" :time="item.updated_at" /> </template> + + <template #cell(edit_path)="{ item }"> + <gl-button :href="item.edit_path"> + {{ __('Configure') }} + </gl-button> + </template> </gl-table> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index e99a61caf3f..e9d7acdc913 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,28 +1,14 @@ <script> -import { - GlAlert, - GlCollapsibleListbox, - GlLink, - GlSprintf, - GlFormCheckboxGroup, - GlButton, - GlCollapse, - GlIcon, -} from '@gitlab/ui'; +import { GlAlert, GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import Tracking from '~/tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { n__, sprintf } from '~/locale'; -import { - memberName, - triggerExternalAlert, - qualifiesForTasksToBeDone, -} from 'ee_else_ce/invite_members/utils/member_utils'; +import { memberName, triggerExternalAlert } from 'ee_else_ce/invite_members/utils/member_utils'; import { USERS_FILTER_ALL, - INVITE_MEMBERS_FOR_TASK, MEMBER_MODAL_LABELS, INVITE_MEMBER_MODAL_TRACKING_CATEGORY, } from '../constants'; @@ -41,10 +27,6 @@ export default { name: 'InviteMembersModal', components: { GlAlert, - GlLink, - GlCollapsibleListbox, - GlSprintf, - GlFormCheckboxGroup, GlButton, GlCollapse, GlIcon, @@ -56,7 +38,6 @@ export default { import('ee_component/invite_members/components/active_trial_notification.vue'), }, mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })], - inject: ['newProjectPath'], props: { id: { type: String, @@ -96,14 +77,6 @@ export default { required: false, default: null, }, - tasksToBeDoneOptions: { - type: Array, - required: true, - }, - projects: { - type: Array, - required: true, - }, fullPath: { type: String, required: true, @@ -131,9 +104,6 @@ export default { modalId: uniqueId('invite-members-modal-'), newUsersToInvite: [], invalidMembers: {}, - selectedTasksToBeDone: [], - selectedTaskProject: this.projects[0], - selectedTaskProjectId: this.projects[0]?.id, source: 'unknown', mode: 'default', // Kept in sync with "base" @@ -141,7 +111,6 @@ export default { errorsLimit: 2, isErrorsSectionExpanded: false, shouldShowEmptyInvitesAlert: false, - projectsForDropdown: this.projects.map((p) => ({ value: p.id, text: p.title, ...p })), }; }, computed: { @@ -170,26 +139,6 @@ export default { this.errorList.length, ); }, - tasksToBeDoneEnabled() { - return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length; - }, - showTasksToBeDone() { - return ( - this.tasksToBeDoneEnabled && - this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level - ); - }, - showTaskProjects() { - return !this.isProject && this.selectedTasksToBeDone.length; - }, - tasksToBeDoneForPost() { - return this.showTasksToBeDone ? this.selectedTasksToBeDone : []; - }, - tasksProjectForPost() { - return this.showTasksToBeDone && this.selectedTasksToBeDone.length - ? this.selectedTaskProject.id - : ''; - }, showUserLimitNotification() { return !isEmpty(this.usersLimitDataset.alertVariant); }, @@ -243,10 +192,6 @@ export default { eventHub.$on('openModal', (options) => { this.openModal(options); }); - - if (this.tasksToBeDoneEnabled) { - this.openModal({ source: 'in_product_marketing_email' }); - } }, methods: { showInvalidFeedbackMessage(response) { @@ -289,8 +234,6 @@ export default { expires_at: expiresAt, access_level: accessLevel, invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, ...email, ...userId, }; @@ -304,8 +247,6 @@ export default { return; } - this.trackInviteMembersForTask(); - const apiAddByInvite = this.isProject ? Api.inviteProjectMembers.bind(Api) : Api.inviteGroupMembers.bind(Api); @@ -317,7 +258,7 @@ export default { const { error, message } = responseFromSuccess(response); if (error) { - this.showMemberErrors(message); + this.showErrors(message); } else { this.onInviteSuccess(); } @@ -327,19 +268,18 @@ export default { this.isLoading = false; } }, - showMemberErrors(message) { - this.invalidMembers = message; - this.$refs.alerts.focus(); + showErrors(message) { + if (isString(message)) { + this.invalidFeedbackMessage = message; + } else { + this.invalidMembers = message; + this.$refs.alerts.focus(); + } }, tokenName(username) { // initial token creation hits this and nothing is found... so safe navigation return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; }, - trackInviteMembersForTask() { - const label = 'selected_tasks_to_be_done'; - const property = this.selectedTasksToBeDone.join(','); - this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property }); - }, onCancel() { this.track('click_cancel', { label: this.source }); }, @@ -351,11 +291,6 @@ export default { this.isLoading = false; this.shouldShowEmptyInvitesAlert = false; this.newUsersToInvite = []; - this.selectedTasksToBeDone = []; - [this.selectedTaskProject] = this.projects; - }, - changeSelectedTaskProject(projectId) { - this.selectedTaskProject = this.projects.find((project) => project.id === projectId); }, onInviteSuccess() { this.track('invite_successful', { label: this.source }); @@ -513,46 +448,5 @@ export default { @token-remove="removeToken" /> </template> - <template #form-after> - <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> - <label class="gl-mt-5"> - {{ $options.labels.tasksToBeDone.title }} - </label> - <template v-if="projects.length"> - <gl-form-checkbox-group - v-model="selectedTasksToBeDone" - :options="tasksToBeDoneOptions" - data-testid="invite-members-modal-tasks" - /> - <template v-if="showTaskProjects"> - <label class="gl-mt-5 gl-display-block"> - {{ $options.labels.tasksProject.title }} - </label> - <gl-collapsible-listbox - v-model="selectedTaskProjectId" - :items="projectsForDropdown" - :block="true" - class="gl-w-half gl-xs-w-full" - data-testid="invite-members-modal-project-select" - @select="changeSelectedTaskProject" - /> - </template> - </template> - <gl-alert - v-else-if="tasksToBeDoneEnabled" - variant="tip" - :dismissible="false" - data-testid="invite-members-modal-no-projects-alert" - > - <gl-sprintf :message="$options.labels.tasksToBeDone.noProjects"> - <template #link="{ content }"> - <gl-link :href="newProjectPath" target="_blank" class="gl-label-link"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> - </div> - </template> </invite-modal-base> </template> 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 91b623821dd..5a891e23faf 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -330,8 +330,6 @@ export default { :target="null" /> </gl-form-group> - - <slot name="form-after"></slot> </template> <template v-for="{ key } in extraSlots" #[key]> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 1cee0c32008..93386e5504b 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -5,10 +5,6 @@ export const PROJECT_SELECT_LABEL_ID = 'project-select'; export const SEARCH_DELAY = 200; export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100'; export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100'; -export const INVITE_MEMBERS_FOR_TASK = { - minimum_access_level: 30, - submit: 'submit', -}; export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully'; export const GROUP_FILTERS = { @@ -46,15 +42,6 @@ export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__( ); export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|Username or email address'); export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses'); -export const MEMBERS_TASKS_TO_BE_DONE_TITLE = s__( - 'InviteMembersModal|Create issues for your new team member to work on (optional)', -); -export const MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS = s__( - 'InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}', -); -export const MEMBERS_TASKS_PROJECTS_TITLE = s__( - 'InviteMembersModal|Choose a project for the issues', -); export const GROUP_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite a group'); export const GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT = s__( @@ -123,13 +110,6 @@ export const MEMBER_MODAL_LABELS = { }, searchField: MEMBERS_SEARCH_FIELD, placeHolder: MEMBERS_PLACEHOLDER, - tasksToBeDone: { - title: MEMBERS_TASKS_TO_BE_DONE_TITLE, - noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, - }, - tasksProject: { - title: MEMBERS_TASKS_PROJECTS_TITLE, - }, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, memberErrorListText: MEMBER_ERROR_LIST_TEXT, collapsedErrors: COLLAPSED_ERRORS, diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 4f539cd8756..41ed0179364 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -25,7 +25,6 @@ export default (function initInviteMembersModal() { name: 'InviteMembersModalRoot', provide: { name: el.dataset.name, - newProjectPath: el.dataset.newProjectPath, }, render: (createElement) => createElement(InviteMembersModal, { @@ -34,8 +33,6 @@ export default (function initInviteMembersModal() { isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), - tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), - projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), usersLimitDataset: convertObjectPropsToCamelCase( diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js index 240a3a89686..7998cb69445 100644 --- a/app/assets/javascripts/invite_members/utils/member_utils.js +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -1,5 +1,3 @@ -import { getParameterValues } from '~/lib/utils/url_utility'; - export function memberName(member) { // user defined tokens(invites by email) will have email in `name` and will not contain `username` return member.username || member.name; @@ -8,7 +6,3 @@ export function memberName(member) { export function triggerExternalAlert() { return false; } - -export function qualifiesForTasksToBeDone() { - return getParameterValues('open_modal')[0] === 'invite_members_for_task'; -} diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue index c1de507cd80..fd279a6a451 100644 --- a/app/assets/javascripts/issuable/components/csv_export_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -47,7 +47,7 @@ export default { href: this.exportCsvPath, variant: 'confirm', 'data-method': 'post', - 'data-qa-selector': `export_issues_button`, + 'data-testid': 'export-issues-button', 'data-track-action': 'click_button', 'data-track-label': this.dataTrackLabel, }, @@ -78,7 +78,7 @@ export default { :action-cancel="$options.actionCancel" body-class="gl-p-0!" :title="exportText" - data-qa-selector="export_issuable_modal" + data-testid="export-issuable-modal" > <div class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50" diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index 872e1d4269d..8e2ed63613d 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -76,7 +76,6 @@ export default { v-if="showExportButton" v-gl-modal="exportModalId" data-testid="export-as-csv-button" - data-qa-selector="export_as_csv_button" :item="dropdownItems.exportAsCSV" /> <gl-disclosure-dropdown-item @@ -88,7 +87,6 @@ export default { <gl-disclosure-dropdown-item v-if="showImportButton && canEdit" data-testid="import-from-jira-link" - data-qa-selector="import_from_jira_link" :item="dropdownItems.importFromJIRA" /> diff --git a/app/assets/javascripts/issuable/components/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue index d8cbc45684b..2181b4e1a40 100644 --- a/app/assets/javascripts/issuable/components/issue_assignees.vue +++ b/app/assets/javascripts/issuable/components/issue_assignees.vue @@ -89,7 +89,7 @@ export default { :img-size="iconSize" class="js-no-trigger author-link" tooltip-placement="bottom" - data-qa-selector="assignee_link" + data-testid="assignee-link" > <span class="js-assignee-tooltip"> <span class="bold d-block">{{ s__('Label|Assignee') }}</span> {{ assignee.name }} @@ -101,7 +101,7 @@ export default { v-gl-tooltip.bottom :title="assigneesCounterTooltip" class="avatar-counter" - data-qa-selector="avatar_counter_content" + data-testid="avatar-counter-content" >{{ assigneeCounterLabel }}</span > </div> diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index c7da3e59098..3340ef2338c 100644 --- a/app/assets/javascripts/issuable/components/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue @@ -42,7 +42,8 @@ export default { milestoneDatesAbsolute() { if (this.milestoneDue) { return `(${dateInWords(this.milestoneDue)})`; - } else if (this.milestoneStart) { + } + if (this.milestoneStart) { return `(${dateInWords(this.milestoneStart)})`; } return ''; diff --git a/app/assets/javascripts/issuable/components/status_badge.vue b/app/assets/javascripts/issuable/components/status_badge.vue new file mode 100644 index 00000000000..949fb3c1ce5 --- /dev/null +++ b/app/assets/javascripts/issuable/components/status_badge.vue @@ -0,0 +1,98 @@ +<script> +import { GlBadge, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { + STATUS_CLOSED, + STATUS_LOCKED, + STATUS_MERGED, + STATUS_OPEN, + TYPE_EPIC, + TYPE_ISSUE, + TYPE_MERGE_REQUEST, +} from '~/issues/constants'; + +const badgePropertiesMap = { + [TYPE_EPIC]: { + [STATUS_OPEN]: { + icon: 'epic', + text: __('Open'), + variant: 'success', + }, + [STATUS_CLOSED]: { + icon: 'epic-closed', + text: __('Closed'), + variant: 'info', + }, + }, + [TYPE_ISSUE]: { + [STATUS_OPEN]: { + icon: 'issues', + text: __('Open'), + variant: 'success', + }, + [STATUS_CLOSED]: { + icon: 'issue-closed', + text: __('Closed'), + variant: 'info', + }, + [STATUS_LOCKED]: { + icon: 'issues', + text: __('Open'), + variant: 'success', + }, + }, + [TYPE_MERGE_REQUEST]: { + [STATUS_OPEN]: { + icon: 'merge-request-open', + text: __('Open'), + variant: 'success', + }, + [STATUS_CLOSED]: { + icon: 'merge-request-close', + text: __('Closed'), + variant: 'danger', + }, + [STATUS_MERGED]: { + icon: 'merge', + text: __('Merged'), + variant: 'info', + }, + [STATUS_LOCKED]: { + icon: 'merge-request-open', + text: __('Open'), + variant: 'success', + }, + }, +}; + +export default { + components: { + GlBadge, + GlIcon, + }, + props: { + issuableType: { + type: String, + required: false, + default: '', + }, + state: { + type: String, + required: false, + default: null, + }, + }, + computed: { + badgeProperties() { + return badgePropertiesMap[this.issuableType][this.state]; + }, + }, +}; +</script> + +<template> + <gl-badge :variant="badgeProperties.variant" :aria-label="badgeProperties.text"> + <gl-icon :name="badgeProperties.icon" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeProperties.text }}</span> + </gl-badge> +</template> diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue deleted file mode 100644 index 0d7d0f020dd..00000000000 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ /dev/null @@ -1,146 +0,0 @@ -<script> -import { GlBadge, GlIcon } from '@gitlab/ui'; -import Vue from 'vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { fetchPolicies } from '~/lib/graphql'; -import { __ } from '~/locale'; -import { - STATUS_CLOSED, - STATUS_OPEN, - TYPE_ISSUE, - TYPE_MERGE_REQUEST, - TYPE_EPIC, -} from '~/issues/constants'; - -export const badgeState = Vue.observable({ - state: '', - updateStatus: null, -}); - -const CLASSES = { - opened: 'issuable-status-badge-open', - locked: 'issuable-status-badge-open', - closed: 'issuable-status-badge-closed', - merged: 'issuable-status-badge-merged', -}; - -const ICONS = { - [TYPE_EPIC]: { - opened: 'epic', - closed: 'epic-closed', - }, - [TYPE_ISSUE]: { - opened: 'issues', - locked: 'issues', - closed: 'issue-closed', - }, - [TYPE_MERGE_REQUEST]: { - opened: 'merge-request-open', - locked: 'merge-request-open', - closed: 'merge-request-close', - merged: 'merge', - }, -}; - -const STATUS = { - opened: __('Open'), - locked: __('Open'), - closed: __('Closed'), - merged: __('Merged'), -}; - -export default { - components: { - GlBadge, - GlIcon, - }, - mixins: [glFeatureFlagMixin()], - inject: { - query: { default: null }, - projectPath: { default: null }, - iid: { default: null }, - }, - props: { - initialState: { - type: String, - required: false, - default: null, - }, - issuableType: { - type: String, - required: false, - default: '', - }, - }, - data() { - if (!this.iid) return { state: this.initialState }; - - if (this.initialState && !badgeState.state) { - badgeState.state = this.initialState; - } - - return badgeState; - }, - computed: { - badgeClass() { - return [ - CLASSES[this.state], - { - 'gl-vertical-align-bottom': this.issuableType === TYPE_MERGE_REQUEST, - }, - ]; - }, - badgeVariant() { - if (this.state === STATUS_OPEN) { - return 'success'; - } else if (this.state === STATUS_CLOSED) { - return this.issuableType === TYPE_MERGE_REQUEST ? 'danger' : 'info'; - } - return 'info'; - }, - badgeText() { - return STATUS[this.state]; - }, - badgeIcon() { - const type = this.issuableType || TYPE_MERGE_REQUEST; - return ICONS[type][this.state]; - }, - }, - created() { - if (!badgeState.updateStatus) { - badgeState.updateStatus = this.fetchState; - } - }, - beforeDestroy() { - if (badgeState.updateStatus && this.query) { - badgeState.updateStatus = null; - } - }, - methods: { - async fetchState() { - const { data } = await this.$apollo.query({ - query: this.query, - variables: { - projectPath: this.projectPath, - iid: this.iid, - }, - fetchPolicy: fetchPolicies.NO_CACHE, - }); - - badgeState.state = data?.workspace?.issuable?.state; - }, - }, -}; -</script> - -<template> - <gl-badge - class="issuable-status-badge gl-mr-3 gl-align-self-center" - :class="badgeClass" - :variant="badgeVariant" - :aria-label="badgeText" - > - <gl-icon :name="badgeIcon" class="gl-badge-icon" /> - <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span> - </gl-badge> -</template> diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js index acc0161bf6a..40f92763b29 100644 --- a/app/assets/javascripts/issuable/index.js +++ b/app/assets/javascripts/issuable/index.js @@ -5,7 +5,6 @@ import Sidebar from '~/right_sidebar'; import { getSidebarOptions } from '~/sidebar/mount_sidebar'; import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; import IssuableByEmail from './components/issuable_by_email.vue'; -import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; import issuableBulkUpdateActions from './issuable_bulk_update_actions'; import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; import IssuableContext from './issuable_context'; @@ -97,24 +96,6 @@ export function initIssuableByEmail() { }); } -export function initIssuableHeaderWarnings(store) { - const el = document.getElementById('js-issuable-header-warnings'); - - if (!el) { - return null; - } - - const { hidden } = el.dataset; - - return new Vue({ - el, - name: 'IssuableHeaderWarningsRoot', - store, - provide: { hidden: parseBoolean(hidden) }, - render: (createElement) => createElement(IssuableHeaderWarnings), - }); -} - export function initIssuableSidebar() { const el = document.querySelector('.js-sidebar-options'); diff --git a/app/assets/javascripts/issuable/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js index 8c2e2a5df67..ef49bd29a40 100644 --- a/app/assets/javascripts/issuable/issuable_context.js +++ b/app/assets/javascripts/issuable/issuable_context.js @@ -8,6 +8,29 @@ export default class IssuableContext { this.userSelect = new UsersSelect(currentUser); this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search'); + this.reviewersSelect.dropdowns.forEach((glDropdownInstance) => { + const jQueryWrapper = glDropdownInstance.dropdown; + const domElement = jQueryWrapper[0]; + const content = domElement.querySelector('.dropdown-content'); + const loader = domElement.querySelector('.dropdown-loading'); + const spinner = loader.querySelector('.gl-spinner-container'); + const realParent = loader.parentNode; + + domElement.classList.add('non-blocking-loader'); + spinner.classList.remove('gl-mt-7'); + spinner.classList.add('gl-mt-2'); + + jQueryWrapper.on('shown.bs.dropdown', () => { + glDropdownInstance.filterInput.focus(); + }); + jQueryWrapper.on('toggle.on.loading.gl.dropdown filtering.gl.dropdown', () => { + content.appendChild(loader); + }); + jQueryWrapper.on('done.remote.loading.gl.dropdown done.filtering.gl.dropdown', () => { + realParent.appendChild(loader); + }); + }); + $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); }); diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue index 044a1bba7ad..12d76ec4b54 100644 --- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue @@ -3,12 +3,13 @@ import { GlIcon, GlPopover, GlSkeletonLoader, GlTooltipDirective } from '@gitlab import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import IssueMilestone from '~/issuable/components/issue_milestone.vue'; -import StatusBox from '~/issuable/components/status_box.vue'; -import { STATUS_CLOSED } from '~/issues/constants'; +import StatusBadge from '~/issuable/components/status_badge.vue'; +import { STATUS_CLOSED, TYPE_ISSUE } from '~/issues/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; export default { + TYPE_ISSUE, components: { GlIcon, GlPopover, @@ -16,7 +17,7 @@ export default { IssueDueDate, IssueMilestone, IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), - StatusBox, + StatusBadge, WorkItemTypeIcon, }, directives: { @@ -82,14 +83,14 @@ export default { <gl-skeleton-loader v-if="$apollo.queries.issue.loading" :height="15"> <rect width="250" height="15" rx="4" /> </gl-skeleton-loader> - <div v-else-if="showDetails" class="gl-display-flex gl-align-items-center"> - <status-box issuable-type="issue" :initial-state="issue.state" /> + <div v-else-if="showDetails" class="gl-display-flex gl-align-items-center gl-gap-2"> + <status-badge :issuable-type="$options.TYPE_ISSUE" :state="issue.state" /> <gl-icon v-if="issue.confidential" v-gl-tooltip name="eye-slash" :title="__('Confidential')" - class="gl-text-orange-500 gl-mr-2" + class="gl-text-orange-500" :aria-label="__('Confidential')" /> <span class="gl-text-secondary"> diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 0a1a1324d7d..80344efc44c 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -30,6 +30,7 @@ export const issuableStatusText = { export const IssuableTypeText = { [TYPE_ISSUE]: __('issue'), + [TYPE_EPIC]: __('epic'), [TYPE_MERGE_REQUEST]: __('merge request'), [TYPE_ALERT]: __('alert'), [TYPE_INCIDENT]: __('incident'), diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 9febebf7e55..a756229e6ca 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -495,7 +495,6 @@ export default { :issuables-loading="isLoading" namespace="dashboard" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :show-pagination-controls="showPaginationControls" show-work-item-type-icon diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index eec7c6bf842..3bd28c50800 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -3,21 +3,20 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import { TYPE_INCIDENT } from '~/issues/constants'; +import { initIssuableSidebar } from '~/issuable'; import Issue from '~/issues/issue'; import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new'; import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; import { initRelatedIssues } from '~/related_issues'; -import { initIncidentApp, initIssueApp, initSentryErrorStackTrace } from '~/issues/show'; -import { parseIssuableData } from '~/issues/show/utils/parse_data'; +import { initIssuableApp, initSentryErrorStackTrace } from '~/issues/show'; import LabelsSelect from '~/labels/labels_select'; import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import initWorkItemLinks from '~/work_items/components/work_item_links'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; -import initLinkedResources from '~/linked_resources'; import FilteredSearchServiceDesk from './filtered_search_service_desk'; export function initFilteredSearchServiceDesk() { @@ -42,33 +41,20 @@ export function initForm() { mountMilestoneDropdown(); } -export function initShow({ notesParams } = {}) { - const el = document.getElementById('js-issuable-app'); - - if (!el) { - return; - } - - const { issueType, ...issuableData } = parseIssuableData(el); - - if (issueType === TYPE_INCIDENT) { - initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store); - initLinkedResources(); - initRelatedIssues(TYPE_INCIDENT); - } else { - initIssueApp(issuableData, store); - } - +export function initShow() { new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new - initIssuableHeaderWarnings(store); + + initAwardsApp(document.getElementById('js-vue-awards-block')); + initIssuableApp(store); initIssuableSidebar(); - initNotesApp(notesParams); + initNotesApp(); + initRelatedIssues(); initRelatedMergeRequests(); initSentryErrorStackTrace(); - - initAwardsApp(document.getElementById('js-vue-awards-block')); + initSidebarBundle(store); + initWorkItemLinks(); import(/* webpackChunkName: 'design_management' */ '~/design_management') .then((module) => module.default()) diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index b7fd99d8042..06bbcdc12ea 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -49,13 +49,8 @@ export default class Issue { issueFailMessage = __('Unable to update this issue at this time.'), ) { if ('id' in data) { - const isClosedBadge = $('.issuable-status-badge-closed'); - const isOpenBadge = $('.issuable-status-badge-open'); const projectIssuesCounter = $('.issue_counter'); - isClosedBadge.toggleClass('hidden', !isClosed); - isOpenBadge.toggleClass('hidden', isClosed); - $(document).trigger('issuable:change', isClosed); let numProjectIssues = Number( diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue index 9f7fca0ceca..3d62ea07f59 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -82,7 +82,7 @@ export default { v-if="showCsvButtons" class="gl-w-full gl-sm-w-auto gl-sm-mr-3" :toggle-text="$options.i18n.importIssues" - data-qa-selector="import_issues_dropdown" + data-testid="import-issues-dropdown" > <csv-import-export-buttons :export-csv-path="exportCsvPathWithQuery" diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index dde1a4fd2d6..22c0984ebdb 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -10,6 +10,8 @@ import { newDateAsLocaleTime, } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; +import { STATE_CLOSED } from '~/work_items/constants'; +import { isMilestoneWidget, isStartAndDueDateWidget } from '~/work_items/utils'; export default { components: { @@ -26,9 +28,12 @@ export default { }, }, computed: { + milestone() { + return this.issue.milestone || this.issue.widgets?.find(isMilestoneWidget)?.milestone; + }, milestoneDate() { - if (this.issue.milestone?.dueDate) { - const { dueDate, startDate } = this.issue.milestone; + if (this.milestone.dueDate) { + const { dueDate, startDate } = this.milestone; const date = dateInWords(newDateAsLocaleTime(dueDate), true); const remainingTime = this.milestoneRemainingTime(dueDate, startDate); return `${date} (${remainingTime})`; @@ -36,15 +41,19 @@ export default { return __('Milestone'); }, milestoneLink() { - return this.issue.milestone.webPath || this.issue.milestone.webUrl; + return this.milestone.webPath || this.milestone.webUrl; }, dueDate() { - return this.issue.dueDate && dateInWords(newDateAsLocaleTime(this.issue.dueDate), true); + return this.issue.dueDate || this.issue.widgets?.find(isStartAndDueDateWidget)?.dueDate; + }, + dueDateText() { + return this.dueDate && dateInWords(newDateAsLocaleTime(this.dueDate), true); + }, + isClosed() { + return this.issue.state === STATUS_CLOSED || this.issue.state === STATE_CLOSED; }, showDueDateInRed() { - return ( - isInPast(newDateAsLocaleTime(this.issue.dueDate)) && this.issue.state !== STATUS_CLOSED - ); + return isInPast(newDateAsLocaleTime(this.dueDate)) && !this.isClosed; }, timeEstimate() { return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; @@ -57,11 +66,14 @@ export default { if (dueDate && isInPast(due)) { return __('Past due'); - } else if (dueDate && isToday(due)) { + } + if (dueDate && isToday(due)) { return __('Today'); - } else if (startDate && isInFuture(start)) { + } + if (startDate && isInFuture(start)) { return __('Upcoming'); - } else if (dueDate) { + } + if (dueDate) { return getTimeRemainingInWords(due); } return ''; @@ -73,7 +85,7 @@ export default { <template> <span> <span - v-if="issue.milestone" + v-if="milestone" class="issuable-milestone gl-mr-3 gl-text-truncate gl-max-w-26 gl-display-inline-block gl-vertical-align-bottom" data-testid="issuable-milestone" > @@ -84,11 +96,11 @@ export default { class="gl-font-sm gl-text-gray-500!" > <gl-icon name="clock" :size="12" /> - {{ issue.milestone.title }} + {{ milestone.title }} </gl-link> </span> <span - v-if="issue.dueDate" + v-if="dueDate" v-gl-tooltip class="issuable-due-date gl-mr-3" :class="{ 'gl-text-red-500': showDueDateInRed }" @@ -96,7 +108,7 @@ export default { data-testid="issuable-due-date" > <gl-icon name="calendar" :size="12" /> - {{ dueDate }} + {{ dueDateText }} </span> <span v-if="timeEstimate" 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 c50b48ca0d8..3d8ed3af816 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -99,8 +99,6 @@ import { import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; -import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; -import searchUsersQuery from '../queries/search_users.query.graphql'; import setSortPreferenceMutation from '../queries/set_sort_preference.mutation.graphql'; import { convertToApiParams, @@ -204,11 +202,6 @@ export default { required: false, default: () => [], }, - eeIsOkrsEnabled: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -411,9 +404,10 @@ export default { title: TOKEN_TITLE_MILESTONE, icon: 'clock', token: MilestoneToken, - fetchMilestones: this.fetchMilestones, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, shouldSkipSort: true, + fullPath: this.fullPath, + isProject: this.isProject, }, { type: TOKEN_TYPE_LABEL, @@ -640,32 +634,13 @@ export default { fetchLatestLabels(search) { return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); }, - fetchMilestones(search) { - return this.$apollo - .query({ - query: searchMilestonesQuery, - variables: { fullPath: this.fullPath, search, isProject: this.isProject }, - }) - .then(({ data }) => data[this.namespace]?.milestones.nodes); - }, fetchUsers(search) { - if (gon.features?.newGraphqlUsersAutocomplete) { - return this.$apollo - .query({ - query: usersAutocompleteQuery, - variables: { fullPath: this.fullPath, search, isProject: this.isProject }, - }) - .then(({ data }) => data[this.namespace]?.autocompleteUsers); - } - return this.$apollo .query({ - query: searchUsersQuery, + query: usersAutocompleteQuery, variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => - data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user), - ); + .then(({ data }) => data[this.namespace]?.autocompleteUsers); }, getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; @@ -966,7 +941,6 @@ export default { v-if="hasAnyIssues" :namespace="fullPath" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :has-scoped-labels-feature="hasScopedLabelsFeature" :initial-filter-value="filterTokens" @@ -1037,14 +1011,11 @@ export default { > {{ $options.i18n.editIssues }} </gl-button> - <gl-button - v-if="showNewIssueLink && !eeIsOkrsEnabled" - :href="newIssuePath" - variant="confirm" - > - {{ $options.i18n.newIssueLabel }} - </gl-button> - <slot name="new-objective-button"></slot> + <slot name="new-issuable-button"> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </slot> <new-resource-dropdown v-if="showNewIssueDropdown" :query="$options.searchProjectsQuery" @@ -1059,7 +1030,7 @@ export default { no-caret :toggle-text="$options.i18n.actionsLabel" text-sr-only - data-qa-selector="issues_list_more_actions_dropdown" + data-testid="issues-list-more-actions-dropdown" > <csv-import-export-buttons v-if="showCsvButtons" diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 85e300b6474..682c7629962 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -121,7 +121,6 @@ export const i18n = { reorderError: __('An error occurred while reordering issues.'), deleteError: __('An error occurred while deleting an issuable.'), rssLabel: __('Subscribe to RSS feed'), - searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), titles: __('Titles'), descriptions: __('Descriptions'), diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 3b49c0efb14..f3173f0e33a 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -11,6 +11,7 @@ fragment IssueFragment on Issue { moved state title + titleHtml updatedAt closedAt upvotes diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql index 040240cde99..941e71b7ca7 100644 --- a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql @@ -1,6 +1,11 @@ #import "./milestone.fragment.graphql" -query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { +query searchMilestones( + $fullPath: ID! + $search: String + $isProject: Boolean = false + $state: MilestoneStateEnum +) { group(fullPath: $fullPath) @skip(if: $isProject) { id milestones( @@ -8,7 +13,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa includeAncestors: true includeDescendants: true sort: EXPIRED_LAST_DUE_DATE_ASC - state: active + state: $state ) { nodes { ...Milestone @@ -21,7 +26,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa searchTitle: $search includeAncestors: true sort: EXPIRED_LAST_DUE_DATE_ASC - state: active + state: $state ) { nodes { ...Milestone diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql deleted file mode 100644 index 6a1967a8875..00000000000 --- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql +++ /dev/null @@ -1,29 +0,0 @@ -#import "./user.fragment.graphql" - -query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { - group(fullPath: $fullPath) @skip(if: $isProject) { - id - groupMembers(search: $search, relations: [DIRECT, INHERITED, SHARED_FROM_GROUPS]) { - nodes { - id - user { - ...User - } - } - } - } - project(fullPath: $fullPath) @include(if: $isProject) { - id - projectMembers( - search: $search - relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS] - ) { - nodes { - id - user { - ...User - } - } - } - } -} diff --git a/app/assets/javascripts/issues/list/queries/user.fragment.graphql b/app/assets/javascripts/issues/list/queries/user.fragment.graphql deleted file mode 100644 index 3e5bc0f7b93..00000000000 --- a/app/assets/javascripts/issues/list/queries/user.fragment.graphql +++ /dev/null @@ -1,6 +0,0 @@ -fragment User on User { - id - avatarUrl - name - username -} diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index d819a371c69..5e81f7ad4f6 100644 --- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -4,7 +4,6 @@ import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { sprintf, __, n__ } from '~/locale'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; -import { parseIssuableData } from '~/issues/show/utils/parse_data'; export default { name: 'RelatedMergeRequests', @@ -19,6 +18,11 @@ export default { type: String, required: true, }, + hasClosingMergeRequest: { + type: Boolean, + required: false, + default: false, + }, projectNamespace: { type: String, required: true, @@ -48,9 +52,6 @@ export default { this.setInitialState({ apiEndpoint: this.endpoint }); this.fetchMergeRequests(); }, - created() { - this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest; - }, methods: { ...mapActions(['setInitialState', 'fetchMergeRequests']), getAssignees(mr) { diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index 196084093c8..413b48b9720 100644 --- a/app/assets/javascripts/issues/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import RelatedMergeRequests from './components/related_merge_requests.vue'; import createStore from './store'; @@ -9,7 +10,7 @@ export function initRelatedMergeRequests() { return undefined; } - const { endpoint, projectPath, projectNamespace } = el.dataset; + const { endpoint, hasClosingMergeRequest, projectPath, projectNamespace } = el.dataset; return new Vue({ el, @@ -17,7 +18,12 @@ export function initRelatedMergeRequests() { store: createStore(), render: (createElement) => createElement(RelatedMergeRequests, { - props: { endpoint, projectNamespace, projectPath }, + props: { + endpoint, + hasClosingMergeRequest: parseBoolean(hasClosingMergeRequest), + projectNamespace, + projectPath, + }, }), }); } diff --git a/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue index a15c8ee2e9f..ab9e70ae223 100644 --- a/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue +++ b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue @@ -38,7 +38,8 @@ export default { description: noSearchResultsDescription, svgHeight: 150, }; - } else if (this.isOpenTab) { + } + if (this.isOpenTab) { return { title: noOpenIssuesTitle, description: infoBannerUserNote }; } diff --git a/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue index 9dbed2c2579..9dbed2c2579 100644 --- a/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue diff --git a/app/assets/javascripts/service_desk/components/info_banner.vue b/app/assets/javascripts/issues/service_desk/components/info_banner.vue index 5667ee2f31d..5667ee2f31d 100644 --- a/app/assets/javascripts/service_desk/components/info_banner.vue +++ b/app/assets/javascripts/issues/service_desk/components/info_banner.vue diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue index 56cd21d7ea9..4b59672428b 100644 --- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue @@ -5,7 +5,8 @@ import { isEmpty } from 'lodash'; import { fetchPolicies } from '~/lib/graphql'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import axios from '~/lib/utils/axios_utils'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; +import { scrollUp } from '~/lib/utils/scroll_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; @@ -15,6 +16,8 @@ import { getInitialPageParams, getFilterTokens, isSortKey, + getSortOptions, + getSortKey, } from '~/issues/list/utils'; import { OPERATORS_IS_NOT, @@ -31,19 +34,24 @@ import { PARAM_SORT, CREATED_DESC, UPDATED_DESC, + RELATIVE_POSITION_ASC, urlSortParams, } from '~/issues/list/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { createAlert, VARIANT_INFO } from '~/alert'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import searchProjectMembers from '~/graphql_shared/queries/project_user_members_search.query.graphql'; -import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues.query.graphql'; -import getServiceDeskIssuesCounts from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql'; +import getServiceDeskIssuesQuery from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCounts from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql'; import searchProjectLabelsQuery from '../queries/search_project_labels.query.graphql'; import searchProjectMilestonesQuery from '../queries/search_project_milestones.query.graphql'; +import setSortingPreferenceMutation from '../queries/set_sorting_preference.mutation.graphql'; +import reorderServiceDeskIssuesMutation from '../queries/reorder_service_desk_issues.mutation.graphql'; import { errorFetchingCounts, errorFetchingIssues, - searchPlaceholder, + issueRepositioningMessage, + reorderError, SERVICE_DESK_BOT_USERNAME, STATUS_OPEN, STATUS_CLOSED, @@ -68,7 +76,8 @@ export default { i18n: { errorFetchingCounts, errorFetchingIssues, - searchPlaceholder, + issueRepositioningMessage, + reorderError, }, issuableListTabs, components: { @@ -81,6 +90,7 @@ export default { inject: [ 'releasesPath', 'autocompleteAwardEmojisPath', + 'hasBlockedIssuesFeature', 'hasIterationsFeature', 'hasIssueWeightsFeature', 'hasIssuableHealthStatusFeature', @@ -92,6 +102,7 @@ export default { 'isServiceDeskSupported', 'hasAnyIssues', 'initialSort', + 'isIssueRepositioningDisabled', ], props: { eeSearchTokens: { @@ -104,14 +115,13 @@ export default { return { serviceDeskIssues: [], serviceDeskIssuesCounts: {}, - sortOptions: [], filterTokens: [], pageInfo: {}, pageParams: {}, sortKey: CREATED_DESC, state: STATUS_OPEN, pageSize: DEFAULT_PAGE_SIZE, - issuesError: null, + issuesError: '', }; }, apollo: { @@ -167,7 +177,6 @@ export default { return { fullPath: this.fullPath, iid: isIidSearch ? this.searchQuery.slice(1) : undefined, - isProject: this.isProject, isSignedIn: this.isSignedIn, authorUsername: SERVICE_DESK_BOT_USERNAME, sort: this.sortKey, @@ -180,6 +189,13 @@ export default { shouldSkipQuery() { return !this.hasAnyIssues || isEmpty(this.pageParams); }, + sortOptions() { + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); + }, tabCounts() { const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts; return { @@ -188,8 +204,20 @@ export default { [STATUS_ALL]: allIssues?.count, }; }, + currentTabCount() { + return this.tabCounts[this.state] ?? 0; + }, + showPaginationControls() { + return ( + this.serviceDeskIssues.length > 0 && + (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage) + ); + }, + showPageSizeControls() { + return this.currentTabCount > DEFAULT_PAGE_SIZE; + }, isLoading() { - return this.$apollo.queries.serviceDeskIssues.loading; + return this.$apollo.loading; }, isOpenTab() { return this.state === STATUS_OPEN; @@ -205,11 +233,14 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, + hasAnyServiceDeskIssue() { + return this.hasSearch || Boolean(this.tabCounts.all); + }, isInfoBannerVisible() { - return this.isServiceDeskSupported && this.hasAnyServiceDeskIssues; + return this.isServiceDeskSupported && this.hasAnyServiceDeskIssue; }, - hasAnyServiceDeskIssues() { - return this.hasSearch || Boolean(this.tabCounts.all); + canShowIssuesList() { + return this.isLoading || this.issuesError.length || this.hasAnyServiceDeskIssue; }, hasOrFeature() { return this.glFeatures.orIssuableQueries; @@ -296,6 +327,9 @@ export default { return tokens; }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION_ASC; + }, }, watch: { $route(newValue, oldValue) { @@ -383,15 +417,120 @@ export default { this.$router.push({ query: this.urlParams }); }, + handleDismissAlert() { + this.issuesError = ''; + }, + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: this.pageSize, + }; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: this.pageSize, + }; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, + handlePageSizeChange(newPageSize) { + const pageParam = getParameterByName(PARAM_LAST_PAGE_SIZE) ? 'lastPageSize' : 'firstPageSize'; + this.pageParams[pageParam] = newPageSize; + this.pageSize = newPageSize; + scrollUp(); + + this.$router.push({ query: this.urlParams }); + }, + handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + return; + } + + this.sortKey = sortKey; + this.pageParams = getInitialPageParams(this.pageSize); + + if (this.isSignedIn) { + this.saveSortPreference(sortKey); + } + + this.$router.push({ query: this.urlParams }); + }, + saveSortPreference(sortKey) { + this.$apollo + .mutate({ + mutation: setSortingPreferenceMutation, + variables: { input: { issuesSort: sortKey } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); + }, + handleReorder({ newIndex, oldIndex }) { + const issueToMove = this.serviceDeskIssues[oldIndex]; + const isDragDropDownwards = newIndex > oldIndex; + const isMovingToBeginning = newIndex === 0; + const isMovingToEnd = newIndex === this.serviceDeskIssues.length - 1; + + let moveBeforeId; + let moveAfterId; + + if (isDragDropDownwards) { + const afterIndex = isMovingToEnd ? newIndex : newIndex + 1; + moveBeforeId = this.serviceDeskIssues[newIndex].id; + moveAfterId = this.serviceDeskIssues[afterIndex].id; + } else { + const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1; + moveBeforeId = this.serviceDeskIssues[beforeIndex].id; + moveAfterId = this.serviceDeskIssues[newIndex].id; + } + + return axios + .put(joinPaths(issueToMove.webPath, 'reorder'), { + move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), + move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), + }) + .then(() => { + const serializedVariables = JSON.stringify(this.queryVariables); + return this.$apollo.mutate({ + mutation: reorderServiceDeskIssuesMutation, + variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables }, + }); + }) + .catch((error) => { + this.issuesError = this.$options.i18n.reorderError; + Sentry.captureException(error); + }); + }, updateData(sortValue) { const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE); const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); const state = getParameterByName(PARAM_STATE); const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(sortValue); const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); - const sortKey = graphQLSortKey || defaultSortKey; + let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } this.filterTokens = getFilterTokens(window.location.search); @@ -405,6 +544,12 @@ export default { this.sortKey = sortKey; this.state = state || STATUS_OPEN; }, + showIssueRepositioningMessage() { + createAlert({ + message: this.$options.i18n.issueRepositioningMessage, + variant: VARIANT_INFO, + }); + }, }, }; </script> @@ -413,25 +558,36 @@ export default { <section> <info-banner v-if="isInfoBannerVisible" /> <issuable-list - v-if="isLoading || hasAnyServiceDeskIssues" + v-if="canShowIssuesList" namespace="service-desk" recent-searches-storage-key="service-desk-issues" :error="issuesError" - :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :issuables-loading="isLoading" :initial-filter-value="filterTokens" :show-filtered-search-friendly-text="hasOrFeature" + :show-pagination-controls="showPaginationControls" + :show-page-size-change-controls="showPageSizeControls" :sort-options="sortOptions" :initial-sort-by="sortKey" + :is-manual-ordering="isManualOrdering" :issuables="serviceDeskIssues" :tabs="$options.issuableListTabs" :tab-counts="tabCounts" :current-tab="state" :default-page-size="pageSize" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" sync-filter-and-sort + use-keyset-pagination @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" @filter="handleFilter" + @sort="handleSort" + @reorder="handleReorder" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" + @page-size-change="handlePageSizeChange" > <template #empty-state> <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/issues/service_desk/constants.js index a83c0d9ca57..e498a4f39a1 100644 --- a/app/assets/javascripts/service_desk/constants.js +++ b/app/assets/javascripts/issues/service_desk/constants.js @@ -235,7 +235,10 @@ export const noSearchResultsDescription = __( 'To widen your search, change or remove filters above', ); export const noSearchResultsTitle = __('Sorry, your filter produced no results'); -export const searchPlaceholder = __('Search or filter results...'); +export const issueRepositioningMessage = __( + 'Issues are being rebalanced at the moment, so manual reordering is disabled.', +); +export const reorderError = __('An error occurred while reordering issues.'); export const infoBannerTitle = s__( 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab', ); diff --git a/app/assets/javascripts/service_desk/graphql.js b/app/assets/javascripts/issues/service_desk/graphql.js index e01973f1e8a..e01973f1e8a 100644 --- a/app/assets/javascripts/service_desk/graphql.js +++ b/app/assets/javascripts/issues/service_desk/graphql.js diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/issues/service_desk/index.js index afb2d0e8de3..579cf343477 100644 --- a/app/assets/javascripts/service_desk/index.js +++ b/app/assets/javascripts/issues/service_desk/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import { parseBoolean } from '~/lib/utils/common_utils'; -import ServiceDeskListApp from 'ee_else_ce/service_desk/components/service_desk_list_app.vue'; +import ServiceDeskListApp from 'ee_else_ce/issues/service_desk/components/service_desk_list_app.vue'; import { gqlClient } from './graphql'; export async function mountServiceDeskListApp() { @@ -15,6 +15,7 @@ export async function mountServiceDeskListApp() { const { projectDataReleasesPath, projectDataAutocompleteAwardEmojisPath, + projectDataHasBlockedIssuesFeature, projectDataHasIterationsFeature, projectDataHasIssueWeightsFeature, projectDataHasIssuableHealthStatusFeature, @@ -26,6 +27,7 @@ export async function mountServiceDeskListApp() { projectDataSignInPath, projectDataHasAnyIssues, projectDataInitialSort, + projectDataIsIssueRepositioningDisabled, serviceDeskEmailAddress, canAdminIssues, canEditProjectSettings, @@ -53,6 +55,7 @@ export async function mountServiceDeskListApp() { provide: { releasesPath: projectDataReleasesPath, autocompleteAwardEmojisPath: projectDataAutocompleteAwardEmojisPath, + hasBlockedIssuesFeature: parseBoolean(projectDataHasBlockedIssuesFeature), hasIterationsFeature: parseBoolean(projectDataHasIterationsFeature), hasIssueWeightsFeature: parseBoolean(projectDataHasIssueWeightsFeature), hasIssuableHealthStatusFeature: parseBoolean(projectDataHasIssuableHealthStatusFeature), @@ -72,6 +75,7 @@ export async function mountServiceDeskListApp() { signInPath: projectDataSignInPath, hasAnyIssues: parseBoolean(projectDataHasAnyIssues), initialSort: projectDataInitialSort, + isIssueRepositioningDisabled: parseBoolean(projectDataIsIssueRepositioningDisabled), }, render: (createComponent) => createComponent(ServiceDeskListApp), }); diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql index c678b8dd8ab..d8cd28f5cf1 100644 --- a/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql @@ -3,7 +3,6 @@ query getServiceDeskIssues( $hideUsers: Boolean = false - $isProject: Boolean = false $isSignedIn: Boolean = false $fullPath: ID! $iid: String @@ -22,8 +21,6 @@ query getServiceDeskIssues( $releaseTag: [String!] $releaseTagWildcardId: ReleaseTagWildcardId $types: [IssueType!] - $crmContactId: String - $crmOrganizationId: String $not: NegatedIssueFilterInput $or: UnionedIssueFilterInput $beforeCursor: String @@ -31,7 +28,7 @@ query getServiceDeskIssues( $firstPageSize: Int $lastPageSize: Int ) { - project(fullPath: $fullPath) @include(if: $isProject) @persist { + project(fullPath: $fullPath) @persist { id issues( iid: $iid @@ -50,8 +47,6 @@ query getServiceDeskIssues( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types - crmContactId: $crmContactId - crmOrganizationId: $crmOrganizationId not: $not or: $or before: $beforeCursor diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql index c2ba397d76f..008cde60b74 100644 --- a/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql @@ -1,5 +1,4 @@ query getServiceDeskIssuesCount( - $isProject: Boolean = false $fullPath: ID! $iid: String $search: String @@ -14,12 +13,10 @@ query getServiceDeskIssuesCount( $releaseTag: [String!] $releaseTagWildcardId: ReleaseTagWildcardId $types: [IssueType!] - $crmContactId: String - $crmOrganizationId: String $not: NegatedIssueFilterInput $or: UnionedIssueFilterInput ) { - project(fullPath: $fullPath) @include(if: $isProject) { + project(fullPath: $fullPath) { id openedIssues: issues( state: opened @@ -36,8 +33,6 @@ query getServiceDeskIssuesCount( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types - crmContactId: $crmContactId - crmOrganizationId: $crmOrganizationId not: $not or: $or ) { @@ -58,8 +53,6 @@ query getServiceDeskIssuesCount( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types - crmContactId: $crmContactId - crmOrganizationId: $crmOrganizationId not: $not or: $or ) { @@ -80,8 +73,6 @@ query getServiceDeskIssuesCount( releaseTag: $releaseTag releaseTagWildcardId: $releaseTagWildcardId types: $types - crmContactId: $crmContactId - crmOrganizationId: $crmOrganizationId not: $not or: $or ) { diff --git a/app/assets/javascripts/service_desk/queries/issue.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql index 3b49c0efb14..f72663ae5f6 100644 --- a/app/assets/javascripts/service_desk/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql @@ -36,6 +36,7 @@ fragment IssueFragment on Issue { username webUrl } + externalAuthor labels { nodes { __persist diff --git a/app/assets/javascripts/service_desk/queries/label.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql index bb1d8f1ac9b..bb1d8f1ac9b 100644 --- a/app/assets/javascripts/service_desk/queries/label.fragment.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql diff --git a/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql index 3cdf69bf585..3cdf69bf585 100644 --- a/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql diff --git a/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql b/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql new file mode 100644 index 00000000000..2da7850d77d --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql @@ -0,0 +1,13 @@ +mutation reorderServiceDeskIssues( + $oldIndex: Int + $newIndex: Int + $namespace: String + $serializedVariables: String +) { + reorderIssues( + oldIndex: $oldIndex + newIndex: $newIndex + namespace: $namespace + serializedVariables: $serializedVariables + ) @client +} diff --git a/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql b/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql index 89ce14134b4..89ce14134b4 100644 --- a/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql diff --git a/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql b/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql index f34166be87d..f34166be87d 100644 --- a/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql +++ b/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql diff --git a/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql b/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql new file mode 100644 index 00000000000..b01ae3863cd --- /dev/null +++ b/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql @@ -0,0 +1,5 @@ +mutation setSortingPreference($input: UserPreferencesUpdateInput!) { + userPreferencesUpdate(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/service_desk/search_tokens.js b/app/assets/javascripts/issues/service_desk/search_tokens.js index 72750f518e4..72750f518e4 100644 --- a/app/assets/javascripts/service_desk/search_tokens.js +++ b/app/assets/javascripts/issues/service_desk/search_tokens.js diff --git a/app/assets/javascripts/service_desk/utils.js b/app/assets/javascripts/issues/service_desk/utils.js index 86f76da3880..86f76da3880 100644 --- a/app/assets/javascripts/service_desk/utils.js +++ b/app/assets/javascripts/issues/service_desk/utils.js diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 26c3db647a3..d59692d2a28 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -1,49 +1,36 @@ <script> -import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import { createAlert } from '~/alert'; -import { - issuableStatusText, - STATUS_CLOSED, - TYPE_EPIC, - TYPE_INCIDENT, - TYPE_ISSUE, - WORKSPACE_PROJECT, -} from '~/issues/constants'; +import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; +import updateDescription from '~/issues/show/utils/update_description'; +import { sanitize } from '~/lib/dompurify'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; -import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; -import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; -import Store from '../stores'; import DescriptionComponent from './description.vue'; import EditedComponent from './edited.vue'; import FormComponent from './form.vue'; import HeaderActions from './header_actions.vue'; import IssueHeader from './issue_header.vue'; import PinnedLinks from './pinned_links.vue'; +import StickyHeader from './sticky_header.vue'; import TitleComponent from './title.vue'; export default { - WORKSPACE_PROJECT, components: { - GlIcon, - GlBadge, - GlIntersectionObserver, HeaderActions, IssueHeader, TitleComponent, EditedComponent, FormComponent, PinnedLinks, - ConfidentialityBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, + StickyHeader, }, props: { author: { @@ -234,21 +221,26 @@ export default { }, }, data() { - const store = new Store({ - titleHtml: this.initialTitleHtml, - titleText: this.initialTitleText, - descriptionHtml: this.initialDescriptionHtml, - descriptionText: this.initialDescriptionText, - updatedAt: this.updatedAt, - updatedByName: this.updatedByName, - updatedByPath: this.updatedByPath, - taskCompletionStatus: this.initialTaskCompletionStatus, - lock_version: this.lockVersion, - }); - return { - store, - state: store.state, + formState: { + title: '', + description: '', + lockedWarningVisible: false, + updateLoading: false, + lock_version: 0, + issuableTemplates: {}, + }, + state: { + titleHtml: this.initialTitleHtml, + titleText: this.initialTitleText, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, + taskCompletionStatus: this.initialTaskCompletionStatus, + lock_version: this.lockVersion, + }, showForm: false, templatesRequested: false, isStickyHeaderShowing: false, @@ -264,17 +256,9 @@ export default { headerClasses() { return this.issuableType === TYPE_INCIDENT ? 'gl-mb-3' : 'gl-mb-6'; }, - issuableTemplates() { - return this.store.formState.issuableTemplates; - }, - formState() { - return this.store.formState; - }, issueChanged() { const { - store: { - formState: { description, title }, - }, + formState: { description, title }, initialDescriptionText, initialTitleText, } = this; @@ -292,26 +276,13 @@ export default { defaultErrorMessage() { return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, - isClosed() { - return this.issuableStatus === STATUS_CLOSED; - }, + pinnedLinkClasses() { return this.showTitleBorder ? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6' : ''; }, - statusIcon() { - if (this.issuableType === TYPE_EPIC) { - return this.isClosed ? 'epic-closed' : 'epic'; - } - return this.isClosed ? 'issue-closed' : 'issues'; - }, - statusVariant() { - return this.isClosed ? 'info' : 'success'; - }, - statusText() { - return issuableStatusText[this.issuableStatus]; - }, + shouldShowStickyHeader() { return [TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); }, @@ -322,7 +293,7 @@ export default { this.poll = new Poll({ resource: this.service, method: 'getData', - successCallback: (res) => this.store.updateState(res.data), + successCallback: (res) => this.updateState(res.data), errorCallback(err) { throw new Error(err); }, @@ -360,23 +331,37 @@ export default { } return undefined; }, + updateState(data) { + const stateShouldUpdate = + this.state.titleText !== data.title_text || + this.state.descriptionText !== data.description_text; - updateStoreState() { + if (stateShouldUpdate) { + this.formState.lockedWarningVisible = true; + } + + Object.assign(this.state, convertObjectPropsToCamelCase(data)); + // find if there is an open details node inside of the issue description. + const descriptionSection = document.body.querySelector( + '.detail-page-description.content-block', + ); + const details = + descriptionSection != null && descriptionSection.getElementsByTagName('details'); + + this.state.descriptionHtml = updateDescription(sanitize(data.description), details); + this.state.titleHtml = sanitize(data.title); + this.state.lock_version = data.lock_version; + }, + refetchData() { return this.service .getData() .then((res) => res.data) - .then((data) => { - this.store.updateState(data); - }) - .catch(() => { - createAlert({ - message: this.defaultErrorMessage, - }); - }); + .then(this.updateState) + .catch(() => createAlert({ message: this.defaultErrorMessage })); }, setFormState(state) { - this.store.setFormState(state); + this.formState = { ...this.formState, ...state }; }, updateFormState(templates = {}) { @@ -416,7 +401,7 @@ export default { this.templatesRequested = true; this.requestTemplatesAndShowForm(); } else { - this.updateAndShowForm(this.issuableTemplates); + this.updateAndShowForm(this.formState.issuableTemplates); } }, @@ -427,10 +412,7 @@ export default { async updateIssuable() { this.setFormState({ updateLoading: true }); - const { - store: { formState }, - issueState, - } = this; + const { formState, issueState } = this; const issuablePayload = issueState.isDirty ? { ...formState, issue_type: issueState.issueType } : formState; @@ -464,7 +446,7 @@ export default { visitUrl(URI); } }) - .then(this.updateStoreState) + .then(this.refetchData) .then(() => { eventHub.$emit('close.form'); }) @@ -518,7 +500,7 @@ export default { this.poll.enable(); this.poll.makeDelayedRequest(POLLING_DELAY); - this.updateStoreState(); + this.refetchData(); }, }, }; @@ -531,7 +513,7 @@ export default { :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" - :issuable-templates="issuableTemplates" + :issuable-templates="formState.issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" @@ -559,61 +541,19 @@ export default { </template> </title-component> - <gl-intersection-observer + <sticky-header v-if="shouldShowStickyHeader" - @appear="hideStickyHeader" - @disappear="showStickyHeader" - > - <transition name="issuable-header-slide"> - <div - v-if="isStickyHeaderShowing" - class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" - data-testid="issue-sticky-header" - > - <div - class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" - > - <gl-badge :variant="statusVariant" class="gl-mr-2"> - <gl-icon :name="statusIcon" /> - <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ - statusText - }}</span></gl-badge - > - <span - v-if="isLocked" - v-gl-tooltip.bottom - data-testid="locked" - class="issuable-warning-icon" - :title="__('This issue is locked. Only project members can comment.')" - > - <gl-icon name="lock" :aria-label="__('Locked')" /> - </span> - <confidentiality-badge - v-if="isConfidential" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" - :issuable-type="issuableType" - /> - <span - v-if="isHidden" - v-gl-tooltip.bottom - :title="__('This issue is hidden because its author has been banned')" - data-testid="hidden" - class="issuable-warning-icon" - > - <gl-icon name="spam" /> - </span> - <a - href="#top" - class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal" - :title="state.titleText" - > - {{ state.titleText }} - </a> - </div> - </div> - </transition> - </gl-intersection-observer> + :is-confidential="isConfidential" + :is-hidden="isHidden" + :is-locked="isLocked" + :issuable-status="issuableStatus" + :issuable-type="issuableType" + :show="isStickyHeaderShowing" + :title="state.titleText" + :title-html="state.titleHtml" + @hide="hideStickyHeader" + @show="showStickyHeader" + /> <slot name="header"> <issue-header diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 90f01603f96..acbba216601 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -307,7 +307,8 @@ export default { ); taskListItems?.forEach((item) => { - const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate }); + const provide = { canUpdate: this.canUpdate, issuableType: this.issuableType }; + const dropdown = this.createTaskListItemActions(provide); this.insertNextToTaskListItemText(dropdown, item); this.addPointerEventListeners(item, '.task-list-item-actions'); this.hasTaskListItemActions = true; diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 1ade5e654e9..81e5c30a264 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -79,62 +79,25 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [trackingMixin, glFeatureFlagMixin()], - inject: { - canCreateIssue: { - default: false, - }, - canDestroyIssue: { - default: false, - }, - canPromoteToEpic: { - default: false, - }, - canReopenIssue: { - default: false, - }, - canReportSpam: { - default: false, - }, - canUpdateIssue: { - default: false, - }, - iid: { - default: '', - }, - issuableId: { - default: '', - }, - isIssueAuthor: { - default: false, - }, - issuePath: { - default: '', - }, - issueType: { - default: TYPE_ISSUE, - }, - newIssuePath: { - default: '', - }, - projectPath: { - default: '', - }, - submitAsSpamPath: { - default: '', - }, - reportedUserId: { - default: '', - }, - reportedFromUrl: { - default: '', - }, - issuableEmailAddress: { - default: '', - }, - fullPath: { - default: '', - }, - }, + inject: [ + 'canCreateIssue', + 'canDestroyIssue', + 'canPromoteToEpic', + 'canReopenIssue', + 'canReportSpam', + 'canUpdateIssue', + 'iid', + 'isIssueAuthor', + 'issuePath', + 'issueType', + 'newIssuePath', + 'projectPath', + 'submitAsSpamPath', + 'reportedUserId', + 'reportedFromUrl', + 'issuableEmailAddress', + 'fullPath', + ], data() { return { isReportAbuseDrawerOpen: false, @@ -256,7 +219,7 @@ export default { mutation: updateIssueMutation, variables: { input: { - iid: this.iid.toString(), + iid: String(this.iid), projectPath: this.projectPath, stateEvent: this.isClosed ? ISSUE_STATE_EVENT_REOPEN : ISSUE_STATE_EVENT_CLOSE, }, @@ -501,7 +464,7 @@ export default { >{{ copyMailAddressText }}</gl-dropdown-item > </template> - <gl-dropdown-divider v-if="showToggleIssueStateButton || canDestroyIssue || canReportSpam" /> + <gl-dropdown-divider v-if="canDestroyIssue || canReportSpam || !isIssueAuthor" /> <gl-dropdown-item v-if="canReportSpam" :href="submitAsSpamPath" diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index ac64c35bf15..ab1bb9253f4 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -107,10 +107,7 @@ export default { </script> <template> - <div - class="create-timeline-event gl-relative gl-display-flex gl-align-items-start" - :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" - > + <div class="create-timeline-event gl-relative gl-display-flex gl-align-items-start"> <div v-if="hasTimelineEvents" class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-flex-shrink-0 gl-p-3 gl-z-index-1" diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 4ec64ef838d..2909a4d2666 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -43,7 +43,7 @@ export default { variables() { return { fullPath: this.fullPath, - iid: this.iid, + iid: String(this.iid), }; }, update(data) { diff --git a/app/assets/javascripts/issues/show/components/sticky_header.vue b/app/assets/javascripts/issues/show/components/sticky_header.vue new file mode 100644 index 00000000000..bcf10ee92bb --- /dev/null +++ b/app/assets/javascripts/issues/show/components/sticky_header.vue @@ -0,0 +1,130 @@ +<script> +import { GlBadge, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import { + issuableStatusText, + STATUS_CLOSED, + TYPE_EPIC, + WORKSPACE_PROJECT, +} from '~/issues/constants'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +export default { + WORKSPACE_PROJECT, + components: { + ConfidentialityBadge, + GlBadge, + GlIcon, + GlIntersectionObserver, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isHidden: { + type: Boolean, + required: false, + default: false, + }, + isLocked: { + type: Boolean, + required: false, + default: false, + }, + issuableStatus: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + show: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + }, + computed: { + isClosed() { + return this.issuableStatus === STATUS_CLOSED; + }, + statusIcon() { + if (this.issuableType === TYPE_EPIC) { + return this.isClosed ? 'epic-closed' : 'epic'; + } + return this.isClosed ? 'issue-closed' : 'issues'; + }, + statusText() { + return issuableStatusText[this.issuableStatus]; + }, + statusVariant() { + return this.isClosed ? 'info' : 'success'; + }, + }, +}; +</script> + +<template> + <gl-intersection-observer @appear="$emit('hide')" @disappear="$emit('show')"> + <transition name="issuable-header-slide"> + <div + v-if="show" + class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" + data-testid="issue-sticky-header" + > + <div + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-gap-2 gl-mx-auto gl-px-5" + > + <gl-badge :variant="statusVariant"> + <gl-icon :name="statusIcon" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ statusText }}</span> + </gl-badge> + <span + v-if="isLocked" + v-gl-tooltip.bottom + data-testid="locked" + class="issuable-warning-icon" + :title="__('This issue is locked. Only project members can comment.')" + > + <gl-icon name="lock" :aria-label="__('Locked')" /> + </span> + <confidentiality-badge + v-if="isConfidential" + :issuable-type="issuableType" + :workspace-type="$options.WORKSPACE_PROJECT" + /> + <span + v-if="isHidden" + v-gl-tooltip.bottom + :title="__('This issue is hidden because its author has been banned')" + data-testid="hidden" + class="issuable-warning-icon" + > + <gl-icon name="spam" /> + </span> + <a + v-safe-html="titleHtml || title" + href="#top" + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal" + > + </a> + </div> + </div> + </transition> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue index 64b916caddb..55e2e857050 100644 --- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -1,5 +1,6 @@ <script> import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import { __, s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -13,7 +14,12 @@ export default { GlDisclosureDropdown, GlDisclosureDropdownItem, }, - inject: ['canUpdate'], + inject: ['canUpdate', 'issuableType'], + computed: { + showConvertToTaskItem() { + return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType); + }, + }, methods: { convertToTask() { eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos); @@ -37,12 +43,17 @@ export default { text-sr-only toggle-class="task-list-item-actions gl-opacity-0 gl-p-2! " > - <gl-disclosure-dropdown-item class="gl-ml-2!" @action="convertToTask"> + <gl-disclosure-dropdown-item + v-if="showConvertToTaskItem" + class="gl-ml-2!" + data-testid="convert" + @action="convertToTask" + > <template #list-item> {{ $options.i18n.convertToTask }} </template> </gl-disclosure-dropdown-item> - <gl-disclosure-dropdown-item class="gl-ml-2!" @action="deleteTaskListItem"> + <gl-disclosure-dropdown-item class="gl-ml-2!" data-testid="delete" @action="deleteTaskListItem"> <template #list-item> <span class="gl-text-red-500!">{{ $options.i18n.delete }}</span> </template> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index a27f86bd9c3..b94f88f690e 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -6,13 +6,15 @@ import { apolloProvider } from '~/graphql_shared/issuable_client'; import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; +import initLinkedResources from '~/linked_resources'; import IssueApp from './components/app.vue'; -import HeaderActions from './components/header_actions.vue'; +import DescriptionComponent from './components/description.vue'; import IncidentTabs from './components/incidents/incident_tabs.vue'; import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; import { issueState } from './constants'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; import createRouter from './components/incidents/router'; +import { parseIssuableData } from './utils/parse_data'; const bootstrapApollo = (state = {}) => { return apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -23,14 +25,15 @@ const bootstrapApollo = (state = {}) => { }); }; -export function initIncidentApp(issueData = {}, store) { +export function initIssuableApp(store) { const el = document.getElementById('js-issuable-app'); if (!el) { return undefined; } - bootstrapApollo({ ...issueState, issueType: TYPE_INCIDENT }); + const issuableData = parseIssuableData(el); + const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); const { authorId, @@ -38,137 +41,72 @@ export function initIncidentApp(issueData = {}, store) { authorUsername, authorWebUrl, canCreateIncident, - canUpdate, - canUpdateTimelineEvent, + fullPath, iid, issuableId, + issueType, + hasIterationsFeature, + // for issue + registerPath, + signInPath, + // for incident + canUpdate, + canUpdateTimelineEvent, currentPath, currentTab, - projectNamespace, - projectPath, - projectId, hasLinkedAlerts, + projectId, slaFeatureAvailable, uploadMetricsFeatureAvailable, - } = issueData; - const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); - - const fullPath = `${projectNamespace}/${projectPath}`; - const router = createRouter(currentPath, currentTab); - - return new Vue({ - el, - name: 'DescriptionRoot', - apolloProvider, - store, - router, - provide: { - issueType: TYPE_INCIDENT, - canCreateIncident, - canUpdateTimelineEvent, - canUpdate, - fullPath, - iid, - issuableId, - projectId, - hasLinkedAlerts: parseBoolean(hasLinkedAlerts), - slaFeatureAvailable: parseBoolean(slaFeatureAvailable), - uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), - contentEditorOnIssues: gon.features.contentEditorOnIssues, - // for HeaderActions component - canCreateIssue: parseBoolean(headerActionsData.canCreateIncident), - canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue), - canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic), - canReopenIssue: parseBoolean(headerActionsData.canReopenIssue), - canReportSpam: parseBoolean(headerActionsData.canReportSpam), - canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue), - isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor), - issuePath: headerActionsData.issuePath, - newIssuePath: headerActionsData.newIssuePath, - projectPath: headerActionsData.projectPath, - reportAbusePath: headerActionsData.reportAbusePath, - reportedUserId: headerActionsData.reportedUserId, - reportedFromUrl: headerActionsData.reportedFromUrl, - submitAsSpamPath: headerActionsData.submitAsSpamPath, - issuableEmailAddress: headerActionsData.issuableEmailAddress, - }, - computed: { - ...mapGetters(['getNoteableData']), - }, - render(createElement) { - return createElement(IssueApp, { - props: { - ...issueData, - author: { - id: authorId, - name: authorName, - username: authorUsername, - webUrl: authorWebUrl, - }, - issueId: Number(issuableId), - issuableStatus: this.getNoteableData?.state, - issuableType: TYPE_INCIDENT, - descriptionComponent: IncidentTabs, - showTitleBorder: false, - isConfidential: this.getNoteableData?.confidential, - }, - }); - }, - }); -} - -export function initIssueApp(issueData, store) { - const el = document.getElementById('js-issuable-app'); + } = issuableData; - if (!el) { - return undefined; - } + const issueProvideData = { registerPath, signInPath }; + const incidentProvideData = { + canUpdate, + canUpdateTimelineEvent, + hasLinkedAlerts: parseBoolean(hasLinkedAlerts), + projectId, + slaFeatureAvailable: parseBoolean(slaFeatureAvailable), + uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), + }; - const { fullPath, registerPath, signInPath } = el.dataset; - const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); + bootstrapApollo({ ...issueState, issueType }); scrollToTargetOnResize(); - bootstrapApollo({ ...issueState, issueType: TYPE_ISSUE }); - - const { - authorId, - authorName, - authorUsername, - authorWebUrl, - canCreateIncident, - hasIssueWeightsFeature, - hasIterationsFeature, - ...issueProps - } = issueData; + if (issueType === TYPE_INCIDENT) { + initLinkedResources(); + } return new Vue({ el, name: 'DescriptionRoot', apolloProvider, store, + router: issueType === TYPE_INCIDENT ? createRouter(currentPath, currentTab) : undefined, provide: { canCreateIncident, fullPath, - registerPath, - signInPath, - hasIssueWeightsFeature, + iid, + issuableId, + issueType, hasIterationsFeature, + ...(issueType === TYPE_ISSUE && issueProvideData), + ...(issueType === TYPE_INCIDENT && incidentProvideData), // for HeaderActions component - canCreateIssue: parseBoolean(headerActionsData.canCreateIssue), + canCreateIssue: + issueType === TYPE_INCIDENT + ? parseBoolean(headerActionsData.canCreateIncident) + : parseBoolean(headerActionsData.canCreateIssue), canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue), canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic), canReopenIssue: parseBoolean(headerActionsData.canReopenIssue), canReportSpam: parseBoolean(headerActionsData.canReportSpam), canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue), - iid: headerActionsData.iid, - issuableId: headerActionsData.issuableId, isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor), issuePath: headerActionsData.issuePath, - issueType: headerActionsData.issueType, newIssuePath: headerActionsData.newIssuePath, projectPath: headerActionsData.projectPath, - projectId: headerActionsData.projectId, reportAbusePath: headerActionsData.reportAbusePath, reportedUserId: headerActionsData.reportedUserId, reportedFromUrl: headerActionsData.reportedFromUrl, @@ -181,67 +119,27 @@ export function initIssueApp(issueData, store) { render(createElement) { return createElement(IssueApp, { props: { - ...issueProps, + ...issuableData, author: { id: authorId, name: authorName, username: authorUsername, webUrl: authorWebUrl, }, + descriptionComponent: issueType === TYPE_INCIDENT ? IncidentTabs : DescriptionComponent, isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, + issuableType: issueType, issueId: this.getNoteableData?.id, issueIid: this.getNoteableData?.iid, + showTitleBorder: issueType !== TYPE_INCIDENT, }, }); }, }); } -export function initHeaderActions(store, type = '') { - const el = document.querySelector('.js-issue-header-actions'); - - if (!el) { - return undefined; - } - - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - - const canCreate = - type === TYPE_INCIDENT ? el.dataset.canCreateIncident : el.dataset.canCreateIssue; - - return new Vue({ - el, - name: 'HeaderActionsRoot', - apolloProvider, - store, - provide: { - canCreateIssue: parseBoolean(canCreate), - canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), - canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), - canReopenIssue: parseBoolean(el.dataset.canReopenIssue), - canReportSpam: parseBoolean(el.dataset.canReportSpam), - canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), - iid: el.dataset.iid, - issuableId: el.dataset.issuableId, - isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), - issuePath: el.dataset.issuePath, - issueType: el.dataset.issueType, - newIssuePath: el.dataset.newIssuePath, - projectPath: el.dataset.projectPath, - projectId: el.dataset.projectId, - reportAbusePath: el.dataset.reportAbusePath, - reportedUserId: parseInt(el.dataset.reportedUserId, 10), - reportedFromUrl: el.dataset.reportedFromUrl, - submitAsSpamPath: el.dataset.submitAsSpamPath, - issuableEmailAddress: el.dataset.issuableEmailAddress, - fullPath: el.dataset.projectPath, - }, - render: (createElement) => createElement(HeaderActions), - }); -} - export function initSentryErrorStackTrace() { const el = document.querySelector('#js-sentry-error-stack-trace'); diff --git a/app/assets/javascripts/issues/show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js deleted file mode 100644 index a50913d3455..00000000000 --- a/app/assets/javascripts/issues/show/stores/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import { sanitize } from '~/lib/dompurify'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import updateDescription from '../utils/update_description'; - -export default class Store { - constructor(initialState) { - this.state = initialState; - this.formState = { - title: '', - description: '', - lockedWarningVisible: false, - updateLoading: false, - lock_version: 0, - issuableTemplates: {}, - }; - } - - updateState(data) { - if (this.stateShouldUpdate(data)) { - this.formState.lockedWarningVisible = true; - } - - Object.assign(this.state, convertObjectPropsToCamelCase(data)); - // find if there is an open details node inside of the issue description. - const descriptionSection = document.body.querySelector( - '.detail-page-description.content-block', - ); - const details = - descriptionSection != null && descriptionSection.getElementsByTagName('details'); - - this.state.descriptionHtml = updateDescription(sanitize(data.description), details); - this.state.titleHtml = sanitize(data.title); - this.state.lock_version = data.lock_version; - } - - stateShouldUpdate(data) { - return ( - this.state.titleText !== data.title_text || - this.state.descriptionText !== data.description_text - ); - } - - setFormState(state) { - this.formState = Object.assign(this.formState, state); - } -} diff --git a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue index 46c27c33f56..1d926c0d0c5 100644 --- a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue +++ b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue @@ -193,6 +193,7 @@ export default { > <source-branch-dropdown id="source-branch-select" + :key="selectedProject.id" :selected-project="selectedProject" :selected-branch-name="selectedSourceBranchName" @change="onSourceBranchSelect" 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 dd9afb01590..52a12cc7771 100644 --- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -98,13 +98,13 @@ export default { :loading="initialProjectsLoading" :searchable="true" :searching="projectsLoading" + fluid-width @search="onSearch" @select="onProjectSelect" > <template #list-item="{ item: project }"> <gl-avatar-labeled v-if="project" - class="gl-text-truncate" :shape="$options.AVATAR_SHAPE_OPTION_RECT" :size="32" :src="project.avatarUrl" diff --git a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue index dac807dceb0..3f9dd4eb6c6 100644 --- a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue @@ -2,6 +2,8 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; import { __ } from '~/locale'; +import { logError } from '~/lib/logger'; + import { BRANCHES_PER_PAGE } from '../constants'; import getProjectQuery from '../graphql/queries/get_project.query.graphql'; @@ -26,6 +28,7 @@ export default { return { initialSourceBranchNamesLoading: false, sourceBranchNamesLoading: false, + sourceBranchNamesLoadingMore: false, sourceBranchNames: [], }; }, @@ -36,6 +39,11 @@ export default { hasSelectedSourceBranch() { return Boolean(this.selectedBranchName); }, + hasMoreBranches() { + return ( + this.sourceBranchNames.length > 0 && this.sourceBranchNames.length % BRANCHES_PER_PAGE === 0 + ); + }, branchDropdownText() { return this.selectedBranchName || __('Select a branch'); }, @@ -59,45 +67,63 @@ export default { onSearch: debounce(function debouncedSearch(branchSearchQuery) { this.onSourceBranchSearchQuery(branchSearchQuery); }, 250), - onSourceBranchSearchQuery(branchSearchQuery) { + async onSourceBranchSearchQuery(branchSearchQuery) { this.branchSearchQuery = branchSearchQuery; - this.fetchSourceBranchNames({ + this.sourceBranchNamesLoading = true; + + await this.fetchSourceBranchNames({ + projectPath: this.selectedProject.fullPath, + searchPattern: this.branchSearchQuery, + }); + this.sourceBranchNamesLoading = false; + }, + async onBottomReached() { + this.sourceBranchNamesLoadingMore = true; + + await this.fetchSourceBranchNames({ projectPath: this.selectedProject.fullPath, searchPattern: this.branchSearchQuery, + append: true, }); + + this.sourceBranchNamesLoadingMore = false; }, onError({ message } = {}) { this.$emit('error', { message }); }, - async fetchSourceBranchNames({ projectPath, searchPattern } = {}) { - this.sourceBranchNamesLoading = true; + async fetchSourceBranchNames({ projectPath, searchPattern, append = false } = {}) { try { const { data } = await this.$apollo.query({ query: getProjectQuery, variables: { projectPath, branchNamesLimit: this.$options.BRANCHES_PER_PAGE, - branchNamesOffset: 0, + branchNamesOffset: append ? this.sourceBranchNames.length : 0, branchNamesSearchPattern: searchPattern ? `*${searchPattern}*` : '*', }, }); const { branchNames, rootRef } = data?.project.repository || {}; - this.sourceBranchNames = - branchNames.map((value) => { + const branchNameItems = + branchNames?.map((value) => { return { text: value, value }; }) || []; - // Use root ref as the default selection - if (rootRef && !this.hasSelectedSourceBranch) { - this.onSourceBranchSelect(rootRef); + if (append) { + this.sourceBranchNames.push(...branchNameItems); + } else { + this.sourceBranchNames = branchNameItems; + + // Use root ref as the default selection + if (rootRef && !this.hasSelectedSourceBranch) { + this.onSourceBranchSelect(rootRef); + } } } catch (err) { + logError(err); this.onError({ message: __('Something went wrong while fetching source branches.'), }); - } finally { - this.sourceBranchNamesLoading = false; } }, }, @@ -107,12 +133,17 @@ export default { <template> <gl-collapsible-listbox :class="{ 'gl-font-monospace': hasSelectedSourceBranch }" + :selected="selectedBranchName" :disabled="!hasSelectedProject" :items="sourceBranchNames" :loading="initialSourceBranchNamesLoading" :searchable="true" :searching="sourceBranchNamesLoading" :toggle-text="branchDropdownText" + fluid-width + :infinite-scroll="hasMoreBranches" + :infinite-scroll-loading="sourceBranchNamesLoadingMore" + @bottom-reached="onBottomReached" @search="onSearch" @select="onSourceBranchSelect" /> diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js index a9a56a6362e..893b9dfa1c7 100644 --- a/app/assets/javascripts/jira_connect/branches/index.js +++ b/app/assets/javascripts/jira_connect/branches/index.js @@ -19,6 +19,7 @@ export default function initJiraConnectBranches() { return new Vue({ el, + name: 'JiraConnectNewBranchRoot', apolloProvider, provide: { initialBranchName, diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index f8ca62da1a5..d737916857b 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -269,7 +269,7 @@ export default { <gl-form-select id="jira-project-select" v-model="selectedProject" - data-qa-selector="jira_project_dropdown" + data-testid="jira-project-dropdown" class="mb-2" :options="jiraProjects" :state="selectState" @@ -349,7 +349,7 @@ export default { variant="confirm" class="js-no-auto-disable" :loading="isSubmitting" - data-qa-selector="jira_issues_import_button" + data-testid="jira-issues-import-button" > {{ __('Continue') }} </gl-button> diff --git a/app/assets/javascripts/jobs/components/filtered_search/constants.js b/app/assets/javascripts/jobs/components/filtered_search/constants.js deleted file mode 100644 index 0daba892375..00000000000 --- a/app/assets/javascripts/jobs/components/filtered_search/constants.js +++ /dev/null @@ -1,13 +0,0 @@ -export const jobStatusValues = [ - 'CANCELED', - 'CREATED', - 'FAILED', - 'MANUAL', - 'SUCCESS', - 'PENDING', - 'PREPARING', - 'RUNNING', - 'SCHEDULED', - 'SKIPPED', - 'WAITING_FOR_RESOURCE', -]; diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue deleted file mode 100644 index 67cdca6aa0a..00000000000 --- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue +++ /dev/null @@ -1,64 +0,0 @@ -<script> -import { GlFilteredSearch } from '@gitlab/ui'; -import { - OPERATORS_IS, - TOKEN_TITLE_STATUS, - TOKEN_TYPE_STATUS, -} from '~/vue_shared/components/filtered_search_bar/constants'; -import JobStatusToken from './tokens/job_status_token.vue'; - -export default { - components: { - GlFilteredSearch, - }, - props: { - queryString: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - tokens() { - return [ - { - type: TOKEN_TYPE_STATUS, - icon: 'status', - title: TOKEN_TITLE_STATUS, - unique: true, - token: JobStatusToken, - operators: OPERATORS_IS, - }, - ]; - }, - filteredSearchValue() { - if (this.queryString?.statuses) { - return [ - { - type: TOKEN_TYPE_STATUS, - value: { - data: this.queryString?.statuses, - operator: '=', - }, - }, - ]; - } - return []; - }, - }, - methods: { - onSubmit(filters) { - this.$emit('filterJobsBySearch', filters); - }, - }, -}; -</script> - -<template> - <gl-filtered-search - :placeholder="s__('Jobs|Filter jobs')" - :available-tokens="tokens" - :value="filteredSearchValue" - @submit="onSubmit" - /> -</template> diff --git a/app/assets/javascripts/jobs/components/filtered_search/utils.js b/app/assets/javascripts/jobs/components/filtered_search/utils.js deleted file mode 100644 index 696cd8d4706..00000000000 --- a/app/assets/javascripts/jobs/components/filtered_search/utils.js +++ /dev/null @@ -1,27 +0,0 @@ -import { jobStatusValues } from './constants'; - -// validates query string used for filtered search -// on jobs table to ensure GraphQL query is called correctly -export const validateQueryString = (queryStringObj) => { - // currently only one token is supported `statuses` - // this code will need to be expanded as more tokens - // are introduced - - const filters = Object.keys(queryStringObj); - - if (filters.includes('statuses')) { - const queryStringStatus = { - statuses: queryStringObj.statuses.toUpperCase(), - }; - - const found = jobStatusValues.find((status) => status === queryStringStatus.statuses); - - if (found) { - return queryStringStatus; - } - - return null; - } - - return null; -}; diff --git a/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue deleted file mode 100644 index 7f25ca8a94d..00000000000 --- a/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -import { GlLink } from '@gitlab/ui'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -export default { - components: { - ClipboardButton, - GlLink, - }, - props: { - commit: { - type: Object, - required: true, - }, - mergeRequest: { - type: Object, - required: false, - default: null, - }, - }, -}; -</script> -<template> - <div> - <span class="gl-font-weight-bold">{{ __('Commit') }}</span> - - <gl-link :href="commit.commit_path" class="gl-text-blue-600!" data-testid="commit-sha"> - {{ commit.short_id }} - </gl-link> - - <clipboard-button - :text="commit.id" - :title="__('Copy commit SHA')" - category="tertiary" - size="small" - /> - - <span v-if="mergeRequest"> - {{ __('in') }} - <gl-link :href="mergeRequest.path" class="gl-text-blue-600!" data-testid="link-commit" - >!{{ mergeRequest.iid }}</gl-link - > - </span> - - <p class="gl-mb-0">{{ commit.title }}</p> - </div> -</template> diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js deleted file mode 100644 index 40b3de7edd9..00000000000 --- a/app/assets/javascripts/jobs/constants.js +++ /dev/null @@ -1,40 +0,0 @@ -import { __, s__ } from '~/locale'; - -const cancel = __('Cancel'); -const moreInfo = __('More information'); - -export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; - -export const JOB_SIDEBAR_COPY = { - cancel, - cancelJobButtonLabel: s__('Job|Cancel'), - debug: __('Debug'), - eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), - eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), - newIssue: __('New issue'), - retryJobLabel: s__('Job|Retry'), - toggleSidebar: __('Toggle Sidebar'), - runAgainJobButtonLabel: s__('Job|Run again'), - updateVariables: s__('Job|Update CI/CD variables'), -}; - -export const JOB_GRAPHQL_ERRORS = { - jobMutationErrorText: __('There was an error running the job. Please try again.'), - jobQueryErrorText: __('There was an error fetching the job.'), -}; - -export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { - cancel, - info: s__( - `Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. - Retrying this job could result in overwriting the environment with the older source code.`, - ), - areYouSure: s__('Jobs|Are you sure you want to proceed?'), - moreInfo, - primaryText: __('Retry job'), - title: s__('Jobs|Are you sure you want to retry this job?'), -}; - -export const SUCCESS_STATUS = 'SUCCESS'; -export const PASSED_STATUS = 'passed'; -export const MANUAL_STATUS = 'manual'; diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 587cc82f0fa..db1dbc72624 100644 --- a/app/assets/javascripts/labels/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -276,7 +276,8 @@ export default class LabelsSelect { if (selected && selected.id === 0) { this.selected = []; return __('No label'); - } else if (isSelected) { + } + if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { const index = this.selected.indexOf(title); @@ -285,7 +286,8 @@ export default class LabelsSelect { if (selectedLabels.length === 1) { return selectedLabels; - } else if (selectedLabels.length) { + } + if (selectedLabels.length) { return sprintf(__('%{firstLabel} +%{labelCount} more'), { firstLabel: selectedLabels[0], labelCount: selectedLabels.length - 1, diff --git a/app/assets/javascripts/lib/swagger.js b/app/assets/javascripts/lib/swagger.js index fcdab18c623..b0701054174 100644 --- a/app/assets/javascripts/lib/swagger.js +++ b/app/assets/javascripts/lib/swagger.js @@ -27,7 +27,7 @@ const renderSwaggerUI = (value) => { spec, dom_id: '#swagger-ui', deepLinking: true, - displayOperationId: true, + displayOperationId: Boolean(getParameterByName('displayOperationId')), }); }) .catch((error) => { diff --git a/app/assets/javascripts/lib/utils/array_utility.js b/app/assets/javascripts/lib/utils/array_utility.js index 04f9cb1cdb5..9eddae860c2 100644 --- a/app/assets/javascripts/lib/utils/array_utility.js +++ b/app/assets/javascripts/lib/utils/array_utility.js @@ -28,3 +28,18 @@ export const swapArrayItems = (array, leftIndex = 0, rightIndex = 0) => { export const getDuplicateItemsFromArray = (array) => [ ...new Set(array.filter((value, index) => array.indexOf(value) !== index)), ]; + +/** + * Toggles the presence of an item in a given array. + * Use pass by reference when toggling non-primivive types. + * + * @param {Array} array - The array to use + * @param {Any} item - The array item to toggle + * @returns {Array} new array with toggled item + */ +export const toggleArrayItem = (array, value) => { + if (array.includes(value)) { + return array.filter((item) => item !== value); + } + return [...array, value]; +}; diff --git a/app/assets/javascripts/lib/utils/breadcrumbs.js b/app/assets/javascripts/lib/utils/breadcrumbs.js new file mode 100644 index 00000000000..e38094fc895 --- /dev/null +++ b/app/assets/javascripts/lib/utils/breadcrumbs.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; + +// TODO: Review replacing this when a breadcrumbs ViewComponent has been created https://gitlab.com/gitlab-org/gitlab/-/issues/367326 +export const injectVueAppBreadcrumbs = (router, BreadcrumbsComponent, apolloProvider = null) => { + const breadcrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li'); + + if (breadcrumbEls.length < 1) { + return false; + } + + const breadcrumbEl = breadcrumbEls[breadcrumbEls.length - 1]; + + const lastCrumb = breadcrumbEl.children[0]; + const nestedBreadcrumbEl = document.createElement('div'); + + breadcrumbEl.replaceChild(nestedBreadcrumbEl, lastCrumb); + + return new Vue({ + el: nestedBreadcrumbEl, + router, + apolloProvider, + render(createElement) { + return createElement(BreadcrumbsComponent, { + class: breadcrumbEl.className, + }); + }, + }); +}; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index cca4cf68f5e..7d16af003e4 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -86,7 +86,6 @@ export const handleLocationHash = () => { const performanceBar = document.querySelector('#js-peek'); const topPadding = 8; const diffFileHeader = document.querySelector('.js-file-title'); - const versionMenusContainer = document.querySelector('.mr-version-menus-container'); const fixedIssuableTitle = document.querySelector('.issue-sticky-header'); let adjustment = 0; @@ -97,7 +96,6 @@ export const handleLocationHash = () => { adjustment -= getElementOffsetHeight(fixedTopBar); adjustment -= getElementOffsetHeight(performanceBar); adjustment -= getElementOffsetHeight(diffFileHeader); - adjustment -= getElementOffsetHeight(versionMenusContainer); if (isInIssuePage()) { adjustment -= getElementOffsetHeight(fixedIssuableTitle); @@ -731,3 +729,11 @@ export const isCurrentUser = (userId) => { return Number(userId) === currentUserId; }; + +/** + * Clones an object via JSON stringifying and re-parsing. + * This ensures object references are not persisted (e.g. unlike lodash cloneDeep) + */ +export const cloneWithoutReferences = (obj) => { + return JSON.parse(JSON.stringify(obj)); +}; diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index aceae188b73..da5fb831ae5 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -3,15 +3,6 @@ export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; export const THOUSAND = 1000; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; - -export const DATETIME_RANGE_TYPES = { - fixed: 'fixed', - anchored: 'anchored', - rolling: 'rolling', - open: 'open', - invalid: 'invalid', -}; - export const BV_SHOW_MODAL = 'bv::show::modal'; export const BV_HIDE_MODAL = 'bv::hide::modal'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index d52672b9d08..4e0d19f2c2a 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -133,7 +133,8 @@ export const dayInQuarter = (date, quarter) => { return quarter.reduce((acc, month) => { if (dateValues.month > month.getMonth()) { return acc + totalDaysInMonth(month); - } else if (dateValues.month === month.getMonth()) { + } + if (dateValues.month === month.getMonth()) { return acc + dateValues.date; } return acc + 0; @@ -562,9 +563,11 @@ export const approximateDuration = (seconds = 0) => { if (seconds < 30) { return __('less than a minute'); - } else if (seconds < MINUTES_LIMIT) { + } + if (seconds < MINUTES_LIMIT) { return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes); - } else if (seconds < HOURS_LIMIT) { + } + if (seconds < HOURS_LIMIT) { return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours); } return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index b0264796d90..c4b8f95e99f 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -93,10 +93,12 @@ export const humanizeTimeInterval = (intervalInSeconds) => { if (intervalInSeconds < 60 /* = 1 minute */) { const seconds = Math.round(intervalInSeconds * 10) / 10; return n__('%d second', '%d seconds', seconds); - } else if (intervalInSeconds < 3600 /* = 1 hour */) { + } + if (intervalInSeconds < 3600 /* = 1 hour */) { const minutes = Math.round(intervalInSeconds / 6) / 10; return n__('%d minute', '%d minutes', minutes); - } else if (intervalInSeconds < 86400 /* = 1 day */) { + } + if (intervalInSeconds < 86400 /* = 1 day */) { const hours = Math.round(intervalInSeconds / 360) / 10; return n__('%d hour', '%d hours', hours); } @@ -378,19 +380,24 @@ export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, mont return sprintf(s__('ValueStreamAnalytics|%{value}M'), { value: roundToNearestHalf(months), }); - } else if (weeks) { + } + if (weeks) { return sprintf(s__('ValueStreamAnalytics|%{value}w'), { value: roundToNearestHalf(weeks), }); - } else if (days) { + } + if (days) { return sprintf(s__('ValueStreamAnalytics|%{value}d'), { value: roundToNearestHalf(days), }); - } else if (hours) { + } + if (hours) { return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours }); - } else if (minutes) { + } + if (minutes) { return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes }); - } else if (seconds) { + } + if (seconds) { return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] })); } return '-'; @@ -441,11 +448,13 @@ export const humanTimeframe = (startDate, dueDate) => { startDate: startDateInWords, dueDate: dueDateInWords, }); - } else if (startDate && !dueDate) { + } + if (startDate && !dueDate) { return sprintf(__('%{startDate} – No due date'), { startDate: dateInWords(start, true, false), }); - } else if (!startDate && dueDate) { + } + if (!startDate && dueDate) { return sprintf(__('No start date – %{dueDate}'), { dueDate: dateInWords(due, true, false), }); diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js index 548f5a438df..f0a03874949 100644 --- a/app/assets/javascripts/lib/utils/datetime_range.js +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -1,27 +1,6 @@ -import { pick, omit, isEqual, isEmpty } from 'lodash'; import dateformat from '~/lib/dateformat'; -import { DATETIME_RANGE_TYPES } from './constants'; -import { secondsToMilliseconds } from './datetime_utility'; -const MINIMUM_DATE = new Date(0); - -const DEFAULT_DIRECTION = 'before'; - -const durationToMillis = (duration) => { - if (Object.entries(duration).length === 1 && Number.isFinite(duration.seconds)) { - return secondsToMilliseconds(duration.seconds); - } - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Invalid duration: only `seconds` is supported'); -}; - -const dateMinusDuration = (date, duration) => new Date(date.getTime() - durationToMillis(duration)); - -const datePlusDuration = (date, duration) => new Date(date.getTime() + durationToMillis(duration)); - -const isValidDuration = (duration) => Boolean(duration && Number.isFinite(duration.seconds)); - -const isValidDateString = (dateString) => { +export const isValidDateString = (dateString) => { if (typeof dateString !== 'string' || !dateString.trim()) { return false; } @@ -38,291 +17,3 @@ const isValidDateString = (dateString) => { } return !Number.isNaN(Date.parse(isoFormatted)); }; - -const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => { - let startDate; - let endDate; - - if (direction === DEFAULT_DIRECTION) { - startDate = minDate; - endDate = anchorDate; - } else { - startDate = anchorDate; - endDate = maxDate; - } - - return { - startDate, - endDate, - }; -}; - -/** - * Converts a fixed range to a fixed range - * @param {Object} fixedRange - A range with fixed start and - * end (e.g. "midnight January 1st 2020 to midday January31st 2020") - */ -const convertFixedToFixed = ({ start, end }) => ({ - start, - end, -}); - -/** - * Converts an anchored range to a fixed range - * @param {Object} anchoredRange - A duration of time - * relative to a fixed point in time (e.g., "the 30 minutes - * before midnight January 1st 2020", or "the 2 days - * after midday on the 11th of May 2019") - */ -const convertAnchoredToFixed = ({ anchor, duration, direction }) => { - const anchorDate = new Date(anchor); - - const { startDate, endDate } = handleRangeDirection({ - minDate: dateMinusDuration(anchorDate, duration), - maxDate: datePlusDuration(anchorDate, duration), - direction, - anchorDate, - }); - - return { - start: startDate.toISOString(), - end: endDate.toISOString(), - }; -}; - -/** - * Converts a rolling change to a fixed range - * - * @param {Object} rollingRange - A time range relative to - * now (e.g., "last 2 minutes", or "next 2 days") - */ -const convertRollingToFixed = ({ duration, direction }) => { - // Use Date.now internally for easier mocking in tests - const now = new Date(Date.now()); - - return convertAnchoredToFixed({ - duration, - direction, - anchor: now.toISOString(), - }); -}; - -/** - * Converts an open range to a fixed range - * - * @param {Object} openRange - A time range relative - * to an anchor (e.g., "before midnight on the 1st of - * January 2020", or "after midday on the 11th of May 2019") - */ -const convertOpenToFixed = ({ anchor, direction }) => { - // Use Date.now internally for easier mocking in tests - const now = new Date(Date.now()); - - const { startDate, endDate } = handleRangeDirection({ - minDate: MINIMUM_DATE, - maxDate: now, - direction, - anchorDate: new Date(anchor), - }); - - return { - start: startDate.toISOString(), - end: endDate.toISOString(), - }; -}; - -/** - * Handles invalid date ranges - */ -const handleInvalidRange = () => { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('The input range does not have the right format.'); -}; - -const handlers = { - invalid: handleInvalidRange, - fixed: convertFixedToFixed, - anchored: convertAnchoredToFixed, - rolling: convertRollingToFixed, - open: convertOpenToFixed, -}; - -/** - * Validates and returns the type of range - * - * @param {Object} Date time range - * @returns {String} `key` value for one of the handlers - */ -export function getRangeType(range) { - const { start, end, anchor, duration } = range; - - if ((start || end) && !anchor && !duration) { - return isValidDateString(start) && isValidDateString(end) - ? DATETIME_RANGE_TYPES.fixed - : DATETIME_RANGE_TYPES.invalid; - } - if (anchor && duration) { - return isValidDateString(anchor) && isValidDuration(duration) - ? DATETIME_RANGE_TYPES.anchored - : DATETIME_RANGE_TYPES.invalid; - } - if (duration && !anchor) { - return isValidDuration(duration) ? DATETIME_RANGE_TYPES.rolling : DATETIME_RANGE_TYPES.invalid; - } - if (anchor && !duration) { - return isValidDateString(anchor) ? DATETIME_RANGE_TYPES.open : DATETIME_RANGE_TYPES.invalid; - } - return DATETIME_RANGE_TYPES.invalid; -} - -/** - * convertToFixedRange Transforms a `range of time` into a `fixed range of time`. - * - * The following types of a `ranges of time` can be represented: - * - * Fixed Range: A range with fixed start and end (e.g. "midnight January 1st 2020 to midday January 31st 2020") - * Anchored Range: A duration of time relative to a fixed point in time (e.g., "the 30 minutes before midnight January 1st 2020", or "the 2 days after midday on the 11th of May 2019") - * Rolling Range: A time range relative to now (e.g., "last 2 minutes", or "next 2 days") - * Open Range: A time range relative to an anchor (e.g., "before midnight on the 1st of January 2020", or "after midday on the 11th of May 2019") - * - * @param {Object} dateTimeRange - A Time Range representation - * It contains the data needed to create a fixed time range plus - * a label (recommended) to indicate the range that is covered. - * - * A definition via a TypeScript notation is presented below: - * - * - * type Duration = { // A duration of time, always in seconds - * seconds: number; - * } - * - * type Direction = 'before' | 'after'; // Direction of time relative to an anchor - * - * type FixedRange = { - * start: ISO8601; - * end: ISO8601; - * label: string; - * } - * - * type AnchoredRange = { - * anchor: ISO8601; - * duration: Duration; - * direction: Direction; // defaults to 'before' - * label: string; - * } - * - * type RollingRange = { - * duration: Duration; - * direction: Direction; // defaults to 'before' - * label: string; - * } - * - * type OpenRange = { - * anchor: ISO8601; - * direction: Direction; // defaults to 'before' - * label: string; - * } - * - * type DateTimeRange = FixedRange | AnchoredRange | RollingRange | OpenRange; - * - * - * @returns {FixedRange} An object with a start and end in ISO8601 format. - */ -export const convertToFixedRange = (dateTimeRange) => - handlers[getRangeType(dateTimeRange)](dateTimeRange); - -/** - * Returns a copy of the object only with time range - * properties relevant to time range calculation. - * - * Filtered properties are: - * - 'start' - * - 'end' - * - 'anchor' - * - 'duration' - * - 'direction': if direction is already the default, its removed. - * - * @param {Object} timeRange - A time range object - * @returns Copy of time range - */ -const pruneTimeRange = (timeRange) => { - const res = pick(timeRange, ['start', 'end', 'anchor', 'duration', 'direction']); - if (res.direction === DEFAULT_DIRECTION) { - return omit(res, 'direction'); - } - return res; -}; - -/** - * Returns true if the time ranges are equal according to - * the time range calculation properties - * - * @param {Object} timeRange - A time range object - * @param {Object} other - Time range object to compare with. - * @returns true if the time ranges are equal, false otherwise - */ -export const isEqualTimeRanges = (timeRange, other) => { - const tr1 = pruneTimeRange(timeRange); - const tr2 = pruneTimeRange(other); - return isEqual(tr1, tr2); -}; - -/** - * Searches for a time range in a array of time ranges using - * only the properies relevant to time ranges calculation. - * - * @param {Object} timeRange - Time range to search (needle) - * @param {Array} timeRanges - Array of time tanges (haystack) - */ -export const findTimeRange = (timeRange, timeRanges) => - timeRanges.find((element) => isEqualTimeRanges(element, timeRange)); - -// Time Ranges as URL Parameters Utils - -/** - * List of possible time ranges parameters - */ -export const timeRangeParamNames = ['start', 'end', 'anchor', 'duration_seconds', 'direction']; - -/** - * Converts a valid time range to a flat key-value pairs object. - * - * Duration is flatted to avoid having nested objects. - * - * @param {Object} A time range - * @returns key-value pairs object that can be used as parameters in a URL. - */ -export const timeRangeToParams = (timeRange) => { - let params = pruneTimeRange(timeRange); - if (timeRange.duration) { - const durationParms = {}; - Object.keys(timeRange.duration).forEach((key) => { - durationParms[`duration_${key}`] = timeRange.duration[key].toString(); - }); - params = { ...durationParms, ...params }; - params = omit(params, 'duration'); - } - return params; -}; - -/** - * Converts a valid set of flat params to a time range object - * - * Parameters that are not part of time range object are ignored. - * - * @param {params} params - key-value pairs object. - */ -export const timeRangeFromParams = (params) => { - const timeRangeParams = pick(params, timeRangeParamNames); - let range = Object.entries(timeRangeParams).reduce((acc, [key, val]) => { - // unflatten duration - if (key.startsWith('duration_')) { - acc.duration = acc.duration || {}; - acc.duration[key.slice('duration_'.length)] = parseInt(val, 10); - return acc; - } - return { [key]: val, ...acc }; - }, {}); - range = pruneTimeRange(range); - return !isEmpty(range) ? range : null; -}; diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js index 6d6361d19b6..64ba151d829 100644 --- a/app/assets/javascripts/lib/utils/grammar.js +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -19,9 +19,11 @@ import { sprintf, s__ } from '~/locale'; export const toNounSeriesText = (items, { onlyCommas = false } = {}) => { if (items.length === 0) { return ''; - } else if (items.length === 1) { + } + if (items.length === 1) { return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false); - } else if (items.length === 2 && !onlyCommas) { + } + if (items.length === 2 && !onlyCommas) { return sprintf( s__('nounSeries|%{firstItem} and %{lastItem}'), { diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 0e943cdb623..d17719c0bc0 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -84,9 +84,11 @@ export function numberToHumanSizeSplit(size, digits = 2) { if (abs < BYTES_IN_KIB) { return [size.toString(), BYTES_FORMAT_BYTES]; - } else if (abs < BYTES_IN_KIB ** 2) { + } + if (abs < BYTES_IN_KIB ** 2) { return [bytesToKiB(size).toFixed(digits), BYTES_FORMAT_KIB]; - } else if (abs < BYTES_IN_KIB ** 3) { + } + if (abs < BYTES_IN_KIB ** 3) { return [bytesToMiB(size).toFixed(digits), BYTES_FORMAT_MIB]; } return [bytesToGiB(size).toFixed(digits), BYTES_FORMAT_GIB]; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js index 8e673855631..49de7b3a081 100644 --- a/app/assets/javascripts/lib/utils/secret_detection.js +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -24,6 +24,10 @@ export const containsSensitiveToken = (message) => { name: 'Feed Token', regex: 'feed_token=((glft-)?[0-9a-zA-Z_-]{20}|glft-[a-h0-9]+-[0-9]+_)', }, + { + name: 'GitLab OAuth Application Secret', + regex: `gloas-[0-9a-zA-Z_-]{64}`, + }, ]; for (const rule of sensitiveDataPatterns) { diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index e6eb74834c0..d48e0217fd8 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -202,7 +202,8 @@ function moveCursor({ const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); const endPosition = startPosition + select.length; return textArea.setSelectionRange(startPosition, endPosition); - } else if (editor) { + } + if (editor) { editor.selectWithinSelection(select, tag); return; } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 31e16f7b4db..638ee1f7e5a 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -178,18 +178,6 @@ export function capitalizeFirstCharacter(text) { } /** - * Returns the first character capitalized - * - * If falsey, returns empty string. - * - * @param {String} text - * @return {String} - */ -export function getFirstCharacterCapitalized(text) { - return text ? text.charAt(0).toUpperCase() : ''; -} - -/** * Replaces all html tags from a string with the given replacement. * * @param {String} string @@ -529,9 +517,11 @@ export const humanizeBranchValidationErrors = (invalidChars = []) => { if (chars.length && !chars.includes(' ')) { return sprintf(__("Can't contain %{chars}"), { chars: chars.join(', ') }); - } else if (chars.includes(' ') && chars.length <= 1) { + } + if (chars.includes(' ') && chars.length <= 1) { return __("Can't contain spaces"); - } else if (chars.includes(' ') && chars.length > 1) { + } + if (chars.includes(' ') && chars.length > 1) { return sprintf(__("Can't contain spaces, %{chars}"), { chars: chars.filter((c) => c !== ' ').join(', '), }); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 08c98298121..ea0520e3157 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,5 +1,3 @@ -import * as Sentry from '@sentry/browser'; - export const DASH_SCOPE = '-'; export const PATH_SEPARATOR = '/'; @@ -705,11 +703,7 @@ export function visitUrl(destination, external = false) { } if (!isSafeURL(url)) { - // For now log this to Sentry and do not block the execution. - // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121551#note_1408873600 - // for more context. Once we're sure that it's not breaking functionality, we can use - // a RangeError here (throw new RangeError('Only http and https protocols are allowed')). - Sentry.captureException(new RangeError(`Only http and https protocols are allowed: ${url}`)); + throw new RangeError(`Only http and https protocols are allowed: ${url}`); } if (external) { diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js index 38d2f3d7551..f1e87145406 100644 --- a/app/assets/javascripts/lib/utils/webpack.js +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -5,10 +5,11 @@ import { joinPaths } from '~/lib/utils/url_utility'; * See https://gitlab.com/gitlab-org/gitlab/-/issues/321656 for a fix */ export function resetServiceWorkersPublicPath() { + // No-op if we're running Vite instead of Webpack + if (typeof __webpack_public_path__ === 'undefined') return; // eslint-disable-line camelcase // __webpack_public_path__ is a global variable that can be used to adjust // the webpack publicPath setting at runtime. // see: https://webpack.js.org/guides/public-path/ const relativeRootPath = (gon && gon.relative_url_root) || ''; - const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/'); - __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase + __webpack_public_path__ = joinPaths(relativeRootPath, '/assets/webpack/'); // eslint-disable-line camelcase } diff --git a/app/assets/javascripts/locale/ensure_single_line.cjs b/app/assets/javascripts/locale/ensure_single_line.cjs index f7790cadc48..abdd56c3589 100644 --- a/app/assets/javascripts/locale/ensure_single_line.cjs +++ b/app/assets/javascripts/locale/ensure_single_line.cjs @@ -1,15 +1,11 @@ const SPLIT_REGEX = /\s*[\r\n]+\s*/; /** - * - * strips newlines from strings and replaces them with a single space - * + * Strips newlines from strings and replaces them with a single space. * @example - * * ensureSingleLine('foo \n bar') === 'foo bar' - * - * @param {String} str - * @returns {String} + * @param {string} - str + * @returns {string} */ module.exports = function ensureSingleLine(str) { // This guard makes the function significantly faster diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 600654794a5..48cf06e0793 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -19,22 +19,22 @@ if (hasTranslations) { } /** - Translates `text` - @param text The text to be translated - @returns {String} The translated text -*/ + * Translates `text`. + * @param {string} text - The text to be translated + * @returns {string} The translated text + */ const gettext = (text) => locale.gettext(ensureSingleLine(text)); /** - Translate the text with a number - if the number is more than 1 it will use the `pluralText` translation. - This method allows for contexts, see below re. contexts - - @param text Singular text to translate (eg. '%d day') - @param pluralText Plural text to translate (eg. '%d days') - @param count Number to decide which translation to use (eg. 2) - @returns {String} Translated text with the number replaced (eg. '2 days') -*/ + * Translate the text with a number. + * + * If the number is more than 1 it will use the `pluralText` translation. + * This method allows for contexts, see below re. contexts + * @param {string} text - Singular text to translate (e.g. '%d day') + * @param {string} pluralText - Plural text to translate (e.g. '%d days') + * @param {number} count - Number to decide which translation to use (e.g. 2) + * @returns {string} Translated text with the number replaced (e.g. '2 days') + */ const ngettext = (text, pluralText, count) => { const translated = locale .ngettext(ensureSingleLine(text), ensureSingleLine(pluralText), count) @@ -45,16 +45,16 @@ const ngettext = (text, pluralText, count) => { }; /** - Translate context based text - Either pass in the context translation like `Context|Text to translate` - or allow for dynamic text by doing passing in the context first & then the text to translate - - @param keyOrContext Can be either the key to translate including the context - (eg. 'Context|Text') or just the context for the translation - (eg. 'Context') - @param key Is the dynamic variable you want to be translated - @returns {String} Translated context based text -*/ + * Translate context based text. + * @example + * s__('Context|Text to translate'); + * @example + * s__('Context', 'Text to translate'); + * @param {string} keyOrContext - Context and a key to translation (e.g. 'Context|Text') + * or just a context (e.g. 'Context') + * @param {string} [key] - if `keyOrContext` is just a context, this is the key to translation + * @returns {string} Translated context based text + */ const pgettext = (keyOrContext, key) => { const normalizedKey = ensureSingleLine(key ? `${keyOrContext}|${key}` : keyOrContext); const translated = gettext(normalizedKey).split('|'); @@ -102,21 +102,19 @@ export const getPreferredLocales = () => { }; /** - Creates an instance of Intl.DateTimeFormat for the current locale. - - @param formatOptions for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat - @returns {Intl.DateTimeFormat} -*/ + * Creates an instance of Intl.DateTimeFormat for the current locale. + * @param {Intl.DateTimeFormatOptions} [formatOptions] - for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat + * @returns {Intl.DateTimeFormat} + */ const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(getPreferredLocales(), formatOptions); /** * Formats a number as a string using `toLocaleString`. - * - * @param {Number} value - number to be converted - * @param {options?} options - options to be passed to - * `toLocaleString` such as `unit` and `style`. - * @param {langCode?} langCode - If set, forces a different + * @param {number} value - number to be converted + * @param {Intl.NumberFormatOptions} [options] - options to be passed to + * `toLocaleString`. + * @param {string|string[]} [langCode] - If set, forces a different * language code from the one currently in the document. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat * diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index 12df67670f9..7faf9390684 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -1,17 +1,15 @@ import { escape } from 'lodash'; /** - Very limited implementation of sprintf supporting only named parameters. - - @param input (translated) text with parameters (e.g. '%{num_users} users use us') - @param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 }) - @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape) - @returns {String} the text with parameters replaces (e.g. '5 users use us') - - @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf - @see https://gitlab.com/gitlab-org/gitlab-foss/issues/37992 -*/ -export default (input, parameters, escapeParameters = true) => { + * Very limited implementation of sprintf supporting only named parameters. + * @param {string} input - (translated) text with parameters (e.g. '%{num_users} users use us') + * @param {Object.<string, string|number>} [parameters] - object mapping parameter names to values (e.g. { num_users: 5 }) + * @param {boolean} [escapeParameters=true] - whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape) + * @returns {string} the text with parameters replaces (e.g. '5 users use us') + * @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf + * @see https://gitlab.com/gitlab-org/gitlab-foss/issues/37992 + */ +export default function sprintf(input, parameters, escapeParameters = true) { let output = input; output = output.replace(/%+/g, '%'); @@ -29,4 +27,4 @@ export default (input, parameters, escapeParameters = true) => { } return output; -}; +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index fd002e29afc..5bfdd174694 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -8,7 +8,6 @@ import './commons'; import './behaviors'; // lib/utils -import applyGitLabUIConfig from '@gitlab/ui/dist/config'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; @@ -44,8 +43,6 @@ import 'jh_else_ce/main_jh'; logHelloDeferred(); -applyGitLabUIConfig(); - // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; window.$ = jQuery; diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index 1ae341820d1..2f9acc96923 100644 --- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -40,7 +40,7 @@ export default { v-gl-tooltip.hover :title="$options.title" :aria-label="$options.title" - data-qa-selector="approve_access_request_button" + data-testid="approve-access-request-button" icon="check" type="submit" /> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index caa292b37ce..1f134fe3f5d 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -71,7 +71,7 @@ export default { :title="title" :aria-label="title" icon="remove" - data-qa-selector="delete_member_button" + data-testid="delete-member-button" @click="showRemoveMemberModal(modalData)" /> </template> diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index c7bd1525558..ecc769174f4 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -56,7 +56,8 @@ export default { actionText() { if (this.isAccessRequest) { return __('Deny access request'); - } else if (this.isInvite) { + } + if (this.isInvite) { return s__('Member|Revoke invite'); } diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 407cbc55dd3..cac8c9fb4db 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -32,9 +32,11 @@ export default { memberType() { if (this.isGroup) { return MEMBER_TYPES.group; - } else if (this.isInvite) { + } + if (this.isInvite) { return MEMBER_TYPES.invite; - } else if (this.isAccessRequest) { + } + if (this.isAccessRequest) { return MEMBER_TYPES.accessRequest; } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 09fe611262c..1bc67522e82 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -93,7 +93,11 @@ function mountPipelines() { const { mrWidgetData } = gl; const table = new Vue({ components: { - CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'), + CommitPipelinesTable: () => { + return gon.features.mrPipelinesGraphql + ? import('~/ci/merge_requests/components/pipelines_table_wrapper.vue') + : import('~/commit/pipelines/legacy_pipelines_table_wrapper.vue'); + }, }, apolloProvider, provide: { @@ -103,6 +107,8 @@ function mountPipelines() { fullPath: pipelineTableViewEl.dataset.fullPath, graphqlPath: pipelineTableViewEl.dataset.graphqlPath, manualActionsLimit: 50, + mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, + sourceProjectFullPath: mrWidgetData?.source_project_full_path || '', withFailedJobsDetails: true, }, render(createElement) { @@ -573,10 +579,12 @@ export default class MergeRequestTabs { expandViewContainer() { this.contentWrapper.classList.remove('container-limited'); + this.contentWrapper.classList.add('diffs-container-limited'); } resetViewContainer() { this.contentWrapper.classList.toggle('container-limited', this.isFixedLayoutPreferred); + this.contentWrapper.classList.remove('diffs-container-limited'); } // Expand the issuable sidebar unless the user explicitly collapsed it diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue index 8e02048f494..c7c16e91e4c 100644 --- a/app/assets/javascripts/merge_requests/components/compare_app.vue +++ b/app/assets/javascripts/merge_requests/components/compare_app.vue @@ -23,9 +23,6 @@ export default { currentProject: { default: () => ({}), }, - currentBranch: { - default: () => ({}), - }, inputs: { default: () => ({}), }, @@ -35,8 +32,12 @@ export default { toggleClass: { default: () => ({}), }, - branchQaSelector: { - default: '', + }, + props: { + currentBranch: { + type: Object, + required: false, + default: () => ({}), }, }, data() { @@ -57,6 +58,12 @@ export default { return this.commitHtml || this.loading || !this.selectedBranch.value; }, }, + watch: { + currentBranch(newVal) { + this.selectedBranch = newVal; + this.fetchCommit(); + }, + }, mounted() { this.fetchCommit(); }, @@ -67,6 +74,7 @@ export default { selectBranch(branch) { this.selectedBranch = branch; this.fetchCommit(); + this.$emit('select-branch', branch.value); }, async fetchCommit() { if (!this.selectedBranch.value) return; @@ -108,7 +116,7 @@ export default { :input-name="inputs.branch.name" :default="currentBranch" :toggle-class="toggleClass.branch" - :qa-selector="branchQaSelector" + data-testid="compare-dropdown" @selected="selectBranch" /> </div> diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue index a5a4e683214..2855d704507 100644 --- a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue +++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue @@ -46,11 +46,6 @@ export default { required: false, default: '', }, - qaSelector: { - type: String, - required: false, - default: null, - }, }, data() { return { @@ -70,6 +65,12 @@ export default { ); }, }, + watch: { + default(newVal) { + this.current = newVal; + this.selected = newVal.value; + }, + }, methods: { async fetchData() { if (!this.endpoint) return; @@ -136,7 +137,7 @@ export default { 'gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown', toggleClass, ]" - :data-qa-selector="qaSelector" + data-testid="source-branch-dropdown" @shown="fetchData" @search="searchData" @select="selectItem" diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/merge_requests/components/header_metadata.vue index a0854be099d..fce7ba385b4 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/merge_requests/components/header_metadata.vue @@ -2,16 +2,10 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; -import { sprintf, __ } from '~/locale'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; +import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; -const noteableTypeText = { - issue: __('issue'), - merge_request: __('merge request'), -}; - export default { TYPE_ISSUE, WORKSPACE_PROJECT, @@ -22,7 +16,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], inject: ['hidden'], computed: { ...mapGetters(['getNoteableData']), @@ -32,26 +25,19 @@ export default { isConfidential() { return this.getNoteableData.confidential; }, - isMergeRequest() { - return this.getNoteableData.targetType === TYPE_MERGE_REQUEST; - }, warningIconsMeta() { return [ { iconName: 'lock', visible: this.isLocked, dataTestId: 'locked', - tooltip: sprintf(__('This %{issuable} is locked. Only project members can comment.'), { - issuable: noteableTypeText[this.getNoteableData.targetType], - }), + tooltip: __('This merge request is locked. Only project members can comment.'), }, { iconName: 'spam', visible: this.hidden, dataTestId: 'hidden', - tooltip: sprintf(__('This %{issuable} is hidden because its author has been banned'), { - issuable: noteableTypeText[this.getNoteableData.targetType], - }), + tooltip: __('This merge request is hidden because its author has been banned'), }, ]; }, @@ -63,9 +49,9 @@ export default { <div class="gl-display-inline-block"> <confidentiality-badge v-if="isConfidential" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" + class="gl-mr-3" :issuable-type="$options.TYPE_ISSUE" + :workspace-type="$options.WORKSPACE_PROJECT" /> <template v-for="meta in warningIconsMeta"> <div @@ -74,11 +60,7 @@ export default { v-gl-tooltip.bottom :data-testid="meta.dataTestId" :title="meta.tooltip || null" - :class="{ - 'gl-mr-3 gl-mt-2 gl-display-flex gl-justify-content-center gl-align-items-center': isMergeRequest, - 'gl-display-inline-block': !isMergeRequest, - }" - class="issuable-warning-icon" + class="issuable-warning-icon gl-mr-3 gl-mt-2 gl-display-flex gl-justify-content-center gl-align-items-center" > <gl-icon :name="meta.iconName" class="icon" /> </div> diff --git a/app/assets/javascripts/merge_requests/components/merge_request_status_badge.vue b/app/assets/javascripts/merge_requests/components/merge_request_status_badge.vue new file mode 100644 index 00000000000..3d5478757a8 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/merge_request_status_badge.vue @@ -0,0 +1,74 @@ +<script> +import Vue from 'vue'; +import { fetchPolicies } from '~/lib/graphql'; +import StatusBadge from '~/issuable/components/status_badge.vue'; + +export const badgeState = Vue.observable({ + state: '', + updateStatus: null, +}); + +export default { + components: { + StatusBadge, + }, + inject: { + query: { default: null }, + projectPath: { default: null }, + iid: { default: null }, + }, + props: { + initialState: { + type: String, + required: false, + default: null, + }, + issuableType: { + type: String, + required: false, + default: '', + }, + }, + data() { + if (!this.iid) { + return { + state: this.initialState, + }; + } + + if (!badgeState.state && this.initialState) { + badgeState.state = this.initialState; + } + + return badgeState; + }, + created() { + if (!badgeState.updateStatus) { + badgeState.updateStatus = this.fetchState; + } + }, + beforeDestroy() { + if (badgeState.updateStatus && this.query) { + badgeState.updateStatus = null; + } + }, + methods: { + async fetchState() { + const { data } = await this.$apollo.query({ + query: this.query, + variables: { + projectPath: this.projectPath, + iid: this.iid, + }, + fetchPolicy: fetchPolicies.NO_CACHE, + }); + + badgeState.state = data?.workspace?.issuable?.state; + }, + }, +}; +</script> + +<template> + <status-badge class="gl-align-self-center gl-mr-3" :issuable-type="issuableType" :state="state" /> +</template> diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index 3c3bee9b108..c1e88a901c4 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -7,13 +7,15 @@ import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isLoggedIn } from '~/lib/utils/common_utils'; -import StatusBox from '~/issuable/components/status_box.vue'; +import StatusBadge from '~/issuable/components/status_badge.vue'; +import { TYPE_MERGE_REQUEST } from '~/issues/constants'; import DiscussionCounter from '~/notes/components/discussion_counter.vue'; import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import titleSubscription from '../queries/title.subscription.graphql'; export default { + TYPE_MERGE_REQUEST, apollo: { $subscribe: { title: { @@ -41,8 +43,8 @@ export default { GlLink, GlSprintf, GlBadge, - StatusBox, DiscussionCounter, + StatusBadge, TodoWidget, ClipboardButton, }, @@ -115,7 +117,11 @@ export default { :class="{ 'gl-max-w-container-xl': !isFluidLayout }" > <div class="gl-w-full gl-display-flex gl-align-items-baseline"> - <status-box :initial-state="getNoteableData.state" issuable-type="merge_request" /> + <status-badge + class="gl-align-self-center gl-mr-3" + :issuable-type="$options.TYPE_MERGE_REQUEST" + :state="getNoteableData.state" + /> <a v-safe-html:[$options.safeHtmlConfig]="titleHtml" href="#top" diff --git a/app/assets/javascripts/merge_requests/index.js b/app/assets/javascripts/merge_requests/index.js new file mode 100644 index 00000000000..29218eb53e0 --- /dev/null +++ b/app/assets/javascripts/merge_requests/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import HeaderMetadata from './components/header_metadata.vue'; + +export function mountHeaderMetadata(store) { + const el = document.querySelector('.js-header-metadata-root'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'HeaderMetadataRoot', + store, + provide: { hidden: parseBoolean(el.dataset.hidden) }, + render: (createElement) => createElement(HeaderMetadata), + }); +} diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index c8268b1a9ae..a7b753b7ca8 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -33,19 +33,26 @@ export default { outputType(output) { if (output.text) { return 'text/plain'; - } else if (output.output_type === ERROR_OUTPUT_TYPE) { + } + if (output.output_type === ERROR_OUTPUT_TYPE) { return 'error'; - } else if (output.data['image/png']) { + } + if (output.data['image/png']) { return 'image/png'; - } else if (output.data['image/jpeg']) { + } + if (output.data['image/jpeg']) { return 'image/jpeg'; - } else if (output.data['text/html']) { + } + if (output.data['text/html']) { return 'text/html'; - } else if (output.data['text/latex']) { + } + if (output.data['text/latex']) { return 'text/latex'; - } else if (output.data['image/svg+xml']) { + } + if (output.data['image/svg+xml']) { return 'image/svg+xml'; - } else if (output.data[TEXT_MARKDOWN]) { + } + if (output.data[TEXT_MARKDOWN]) { return TEXT_MARKDOWN; } @@ -63,21 +70,29 @@ export default { getComponent(output) { if (output.text) { return CodeOutput; - } else if (output.output_type === ERROR_OUTPUT_TYPE) { + } + if (output.output_type === ERROR_OUTPUT_TYPE) { return ErrorOutput; - } else if (output.data['image/png']) { + } + if (output.data['image/png']) { return ImageOutput; - } else if (output.data['image/jpeg']) { + } + if (output.data['image/jpeg']) { return ImageOutput; - } else if (isDataframe(output)) { + } + if (isDataframe(output)) { return DataframeOutput; - } else if (output.data['text/html']) { + } + if (output.data['text/html']) { return HtmlOutput; - } else if (output.data['text/latex']) { + } + if (output.data['text/latex']) { return LatexOutput; - } else if (output.data['image/svg+xml']) { + } + if (output.data['image/svg+xml']) { return HtmlOutput; - } else if (output.data[TEXT_MARKDOWN]) { + } + if (output.data[TEXT_MARKDOWN]) { return MarkdownOutput; } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index a009f2975bb..144cfa4295b 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -5,7 +5,6 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; -import { badgeState } from '~/issuable/components/status_box.vue'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { @@ -14,6 +13,7 @@ import { slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { sprintf } from '~/locale'; +import { badgeState } from '~/merge_requests/components/merge_request_status_badge.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -210,8 +210,6 @@ export default { methods: { ...mapActions([ 'saveNote', - 'stopPolling', - 'restartPolling', 'removePlaceholderNotes', 'closeIssuable', 'reopenIssuable', @@ -253,7 +251,6 @@ export default { } this.note = ''; // Empty textarea while being requested. Repopulate in catch - this.stopPolling(); this.isSubmitting = true; @@ -264,7 +261,6 @@ export default { this.saveNote(noteData) .then(() => { - this.restartPolling(); this.discard(); if (withIssueAction) { @@ -381,7 +377,10 @@ export default { @input="onInput" /> </comment-field-layout> - <div class="note-form-actions"> + <div + class="note-form-actions gl-font-size-0" + :class="{ 'gl-display-flex gl-gap-3': hasDrafts }" + > <template v-if="hasDrafts"> <gl-button :disabled="disableSubmitButton" @@ -404,7 +403,7 @@ export default { <gl-form-checkbox v-if="canSetInternalNote" v-model="noteIsInternal" - class="gl-mb-2" + class="gl-mb-2 gl-flex-basis-full" data-testid="internal-note-checkbox" > {{ $options.i18n.internal }} diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 7266cdb6405..90f7a6862f0 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -137,7 +137,8 @@ export default { filterType(value) { if (value === 0) { return DISCUSSION_FILTER_TYPES.ALL; - } else if (value === 1) { + } + if (value === 1) { return DISCUSSION_FILTER_TYPES.COMMENTS; } return DISCUSSION_FILTER_TYPES.HISTORY; diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue index 08d3670ae6a..c2ac95ca56e 100644 --- a/app/assets/javascripts/notes/components/mr_discussion_filter.vue +++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue @@ -36,7 +36,8 @@ export default { if (length === MR_FILTER_OPTIONS.length) { return __('All activity'); - } else if (length > 1) { + } + if (length > 1) { return `%{strongStart}${firstSelected.text}%{strongEnd} +${length - 1} more`; } diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 8b43f068f11..363383fd7ad 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -160,12 +160,14 @@ export default { filePath: this.diffFile.file_path, refs: this.diffFile.diff_refs, }; - } else if (this.note && this.note.position) { + } + if (this.note && this.note.position) { return { filePath: this.note.position.new_path, refs: this.note.position, }; - } else if (this.discussion && this.discussion.diff_file) { + } + if (this.discussion && this.discussion.diff_file) { return { filePath: this.discussion.diff_file.file_path, refs: this.discussion.diff_file.diff_refs, @@ -381,8 +383,8 @@ export default { @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" /> </comment-field-layout> - <div class="note-form-actions"> - <p v-if="showResolveDiscussionToggle"> + <div class="note-form-actions gl-font-size-0"> + <template v-if="showResolveDiscussionToggle"> <label> <template v-if="discussionResolved"> <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox"> @@ -395,7 +397,7 @@ export default { </gl-form-checkbox> </template> </label> - </p> + </template> <template v-if="showBatchCommentsActions"> <div class="gl-display-flex gl-flex-wrap gl-mb-n3"> @@ -432,7 +434,7 @@ export default { </div> </template> <template v-else> - <div class="gl-display-sm-flex gl-flex-wrap"> + <div class="gl-display-sm-flex gl-flex-wrap gl-font-size-0"> <gl-button :disabled="isDisabled" category="primary" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 6fb958e810b..2524b9efdb6 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -169,7 +169,6 @@ export default { }); }, beforeDestroy() { - this.stopPolling(); window.removeEventListener('hashchange', this.handleHashChanged); eventHub.$off('notesApp.updateIssuableConfidentiality', this.setConfidentiality); }, @@ -182,7 +181,6 @@ export default { 'expandDiscussion', 'startTaskList', 'convertToDiscussion', - 'stopPolling', 'setConfidentiality', 'fetchNotes', ]), diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index f60a17eb36b..c02c7a57dfa 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -3,7 +3,7 @@ import { mapActions } from 'vuex'; import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; -import { confidentialityQueries } from '~/sidebar/constants'; +import { confidentialityQueries } from '~/sidebar/queries/constants'; import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; export default { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 0444eca9aa7..7eb01897296 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import Visibility from 'visibilityjs'; import Vue from 'vue'; import actionCable from '~/actioncable_consumer'; import Api from '~/api'; @@ -14,8 +13,6 @@ import updateIssueLockMutation from '~/sidebar/queries/update_issue_lock.mutatio import updateMergeRequestLockMutation from '~/sidebar/queries/update_merge_request_lock.mutation.graphql'; import loadAwardsHandler from '~/awards_handler'; import { isInViewport, scrollToElement, isInMRPage } from '~/lib/utils/common_utils'; -import Poll from '~/lib/utils/poll'; -import { create } from '~/lib/utils/recurrence'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; import TaskList from '~/task_list'; @@ -30,9 +27,6 @@ import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; -const NOTES_POLLING_INTERVAL = 6000; -let eTagPoll; - export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) => { const { iid, targetType } = getters.getNoteableData; @@ -152,29 +146,25 @@ export const initPolling = ({ state, dispatch, getters, commit }) => { dispatch('setLastFetchedAt', getters.getNotesDataByProp('lastFetchedAt')); - if (gon.features?.actionCableNotes) { - actionCable.subscriptions.create( - { - channel: 'Noteable::NotesChannel', - project_id: state.notesData.projectId, - group_id: state.notesData.groupId, - noteable_type: state.notesData.noteableType, - noteable_id: state.notesData.noteableId, + actionCable.subscriptions.create( + { + channel: 'Noteable::NotesChannel', + project_id: state.notesData.projectId, + group_id: state.notesData.groupId, + noteable_type: state.notesData.noteableType, + noteable_id: state.notesData.noteableId, + }, + { + connected() { + dispatch('fetchUpdatedNotes'); }, - { - connected() { + received(data) { + if (data.event === 'updated') { dispatch('fetchUpdatedNotes'); - }, - received(data) { - if (data.event === 'updated') { - dispatch('fetchUpdatedNotes'); - } - }, + } }, - ); - } else { - dispatch('poll'); - } + }, + ); commit(types.SET_IS_POLLING_INITIALIZED, true); }; @@ -386,7 +376,8 @@ export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId } if (!discussion) { return Promise.reject(); - } else if (isResolved) { + } + if (isResolved) { return Promise.resolve(); } @@ -515,8 +506,6 @@ export const saveNote = ({ commit, dispatch }, noteData) => { {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}} */ if (hasQuickActions && message) { - if (eTagPoll) eTagPoll.makeRequest(); - // synchronizing the quick action with the sidebar widget // this is a temporary solution until we have confidentiality real-time updates if ( @@ -624,69 +613,7 @@ export const fetchUpdatedNotes = ({ commit, state, getters, dispatch }) => { .then(({ data }) => { pollSuccessCallBack(data, commit, state, getters, dispatch); }) - .catch(() => { - createAlert({ - message: __('Something went wrong while fetching latest comments.'), - }); - }); -}; - -export const poll = ({ commit, state, getters, dispatch }) => { - const notePollOccurrenceTracking = create(); - let alert; - - notePollOccurrenceTracking.handle(1, () => { - // Since polling halts internally after 1 failure, we manually try one more time - setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); - }); - notePollOccurrenceTracking.handle(2, () => { - // On the second failure in a row, show the alert and try one more time (hoping to succeed and clear the error) - alert = createAlert({ - message: __('Something went wrong while fetching latest comments.'), - }); - setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); - }); - - eTagPoll = new Poll({ - resource: { - poll: () => { - const { endpoint, options } = getFetchDataParams(state); - return axios.get(endpoint, options); - }, - }, - method: 'poll', - successCallback: ({ data }) => { - pollSuccessCallBack(data, commit, state, getters, dispatch); - - if (notePollOccurrenceTracking.count) { - notePollOccurrenceTracking.reset(); - } - alert?.dismiss(); - }, - errorCallback: () => notePollOccurrenceTracking.occur(), - }); - - if (!Visibility.hidden()) { - eTagPoll.makeDelayedRequest(2500); - } else { - eTagPoll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - eTagPoll.restart(); - } else { - eTagPoll.stop(); - } - }); -}; - -export const stopPolling = () => { - if (eTagPoll) eTagPoll.stop(); -}; - -export const restartPolling = () => { - if (eTagPoll) eTagPoll.restart(); + .catch(() => {}); }; export const toggleAward = ({ commit, getters }, { awardName, noteId }) => { @@ -766,7 +693,6 @@ export const submitSuggestion = ( dispatch('resolveDiscussion', { discussionId }).catch(() => {}); commit(types.SET_RESOLVING_DISCUSSION, true); - dispatch('stopPolling'); return Api.applySuggestion(suggestionId, message) .then(dispatchResolveDiscussion) @@ -786,7 +712,6 @@ export const submitSuggestion = ( }) .finally(() => { commit(types.SET_RESOLVING_DISCUSSION, false); - dispatch('restartPolling'); }); }; @@ -801,7 +726,6 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl commit(types.SET_APPLYING_BATCH_STATE, true); commit(types.SET_RESOLVING_DISCUSSION, true); - dispatch('stopPolling'); return Api.applySuggestionBatch(suggestionIds, message) .then(() => Promise.all(resolveAllDiscussions())) @@ -823,7 +747,6 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl .finally(() => { commit(types.SET_APPLYING_BATCH_STATE, false); commit(types.SET_RESOLVING_DISCUSSION, false); - dispatch('restartPolling'); }); }; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 3fb9913bdcb..c43430639ad 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,8 +1,8 @@ import { flattenDeep, clone } from 'lodash'; import { match } from '~/diffs/utils/diff_file'; -import { badgeState } from '~/issuable/components/status_box.vue'; import { isInMRPage } from '~/lib/utils/common_utils'; import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { badgeState } from '~/merge_requests/components/merge_request_status_badge.vue'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index c55600f3db2..718001e98fe 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -1,57 +1,62 @@ +import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; -// import mockData from './mock_traces.json'; - -function enableTraces() { - // TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271 - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 1000); - }); -} -function isTracingEnabled() { - // TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271 - return new Promise((resolve) => { - setTimeout(() => { - // Currently relying on manual provisioning, hence assuming tracing is enabled - resolve(true); - }, 1000); - }); +function reportErrorAndThrow(e) { + Sentry.captureException(e); + throw e; } - -function traceWithDuration(trace) { - // aggregating duration on the client for now, but expecting to be coming from the backend - // https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2274 - const duration = trace.spans[0].duration_nano; - return { - ...trace, - duration: duration / 1000, - }; +// Provisioning API spec: https://gitlab.com/gitlab-org/opstrace/opstrace/-/blob/main/provisioning-api/pkg/provisioningapi/routes.go#L59 +async function enableTraces(provisioningUrl) { + try { + // Note: axios.put(url, undefined, {withCredentials: true}) does not send cookies properly, so need to use the API below for the correct behaviour + return await axios(provisioningUrl, { + method: 'put', + withCredentials: true, + }); + } catch (e) { + return reportErrorAndThrow(e); + } } -async function fetchTrace(tracingUrl, traceId) { - if (!traceId) { - throw new Error('traceId is required.'); +// Provisioning API spec: https://gitlab.com/gitlab-org/opstrace/opstrace/-/blob/main/provisioning-api/pkg/provisioningapi/routes.go#L37 +async function isTracingEnabled(provisioningUrl) { + try { + const { data } = await axios.get(provisioningUrl, { withCredentials: true }); + if (data && data.status) { + // we currently ignore the 'status' payload and just check if the request was successful + // We might improve this as part of https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2315 + return true; + } + } catch (e) { + if (e.response.status === 404) { + return false; + } + return reportErrorAndThrow(e); } + return reportErrorAndThrow(new Error('Failed to check provisioning')); // eslint-disable-line @gitlab/require-i18n-strings +} - const { data } = await axios.get(tracingUrl, { - withCredentials: true, - params: { - trace_id: traceId, - }, - }); +async function fetchTrace(tracingUrl, traceId) { + try { + if (!traceId) { + throw new Error('traceId is required.'); + } + + const { data } = await axios.get(tracingUrl, { + withCredentials: true, + params: { + trace_id: traceId, + }, + }); - // TODO: Improve local GDK dev experience with tracing https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2308 - // const data = mockData; - // const trace = data.traces.find((t) => t.trace_id === traceId); + if (!Array.isArray(data.traces) || data.traces.length === 0) { + throw new Error('traces are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings + } - if (!Array.isArray(data.traces) || data.traces.length === 0) { - throw new Error('traces are missing/invalid in the response.'); // eslint-disable-line @gitlab/require-i18n-strings + return data.traces[0]; + } catch (e) { + return reportErrorAndThrow(e); } - - const trace = data.traces[0]; - return traceWithDuration(trace); } /** @@ -164,18 +169,18 @@ function filterObjToQueryParams(filterObj) { async function fetchTraces(tracingUrl, filters = {}) { const filterParams = filterObjToQueryParams(filters); - const { data } = await axios.get(tracingUrl, { - withCredentials: true, - params: filterParams, - }); - // TODO: Improve local GDK dev experience with tracing https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2308 - // Uncomment the line below to test this locally - // const data = mockData; - - if (!Array.isArray(data.traces)) { - throw new Error('traces are missing/invalid in the response.'); // eslint-disable-line @gitlab/require-i18n-strings + try { + const { data } = await axios.get(tracingUrl, { + withCredentials: true, + params: filterParams, + }); + if (!Array.isArray(data.traces)) { + throw new Error('traces are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings + } + return data.traces; + } catch (e) { + return reportErrorAndThrow(e); } - return data.traces.map(traceWithDuration); } export function buildClient({ provisioningUrl, tracingUrl }) { diff --git a/app/assets/javascripts/observability/mock_traces.json b/app/assets/javascripts/observability/mock_traces.json index ee59258e591..cd7dfb40af6 100644 --- a/app/assets/javascripts/observability/mock_traces.json +++ b/app/assets/javascripts/observability/mock_traces.json @@ -1,348 +1,107 @@ { - "project_id": "10141740", + "project_id": 123, "traces": [ { - "timestamp": "2023-07-18T10:31:23.661285Z", - "trace_id": "08a1b018-e1b9-88b2-094b-ca5fd40783ad", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:23.661285Z", - "span_id": "30A9220B254C42B1", - "trace_id": "08a1b018-e1b9-88b2-094b-ca5fd40783ad", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 250, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:17.026724Z", - "trace_id": "1c2099e0-6da8-d5fb-a91d-bdd5a5bea82c", - "service_name": "my-service-name2", - "operation": "Addition", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:17.026724Z", - "span_id": "154925D3DA2C1307", - "trace_id": "1c2099e0-6da8-d5fb-a91d-bdd5a5bea82c", - "service_name": "my-service-name", - "operation": "Addition", - "duration_nano": 208, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:21.602132Z", - "trace_id": "f4c2f964-afee-cc2e-bd1a-c654ff55db4e", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:21.602132Z", - "span_id": "53A4AE94DFF72A28", - "trace_id": "f4c2f964-afee-cc2e-bd1a-c654ff55db4e", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 5125, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:14.772009Z", - "trace_id": "fa6302ad-7214-7c05-40f7-91195356774e", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:14.772009Z", - "span_id": "5BB240D099656820", - "trace_id": "fa6302ad-7214-7c05-40f7-91195356774e", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 1584, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:22.623552Z", - "trace_id": "54021e57-a25c-c6fe-1f53-542bbdbcb16c", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:22.623552Z", - "span_id": "C5AE65D0C26BF3FD", - "trace_id": "54021e57-a25c-c6fe-1f53-542bbdbcb16c", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 750, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:21.602156Z", - "trace_id": "34d455cc-e518-fb4e-513f-e88030d4ccc8", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:21.602156Z", - "span_id": "5288B61252594EB2", - "trace_id": "34d455cc-e518-fb4e-513f-e88030d4ccc8", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 750, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:20.567364Z", - "trace_id": "3892a93a-f4eb-b416-372e-3c9237be97e3", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:20.567364Z", - "span_id": "1D690E5094345C98", - "trace_id": "3892a93a-f4eb-b416-372e-3c9237be97e3", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 958, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:23.661289Z", - "trace_id": "9d0630d5-21b5-686f-57cb-d97c647fc31f", - "service_name": "my-service-name", - "operation": "Addition", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:23.661289Z", - "span_id": "8F548EE08F9C2EAC", - "trace_id": "9d0630d5-21b5-686f-57cb-d97c647fc31f", - "service_name": "my-service-name", - "operation": "Addition", - "duration_nano": 167, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:14.77197Z", - "trace_id": "f2470b0e-3bbb-8af2-68f8-c97343fba7ee", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:14.77197Z", - "span_id": "6B5AB710CE8A4471", - "trace_id": "f2470b0e-3bbb-8af2-68f8-c97343fba7ee", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 5583, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:17.026712Z", - "trace_id": "f4e64ee0-ee32-0edb-c4d1-a15f4047bdc4", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:17.026712Z", - "span_id": "199D402DE1A29F3F", - "trace_id": "f4e64ee0-ee32-0edb-c4d1-a15f4047bdc4", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 6959, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:20.567337Z", - "trace_id": "344b4db1-c890-514c-b94f-425fff3a795b", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:20.567337Z", - "span_id": "CAC38748150E5A0C", - "trace_id": "344b4db1-c890-514c-b94f-425fff3a795b", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 3917, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:22.623559Z", - "trace_id": "40b933be-11d7-7b41-e63a-ff7e7c5d50ab", - "service_name": "my-service-name", - "operation": "Addition", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:22.623559Z", - "span_id": "3485100A27958F59", - "trace_id": "40b933be-11d7-7b41-e63a-ff7e7c5d50ab", - "service_name": "my-service-name", - "operation": "Addition", - "duration_nano": 709, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:17.026723Z", - "trace_id": "c3347c43-316f-2b08-0b46-7d142d6764b7", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:17.026723Z", - "span_id": "1CF28C36AB7EB3F9", - "trace_id": "c3347c43-316f-2b08-0b46-7d142d6764b7", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 208, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:23.661272Z", - "trace_id": "762f9104-d3db-c762-d6d7-0476ca21249f", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:23.661272Z", - "span_id": "83D8D6D2BD99A4D1", - "trace_id": "762f9104-d3db-c762-d6d7-0476ca21249f", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 10000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:22.623524Z", - "trace_id": "b46ded15-f900-fba7-7396-a6b453221038", - "service_name": "my-service-name", - "operation": "Multiplication", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:22.623524Z", - "span_id": "EB84455AE35DEAD5", - "trace_id": "b46ded15-f900-fba7-7396-a6b453221038", - "service_name": "my-service-name", - "operation": "Multiplication", - "duration_nano": 17666, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:21.60216Z", - "trace_id": "8dcd7b90-7f94-6b19-4a8f-b681801568ba", - "service_name": "my-service-name", - "operation": "Addition", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:21.60216Z", - "span_id": "A5C773414186949D", - "trace_id": "8dcd7b90-7f94-6b19-4a8f-b681801568ba", - "service_name": "my-service-name", - "operation": "Addition", - "duration_nano": 250, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:14.772014Z", - "trace_id": "51e23c12-033d-a0db-d2b0-2f3f4e3d9fb3", - "service_name": "my-service-name", - "operation": "Addition", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:14.772014Z", - "span_id": "3397060046FD4428", - "trace_id": "51e23c12-033d-a0db-d2b0-2f3f4e3d9fb3", - "service_name": "my-service-name", - "operation": "Addition", - "duration_nano": 291, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 - }, - { - "timestamp": "2023-07-18T10:31:20.567369Z", - "trace_id": "d54b5015-3938-ddf8-3aa1-49882503233e", - "service_name": "my-service-name", - "operation": "Addition", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-18T10:31:20.567369Z", - "span_id": "DAC36ACC2DBA8B11", - "trace_id": "d54b5015-3938-ddf8-3aa1-49882503233e", - "service_name": "my-service-name", - "operation": "Addition", - "duration_nano": 208, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 1 + "timestamp": "2023-08-07T15:03:32.199806Z", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "tracegentracegentracegenttracegentracegentracegent", + "operation": "lets-golets-golets-goletslets-golets-golets-golets", + "statusCode": "STATUS_CODE_UNSET", + "duration_nano": 100120000, + "spans": [ + { + "timestamp": "2023-08-07T15:03:32.199806Z", + "span_id": "A1FB81EB031B09E8", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "tracegentracegentracegentracegentracegentracegentracegentracegentracegentracegentracegentracegentracegen", + "operation": "lets-golets-golets-golets-golets-golets-golets-golets-golets-golets-golets-golets-go", + "duration_nano": 100120000, + "parent_span_id": "", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:03:32.199871Z", + "span_id": "9C920500FE9C85E3", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 100055000, + "parent_span_id": "A1FB81EB031B09E8", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:03:53.199871Z", + "span_id": "FAKE", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 50027500, + "parent_span_id": "9C920500FE9C85E3", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:03:53.199871Z", + "span_id": "FAKE-2", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "fake-service-2", + "operation": "okey-dokey", + "duration_nano": 50027500, + "parent_span_id": "9C920500FE9C85E3", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:04:13.199871Z", + "span_id": "FAKE-3", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "fake-service-3", + "operation": "okey-dokey", + "duration_nano": 30000000, + "parent_span_id": "FAKE-2", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:04:13.199871Z", + "span_id": "FAKE-4", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "fake-service-4", + "operation": "okey-dokey", + "duration_nano": 25000000, + "parent_span_id": "FAKE-3", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:04:13.199871Z", + "span_id": "FAKE-5", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "fake-service-5", + "operation": "okey-dokey", + "duration_nano": 10000000, + "parent_span_id": "FAKE-4", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:04:13.199871Z", + "span_id": "FAKE-6", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "fake-service-6", + "operation": "okey-dokey", + "duration_nano": 10000000, + "parent_span_id": "FAKE-5", + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-08-07T15:04:13.199871Z", + "span_id": "FAKE-7", + "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", + "service_name": "fake-service-7", + "operation": "okey-dokey", + "duration_nano": 5000000, + "parent_span_id": "FAKE-6", + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 5 } ], - "totalTraces": 18 + "totalTraces": 50 } diff --git a/app/assets/javascripts/organizations/constants.js b/app/assets/javascripts/organizations/constants.js new file mode 100644 index 00000000000..8ade37b169e --- /dev/null +++ b/app/assets/javascripts/organizations/constants.js @@ -0,0 +1,4 @@ +export const RESOURCE_TYPE_GROUPS = 'groups'; +export const RESOURCE_TYPE_PROJECTS = 'projects'; + +export const ORGANIZATION_ROOT_ROUTE_NAME = 'root'; 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 10471cc1fdd..dba738de5e1 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue +++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue @@ -13,9 +13,10 @@ import { FILTERED_SEARCH_TERM, TOKEN_EMPTY_SEARCH_TERM, } from '~/vue_shared/components/filtered_search_bar/constants'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; +import GroupsView from '../../shared/components/groups_view.vue'; +import ProjectsView from '../../shared/components/projects_view.vue'; import { - DISPLAY_QUERY_GROUPS, - DISPLAY_QUERY_PROJECTS, DISPLAY_LISTBOX_ITEMS, SORT_DIRECTION_ASC, SORT_DIRECTION_DESC, @@ -23,8 +24,6 @@ import { SORT_ITEM_CREATED, FILTERED_SEARCH_TERM_KEY, } from '../constants'; -import GroupsPage from './groups_page.vue'; -import ProjectsPage from './projects_page.vue'; export default { i18n: { @@ -45,14 +44,14 @@ export default { const { display } = this.$route.query; switch (display) { - case DISPLAY_QUERY_GROUPS: - return GroupsPage; + case RESOURCE_TYPE_GROUPS: + return GroupsView; - case DISPLAY_QUERY_PROJECTS: - return ProjectsPage; + case RESOURCE_TYPE_PROJECTS: + return ProjectsView; default: - return GroupsPage; + return GroupsView; } }, activeSortItem() { @@ -80,9 +79,9 @@ export default { displayListboxSelected() { const { display } = this.$route.query; - return [DISPLAY_QUERY_GROUPS, DISPLAY_QUERY_PROJECTS].includes(display) + return [RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS].includes(display) ? display - : DISPLAY_QUERY_GROUPS; + : RESOURCE_TYPE_GROUPS; }, }, methods: { diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue b/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue deleted file mode 100644 index 20db38403f7..00000000000 --- a/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; -import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; -import groupsQuery from '../graphql/queries/groups.query.graphql'; -import { formatGroups } from '../utils'; - -export default { - i18n: { - errorMessage: s__( - 'Organization|An error occurred loading the groups. Please refresh the page to try again.', - ), - }, - components: { GlLoadingIcon, GroupsList }, - data() { - return { - groups: [], - }; - }, - apollo: { - groups: { - query: groupsQuery, - update(data) { - return formatGroups(data.organization.groups.nodes); - }, - error(error) { - createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); - }, - }, - }, - computed: { - isLoading() { - return this.$apollo.queries.groups.loading; - }, - }, -}; -</script> - -<template> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <groups-list v-else :groups="groups" show-group-icon /> -</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue b/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue deleted file mode 100644 index d6958ee996e..00000000000 --- a/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue +++ /dev/null @@ -1,46 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; -import { createAlert } from '~/alert'; -import projectsQuery from '../graphql/queries/projects.query.graphql'; -import { formatProjects } from '../utils'; - -export default { - i18n: { - errorMessage: s__( - 'Organization|An error occurred loading the projects. Please refresh the page to try again.', - ), - }, - components: { - ProjectsList, - GlLoadingIcon, - }, - data() { - return { - projects: [], - }; - }, - apollo: { - projects: { - query: projectsQuery, - update(data) { - return formatProjects(data.organization.projects.nodes); - }, - error(error) { - createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); - }, - }, - }, - computed: { - isLoading() { - return this.$apollo.queries.projects.loading; - }, - }, -}; -</script> - -<template> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <projects-list v-else :projects="projects" show-project-icon /> -</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/constants.js b/app/assets/javascripts/organizations/groups_and_projects/constants.js index 529caa666a0..d79b632f6fb 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/constants.js +++ b/app/assets/javascripts/organizations/groups_and_projects/constants.js @@ -3,8 +3,6 @@ import { __ } from '~/locale'; export const DISPLAY_QUERY_GROUPS = 'groups'; export const DISPLAY_QUERY_PROJECTS = 'projects'; -export const ORGANIZATION_ROOT_ROUTE_NAME = 'root'; - export const FILTERED_SEARCH_TERM_KEY = 'search'; export const DISPLAY_LISTBOX_ITEMS = [ diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js index f3f15c635f1..3e05e4d0a4c 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/index.js +++ b/app/assets/javascripts/organizations/groups_and_projects/index.js @@ -2,9 +2,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; -import resolvers from './graphql/resolvers'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { ORGANIZATION_ROOT_ROUTE_NAME } from '../constants'; +import resolvers from '../shared/graphql/resolvers'; import App from './components/app.vue'; -import { ORGANIZATION_ROOT_ROUTE_NAME } from './constants'; export const createRouter = () => { const routes = [{ path: '/', name: ORGANIZATION_ROOT_ROUTE_NAME }]; @@ -23,6 +24,16 @@ export const initOrganizationsGroupsAndProjects = () => { if (!el) return false; + const { + dataset: { appData }, + } = el; + const { + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + } = convertObjectPropsToCamelCase(JSON.parse(appData)); + Vue.use(VueRouter); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(resolvers), @@ -34,6 +45,12 @@ export const initOrganizationsGroupsAndProjects = () => { name: 'OrganizationsGroupsAndProjects', apolloProvider, router, + provide: { + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + }, render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js new file mode 100644 index 00000000000..17ab7bd1d34 --- /dev/null +++ b/app/assets/javascripts/organizations/mock_data.js @@ -0,0 +1,258 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +// This is temporary mock data that will be removed when completing the following: +// https://gitlab.com/gitlab-org/gitlab/-/issues/420777 +// https://gitlab.com/gitlab-org/gitlab/-/issues/421441 + +export const organization = { + id: 'gid://gitlab/Organization/1', + __typename: 'Organization', +}; + +export const organizationProjects = { + nodes: [ + { + id: 'gid://gitlab/Project/8', + nameWithNamespace: 'Twitter / Typeahead.Js', + webUrl: 'http://127.0.0.1:3000/twitter/Typeahead.Js', + topics: ['JavaScript', 'Vue.js'], + 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>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: true, + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Project/7', + nameWithNamespace: 'Flightjs / Flight', + webUrl: 'http://127.0.0.1:3000/flightjs/Flight', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'private', + openIssuesCount: 37, + descriptionHtml: + '<p data-sourcepos="1:1-1:49" dir="auto">Dolor dicta rerum et ut eius voluptate earum qui.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 20, + }, + }, + { + id: 'gid://gitlab/Project/6', + nameWithNamespace: 'Jashkenas / Underscore', + webUrl: 'http://127.0.0.1:3000/jashkenas/Underscore', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'private', + openIssuesCount: 34, + descriptionHtml: + '<p data-sourcepos="1:1-1:52" dir="auto">Incidunt est aliquam autem nihil eveniet quis autem.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Project/5', + nameWithNamespace: 'Commit451 / Lab Coat', + webUrl: 'http://127.0.0.1:3000/Commit451/lab-coat', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'internal', + openIssuesCount: 49, + descriptionHtml: + '<p data-sourcepos="1:1-1:34" dir="auto">Sint eos dolorem impedit rerum et.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Project/1', + nameWithNamespace: 'Toolbox / Gitlab Smoke Tests', + webUrl: 'http://127.0.0.1:3000/toolbox/gitlab-smoke-tests', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'internal', + openIssuesCount: 34, + descriptionHtml: + '<p data-sourcepos="1:1-1:40" dir="auto">Veritatis error laboriosam libero autem.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 30, + }, + }, + ], +}; + +export const organizationGroups = { + nodes: [ + { + id: 'gid://gitlab/Group/29', + fullName: 'Commit451', + 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>', + avatarUrl: null, + descendantGroupsCount: 0, + projectsCount: 3, + groupMembersCount: 2, + visibility: 'public', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Group/33', + fullName: 'Flightjs', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/flightjs', + descriptionHtml: + '<p data-sourcepos="1:1-1:60" dir="auto">Ipsa reiciendis deleniti officiis illum nostrum quo aliquam.</p>', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 3, + groupMembersCount: 1, + visibility: 'private', + accessLevel: { + integerValue: 20, + }, + }, + { + id: 'gid://gitlab/Group/24', + fullName: 'Gitlab Org', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org', + descriptionHtml: + '<p data-sourcepos="1:1-1:64" dir="auto">Dolorem dolorem omnis impedit cupiditate pariatur officia velit.</p>', + avatarUrl: null, + descendantGroupsCount: 1, + projectsCount: 1, + groupMembersCount: 2, + visibility: 'internal', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Group/27', + fullName: 'Gnuwget', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/gnuwgetf', + descriptionHtml: + '<p data-sourcepos="1:1-1:47" dir="auto">Culpa soluta aut eius dolores est vel sapiente.</p>', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 2, + groupMembersCount: 3, + visibility: 'public', + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Group/31', + fullName: 'Jashkenas', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/jashkenas', + descriptionHtml: '<p data-sourcepos="1:1-1:25" dir="auto">Ut ut id aliquid nostrum.</p>', + avatarUrl: null, + descendantGroupsCount: 3, + projectsCount: 3, + groupMembersCount: 10, + visibility: 'private', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Group/22', + fullName: 'Toolbox', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/toolbox', + descriptionHtml: + '<p data-sourcepos="1:1-1:46" dir="auto">Quo voluptatem magnam facere voluptates alias.</p>', + avatarUrl: null, + descendantGroupsCount: 2, + projectsCount: 3, + groupMembersCount: 40, + visibility: 'internal', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Group/35', + fullName: 'Twitter', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/twitter', + descriptionHtml: + '<p data-sourcepos="1:1-1:40" dir="auto">Quae nulla consequatur assumenda id quo.</p>', + avatarUrl: null, + descendantGroupsCount: 20, + projectsCount: 30, + groupMembersCount: 100, + visibility: 'public', + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Group/73', + fullName: 'test', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/test', + descriptionHtml: '', + avatarUrl: null, + descendantGroupsCount: 1, + projectsCount: 1, + groupMembersCount: 1, + visibility: 'private', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Group/74', + fullName: 'Twitter / test subgroup', + parent: { + id: 'gid://gitlab/Group/35', + }, + webUrl: 'http://127.0.0.1:3000/groups/twitter/test-subgroup', + descriptionHtml: '', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 4, + groupMembersCount: 4, + visibility: 'internal', + accessLevel: { + integerValue: 20, + }, + }, + ], +}; diff --git a/app/assets/javascripts/organizations/shared/components/groups_view.vue b/app/assets/javascripts/organizations/shared/components/groups_view.vue new file mode 100644 index 00000000000..eaa3017ef97 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/groups_view.vue @@ -0,0 +1,82 @@ +<script> +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { s__, __ } from '~/locale'; +import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; +import groupsQuery from '../graphql/queries/groups.query.graphql'; +import { formatGroups } from '../utils'; + +export default { + i18n: { + errorMessage: s__( + 'Organization|An error occurred loading the groups. Please refresh the page to try again.', + ), + emptyState: { + title: s__("Organization|You don't have any groups yet."), + description: s__( + 'Organization|A group is a collection of several projects. If you organize your projects under a group, it works like a folder.', + ), + primaryButtonText: __('New group'), + }, + }, + components: { GlLoadingIcon, GlEmptyState, GroupsList }, + inject: { + groupsEmptyStateSvgPath: {}, + newGroupPath: { + default: null, + }, + }, + props: { + shouldShowEmptyStateButtons: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + groups: [], + }; + }, + apollo: { + groups: { + query: groupsQuery, + update(data) { + return formatGroups(data.organization.groups.nodes); + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.groups.loading; + }, + emptyStateProps() { + const baseProps = { + svgHeight: 144, + svgPath: this.groupsEmptyStateSvgPath, + title: this.$options.i18n.emptyState.title, + description: this.$options.i18n.emptyState.description, + }; + + if (this.shouldShowEmptyStateButtons && this.newGroupPath) { + return { + ...baseProps, + primaryButtonLink: this.newGroupPath, + primaryButtonText: this.$options.i18n.emptyState.primaryButtonText, + }; + } + + return baseProps; + }, + }, +}; +</script> + +<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 /> + <gl-empty-state v-else v-bind="emptyStateProps" /> +</template> diff --git a/app/assets/javascripts/organizations/shared/components/projects_view.vue b/app/assets/javascripts/organizations/shared/components/projects_view.vue new file mode 100644 index 00000000000..9bf4e597884 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/projects_view.vue @@ -0,0 +1,86 @@ +<script> +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import { createAlert } from '~/alert'; +import projectsQuery from '../graphql/queries/projects.query.graphql'; +import { formatProjects } from '../utils'; + +export default { + i18n: { + errorMessage: s__( + 'Organization|An error occurred loading the projects. Please refresh the page to try again.', + ), + emptyState: { + title: s__("Organization|You don't have any projects yet."), + description: s__( + 'GroupsEmptyState|Projects are where you can store your code, access issues, wiki, and other features of Gitlab.', + ), + primaryButtonText: __('New project'), + }, + }, + components: { + ProjectsList, + GlLoadingIcon, + GlEmptyState, + }, + inject: { + projectsEmptyStateSvgPath: {}, + newProjectPath: { + default: null, + }, + }, + props: { + shouldShowEmptyStateButtons: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projects: [], + }; + }, + apollo: { + projects: { + query: projectsQuery, + update(data) { + return formatProjects(data.organization.projects.nodes); + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.projects.loading; + }, + emptyStateProps() { + const baseProps = { + svgHeight: 144, + svgPath: this.projectsEmptyStateSvgPath, + title: this.$options.i18n.emptyState.title, + description: this.$options.i18n.emptyState.description, + }; + + if (this.shouldShowEmptyStateButtons && this.newProjectPath) { + return { + ...baseProps, + primaryButtonLink: this.newProjectPath, + primaryButtonText: this.$options.i18n.emptyState.primaryButtonText, + }; + } + + return baseProps; + }, + }, +}; +</script> + +<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 /> + <gl-empty-state v-else v-bind="emptyStateProps" /> +</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/groups.query.graphql index 842c601e326..842c601e326 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql +++ b/app/assets/javascripts/organizations/shared/graphql/queries/groups.query.graphql diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/projects.query.graphql index 2a7971e1106..2a7971e1106 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql +++ b/app/assets/javascripts/organizations/shared/graphql/queries/projects.query.graphql diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js b/app/assets/javascripts/organizations/shared/graphql/resolvers.js index 8a375b28797..c78266b0476 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/shared/graphql/resolvers.js @@ -1,8 +1,4 @@ -import { - organization, - organizationProjects, - organizationGroups, -} from 'jest/organizations/groups_and_projects/mock_data'; +import { organization, organizationProjects, organizationGroups } from '../../mock_data'; export default { Query: { diff --git a/app/assets/javascripts/organizations/groups_and_projects/utils.js b/app/assets/javascripts/organizations/shared/utils.js index d2a4e05e806..c1aafefc553 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/utils.js +++ b/app/assets/javascripts/organizations/shared/utils.js @@ -1,5 +1,5 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/projects_list/constants'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; export const formatProjects = (projects) => projects.map(({ id, nameWithNamespace, accessLevel, webUrl, ...project }) => ({ @@ -13,11 +13,14 @@ export const formatProjects = (projects) => }, webUrl, editPath: `${webUrl}/edit`, - actions: [ACTION_EDIT, ACTION_DELETE], + availableActions: [ACTION_EDIT, ACTION_DELETE], })); export const formatGroups = (groups) => - groups.map(({ id, ...group }) => ({ + groups.map(({ id, webUrl, ...group }) => ({ ...group, id: getIdFromGraphQLId(id), + webUrl, + editPath: `${webUrl}/-/edit`, + availableActions: [ACTION_EDIT, ACTION_DELETE], })); diff --git a/app/assets/javascripts/organizations/show/components/app.vue b/app/assets/javascripts/organizations/show/components/app.vue new file mode 100644 index 00000000000..47264d80454 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/app.vue @@ -0,0 +1,37 @@ +<script> +import OrganizationAvatar from './organization_avatar.vue'; +import GroupsAndProjects from './groups_and_projects.vue'; +import AssociationCounts from './association_counts.vue'; + +export default { + name: 'OrganizationShowApp', + components: { OrganizationAvatar, GroupsAndProjects, AssociationCounts }, + props: { + organization: { + type: Object, + required: true, + }, + groupsAndProjectsOrganizationPath: { + type: String, + required: true, + }, + associationCounts: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-py-6"> + <organization-avatar :organization="organization" /> + <association-counts + :association-counts="associationCounts" + :groups-and-projects-organization-path="groupsAndProjectsOrganizationPath" + /> + <groups-and-projects + :groups-and-projects-organization-path="groupsAndProjectsOrganizationPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/components/association_count_card.vue b/app/assets/javascripts/organizations/show/components/association_count_card.vue new file mode 100644 index 00000000000..0567f43132f --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/association_count_card.vue @@ -0,0 +1,54 @@ +<script> +import { GlIcon, GlLink, GlCard } from '@gitlab/ui'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; + +export default { + name: 'AssociationCountCard', + components: { GlIcon, GlLink, GlCard }, + props: { + title: { + type: String, + required: true, + }, + iconName: { + type: String, + required: true, + }, + count: { + type: Number, + required: true, + }, + linkHref: { + type: String, + required: true, + }, + linkText: { + type: String, + required: false, + default: __('View all'), + }, + }, + computed: { + formattedCount() { + return numberToMetricPrefix(this.count, 0); + }, + }, +}; +</script> + +<template> + <gl-card> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-center gl-text-gray-700"> + <gl-icon :name="iconName" /> + <span class="gl-ml-2">{{ title }}</span> + </div> + <gl-link :href="linkHref">{{ linkText }}</gl-link> + </div> + <span + class="gl-font-size-h-display gl-font-weight-bold gl-line-height-ratio-1000 gl-mt-2 gl-display-block" + >{{ formattedCount }}</span + > + </gl-card> +</template> diff --git a/app/assets/javascripts/organizations/show/components/association_counts.vue b/app/assets/javascripts/organizations/show/components/association_counts.vue new file mode 100644 index 00000000000..3b312924bd2 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/association_counts.vue @@ -0,0 +1,71 @@ +<script> +import { __, s__ } from '~/locale'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; +import AssociationCountCard from './association_count_card.vue'; + +export default { + name: 'AssociationCounts', + i18n: { + groups: __('Groups'), + projects: __('Projects'), + users: __('Users'), + viewAll: __('View all'), + manage: s__('Organization|Manage'), + }, + components: { AssociationCountCard }, + props: { + associationCounts: { + type: Object, + required: true, + }, + groupsAndProjectsOrganizationPath: { + type: String, + required: true, + }, + }, + computed: { + groupsLinkHref() { + return `${this.groupsAndProjectsOrganizationPath}?display=${RESOURCE_TYPE_GROUPS}`; + }, + projectsLinkHref() { + return `${this.groupsAndProjectsOrganizationPath}?display=${RESOURCE_TYPE_PROJECTS}`; + }, + associationCountCards() { + return [ + { + title: this.$options.i18n.groups, + iconName: 'group', + count: this.associationCounts.groups, + linkHref: this.groupsLinkHref, + }, + { + title: this.$options.i18n.projects, + iconName: 'project', + count: this.associationCounts.projects, + linkHref: this.projectsLinkHref, + }, + { + title: this.$options.i18n.users, + iconName: 'users', + count: this.associationCounts.users, + linkText: this.$options.i18n.manage, + // TODO: update `linkHref` prop to point to users route + // https://gitlab.com/gitlab-org/gitlab/-/issues/409313 + linkHref: '/', + }, + ]; + }, + }, +}; +</script> + +<template> + <div class="gl-display-grid gl-lg-grid-template-columns-4 gl-mt-5 gl-gap-5"> + <association-count-card + v-for="props in associationCountCards" + :key="props.title" + v-bind="props" + class="gl-w-full" + /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/components/groups_and_projects.vue b/app/assets/javascripts/organizations/show/components/groups_and_projects.vue new file mode 100644 index 00000000000..e8972f3b380 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/groups_and_projects.vue @@ -0,0 +1,110 @@ +<script> +import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import { s__, __ } from '~/locale'; +import GroupsView from '../../shared/components/groups_view.vue'; +import ProjectsView from '../../shared/components/projects_view.vue'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; +import { FILTER_FREQUENTLY_VISITED } from '../constants'; +import { buildDisplayListboxItem } from '../utils'; + +export default { + name: 'OrganizationFrontPageGroupsAndProjects', + i18n: { + displayListboxLabel: __('Display'), + viewAll: s__('Organization|View all'), + }, + displayListboxLabelId: 'display-listbox-label', + components: { GlCollapsibleListbox, GlLink }, + displayListboxItems: [ + buildDisplayListboxItem({ + filter: FILTER_FREQUENTLY_VISITED, + resourceType: RESOURCE_TYPE_PROJECTS, + text: s__('Organization|Frequently visited projects'), + }), + buildDisplayListboxItem({ + filter: FILTER_FREQUENTLY_VISITED, + resourceType: RESOURCE_TYPE_GROUPS, + text: s__('Organization|Frequently visited groups'), + }), + ], + props: { + groupsAndProjectsOrganizationPath: { + type: String, + required: true, + }, + }, + computed: { + displayListboxSelected() { + const { display } = this.$route.query; + const [{ value: fallbackSelected }] = this.$options.displayListboxItems; + + return ( + this.$options.displayListboxItems.find(({ value }) => value === display)?.value || + fallbackSelected + ); + }, + resourceTypeSelected() { + return [RESOURCE_TYPE_PROJECTS, RESOURCE_TYPE_GROUPS].find((resourceType) => + this.displayListboxSelected.endsWith(resourceType), + ); + }, + routerView() { + switch (this.resourceTypeSelected) { + case RESOURCE_TYPE_GROUPS: + return GroupsView; + + case RESOURCE_TYPE_PROJECTS: + return ProjectsView; + + default: + return ProjectsView; + } + }, + groupsAndProjectsOrganizationPathWithQueryParam() { + return `${this.groupsAndProjectsOrganizationPath}?display=${this.resourceTypeSelected}`; + }, + }, + methods: { + pushQuery(query) { + const currentQuery = this.$route.query; + + if (isEqual(currentQuery, query)) { + return; + } + + this.$router.push({ query }); + }, + onDisplayListboxSelect(display) { + this.pushQuery({ display }); + }, + }, +}; +</script> + +<template> + <div class="gl-mt-7"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div> + <label + :id="$options.displayListboxLabelId" + class="gl-display-block gl-mb-2" + data-testid="label" + >{{ $options.i18n.displayListboxLabel }}</label + > + <gl-collapsible-listbox + block + toggle-class="gl-w-30" + :selected="displayListboxSelected" + :items="$options.displayListboxItems" + :toggle-aria-labelled-by="$options.displayListboxLabelId" + @select="onDisplayListboxSelect" + /> + </div> + <gl-link class="gl-mt-5" :href="groupsAndProjectsOrganizationPathWithQueryParam">{{ + $options.i18n.viewAll + }}</gl-link> + </div> + <component :is="routerView" should-show-empty-state-buttons class="gl-mt-5" /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/components/organization_avatar.vue b/app/assets/javascripts/organizations/show/components/organization_avatar.vue new file mode 100644 index 00000000000..c57ee0ea5b5 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/organization_avatar.vue @@ -0,0 +1,71 @@ +<script> +import { GlAvatar, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { + VISIBILITY_TYPE_ICON, + ORGANIZATION_VISIBILITY_TYPE, + VISIBILITY_LEVEL_PUBLIC_STRING, +} from '~/visibility_level/constants'; + +export default { + name: 'OrganizationAvatar', + AVATAR_SHAPE_OPTION_RECT, + i18n: { + copyButtonText: s__('Organization|Copy organization ID'), + orgId: s__('Organization|Org ID'), + }, + components: { GlAvatar, GlIcon, ClipboardButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + organization: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_PUBLIC_STRING]; + }, + visibilityTooltip() { + return ORGANIZATION_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + :entity-id="organization.id" + :entity-name="organization.name" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="64" + /> + <div class="gl-ml-3"> + <div class="gl-display-flex gl-align-items-center"> + <h1 class="gl-m-0 gl-font-size-h1">{{ organization.name }}</h1> + <gl-icon + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary gl-ml-3" + /> + </div> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-text-secondary gl-font-sm" + >{{ $options.i18n.orgId }}: {{ organization.id }}</span + > + <clipboard-button + class="gl-ml-2" + category="tertiary" + size="small" + :title="$options.i18n.copyButtonText" + :text="organization.id.toString()" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/constants.js b/app/assets/javascripts/organizations/show/constants.js new file mode 100644 index 00000000000..fe29af67f6b --- /dev/null +++ b/app/assets/javascripts/organizations/show/constants.js @@ -0,0 +1 @@ +export const FILTER_FREQUENTLY_VISITED = 'frequently_visited'; diff --git a/app/assets/javascripts/organizations/show/index.js b/app/assets/javascripts/organizations/show/index.js new file mode 100644 index 00000000000..83a9c37e325 --- /dev/null +++ b/app/assets/javascripts/organizations/show/index.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import createDefaultClient from '~/lib/graphql'; +import { ORGANIZATION_ROOT_ROUTE_NAME } from '../constants'; +import resolvers from '../shared/graphql/resolvers'; +import App from './components/app.vue'; + +export const createRouter = () => { + const routes = [{ path: '/', name: ORGANIZATION_ROOT_ROUTE_NAME }]; + + const router = new VueRouter({ + routes, + base: '/', + mode: 'history', + }); + + return router; +}; + +export const initOrganizationsShow = () => { + const el = document.getElementById('js-organizations-show'); + + if (!el) return false; + + const { + dataset: { appData }, + } = el; + const { + organization, + groupsAndProjectsOrganizationPath, + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + associationCounts, + } = convertObjectPropsToCamelCase(JSON.parse(appData)); + + Vue.use(VueRouter); + const router = createRouter(); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + name: 'OrganizationShowRoot', + apolloProvider, + router, + provide: { + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + }, + render(createElement) { + return createElement(App, { + props: { organization, groupsAndProjectsOrganizationPath, associationCounts }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/show/utils.js b/app/assets/javascripts/organizations/show/utils.js new file mode 100644 index 00000000000..b4f935563aa --- /dev/null +++ b/app/assets/javascripts/organizations/show/utils.js @@ -0,0 +1,4 @@ +export const buildDisplayListboxItem = ({ filter, resourceType, text }) => ({ + text, + value: `${filter}_${resourceType}`, +}); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js index afddf78203d..7040f42398e 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js @@ -4,7 +4,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; -import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; +import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; import { apolloProvider } from './graphql/index'; import RegistryExplorer from './pages/index.vue'; import createRouter from './router'; @@ -88,7 +88,7 @@ export default () => { }); return { - attachBreadcrumb: renderBreadcrumb(router, apolloProvider, RegistryBreadcrumb), + attachBreadcrumb: () => injectVueAppBreadcrumbs(router, RegistryBreadcrumb, apolloProvider), attachMainComponent, }; }; diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index e18e6f7ed1a..6bb4a8797df 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -2,8 +2,8 @@ import { GlAlert, GlButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, @@ -18,6 +18,8 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; +import { getPageParams } from '~/packages_and_registries/dependency_proxy/utils'; +import { extractPageInfo } from '~/packages_and_registries/shared/utils'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; @@ -25,8 +27,8 @@ export default { components: { GlAlert, GlButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlSkeletonLoader, GlFormGroup, GlFormInputGroup, @@ -79,11 +81,15 @@ export default { }, computed: { queryVariables() { - return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE }; + return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE, ...this.pageParams }; }, pageInfo() { return this.group.dependencyProxyManifests?.pageInfo; }, + pageParams() { + const pageInfo = extractPageInfo(this.$route.query); + return getPageParams(pageInfo); + }, manifests() { return this.group.dependencyProxyManifests?.nodes ?? []; }, @@ -123,25 +129,10 @@ export default { }, methods: { fetchNextPage() { - this.fetchMore({ - first: GRAPHQL_PAGE_SIZE, - after: this.pageInfo?.endCursor, - }); + this.$router.push({ query: { after: this.pageInfo?.endCursor } }); }, fetchPreviousPage() { - this.fetchMore({ - first: null, - last: GRAPHQL_PAGE_SIZE, - before: this.pageInfo?.startCursor, - }); - }, - fetchMore(variables) { - this.$apollo.queries.group.fetchMore({ - variables: { ...this.queryVariables, ...variables }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, - }); + this.$router.push({ query: { before: this.pageInfo?.startCursor } }); }, async submit() { try { @@ -165,20 +156,23 @@ export default { </gl-alert> <title-area :title="$options.i18n.pageTitle"> <template #right-actions> - <gl-dropdown + <gl-disclosure-dropdown v-if="showDeleteDropdown" icon="ellipsis_v" - text="More actions" + :toggle-text="__('More actions')" :text-sr-only="true" category="tertiary" + placement="right" no-caret > - <gl-dropdown-item - v-gl-modal-directive="$options.confirmClearCacheModal" - variant="danger" - >{{ $options.i18n.clearCache }}</gl-dropdown-item - > - </gl-dropdown> + <gl-disclosure-dropdown-item v-gl-modal-directive="$options.confirmClearCacheModal"> + <template #list-item> + <span class="gl-text-red-500"> + {{ $options.i18n.clearCache }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> <gl-button v-if="canClearCache" v-gl-tooltip="$options.i18n.settingsText" @@ -198,14 +192,14 @@ export default { <gl-form-input-group id="proxy-url" readonly - :value="group.dependencyProxyImagePrefix" + :value="dependencyProxyImagePrefix" select-on-click class="gl-layout-w-limited" data-testid="proxy-url" > <template #append> <clipboard-button - :text="group.dependencyProxyImagePrefix" + :text="dependencyProxyImagePrefix" :title="$options.i18n.copyImagePrefixText" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js index 74444d2c7ec..c115898c75b 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import app from '~/packages_and_registries/dependency_proxy/app.vue'; import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql'; import Translate from '~/vue_shared/translate'; +import createRouter from './router'; Vue.use(Translate); @@ -11,10 +11,18 @@ export const initDependencyProxyApp = () => { if (!el) { return null; } - const { groupPath, groupId, noManifestsIllustration, canClearCache, settingsPath } = el.dataset; + const { + endpoint, + groupPath, + groupId, + noManifestsIllustration, + canClearCache, + settingsPath, + } = el.dataset; return new Vue({ el, apolloProvider, + router: createRouter(endpoint), provide: { groupPath, groupId, @@ -23,7 +31,7 @@ export const initDependencyProxyApp = () => { settingsPath, }, render(createElement) { - return createElement(app); + return createElement('router-view'); }, }); }; diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/router.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/router.js new file mode 100644 index 00000000000..087d8c189c4 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/router.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import App from '~/packages_and_registries/dependency_proxy/app.vue'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + const routes = [{ path: '/', name: 'dependencyProxyApp', component: App }]; + return new VueRouter({ + mode: 'history', + base, + routes, + }); +} diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/utils.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/utils.js new file mode 100644 index 00000000000..e6b97fac896 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/utils.js @@ -0,0 +1,24 @@ +import { GRAPHQL_PAGE_SIZE } from './constants'; + +const getNextPageParams = (cursor) => ({ + after: cursor, + first: GRAPHQL_PAGE_SIZE, +}); + +const getPreviousPageParams = (cursor) => ({ + first: null, + before: cursor, + last: GRAPHQL_PAGE_SIZE, +}); + +export const getPageParams = (pageInfo = {}) => { + if (pageInfo.before) { + return getPreviousPageParams(pageInfo.before); + } + + if (pageInfo.after) { + return getNextPageParams(pageInfo.after); + } + + return {}; +}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js index 6185e4c7bc6..41a5a0e3797 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js @@ -4,7 +4,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; import RegistryBreadcrumb from '~/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue'; -import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; +import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; import createRouter from './router'; import HarborRegistryExplorer from './pages/index.vue'; @@ -79,7 +79,7 @@ export default (id) => { }; return { - attachBreadcrumb: renderBreadcrumb(router, null, RegistryBreadcrumb), + attachBreadcrumb: () => injectVueAppBreadcrumbs(router, RegistryBreadcrumb), attachMainComponent, }; }; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index 0cf49b25bf2..1020cd0c533 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -6,9 +6,7 @@ import { TOKEN_TITLE_TYPE, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; -import UrlSync from '~/vue_shared/components/url_sync.vue'; -import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PackageTypeToken from './tokens/package_type_token.vue'; @@ -24,7 +22,10 @@ export default { operators: OPERATORS_IS, }, ], - components: { RegistrySearch, UrlSync, LocalStorageSync }, + components: { + LocalStorageSync, + PersistedSearch, + }, inject: ['isGroupPage'], data() { return { @@ -40,17 +41,25 @@ export default { sortableFields() { return sortableFields(this.isGroupPage); }, - parsedSorting() { - const cleanOrderBy = this.sorting?.orderBy.replace('_at', ''); - return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase(); - }, - parsedFilters() { + }, + mounted() { + // local-storage-sync does not emit `input` + // event when key is not found, so set the + // flag if it hasn't been updated + this.$nextTick(() => { + if (!this.mountRegistrySearch) { + this.mountRegistrySearch = true; + } + }); + }, + methods: { + formatFilters(filters) { const parsed = { packageName: '', packageType: undefined, }; - return this.filters.reduce((acc, filter) => { + return filters.reduce((acc, filter) => { if (filter.type === TOKEN_TYPE_TYPE && filter.value?.data) { return { ...acc, @@ -68,28 +77,17 @@ export default { return acc; }, parsed); }, - }, - mounted() { - const queryParams = getQueryParams(window.document.location.search); - const { sorting, filters } = extractFilterAndSorting(queryParams); - this.updateSorting(sorting); - this.updateFilters(filters); - this.mountRegistrySearch = true; - this.emitUpdate(); - }, - methods: { - updateFilters(newValue) { - this.filters = newValue; - }, updateSorting(newValue) { this.sorting = { ...this.sorting, ...newValue }; }, - updateSortingAndEmitUpdate(newValue) { + updateSortingFromLocalStorage(newValue) { this.updateSorting(newValue); - this.emitUpdate(); + this.mountRegistrySearch = true; }, - emitUpdate() { - this.$emit('update', { sort: this.parsedSorting, filters: this.parsedFilters }); + emitUpdate(values) { + const { filters, sorting } = values; + this.updateSorting(sorting); + this.$emit('update', { ...values, filters: this.formatFilters(filters) }); }, }, }; @@ -99,22 +97,15 @@ export default { <local-storage-sync storage-key="package_registry_list_sorting" :value="sorting" - @input="updateSorting" + @input="updateSortingFromLocalStorage" > - <url-sync> - <template #default="{ updateQuery }"> - <registry-search - v-if="mountRegistrySearch" - :filters="filters" - :sorting="sorting" - :tokens="$options.tokens" - :sortable-fields="sortableFields" - @sorting:changed="updateSortingAndEmitUpdate" - @filter:changed="updateFilters" - @filter:submit="emitUpdate" - @query:changed="updateQuery" - /> - </template> - </url-sync> + <persisted-search + v-if="mountRegistrySearch" + :sortable-fields="sortableFields" + :default-order="sorting.orderBy" + :default-sort="sorting.sort" + :tokens="$options.tokens" + @update="emitUpdate" + /> </local-storage-sync> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index a7831ef2588..b892305055c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -48,10 +48,6 @@ export default { required: false, default: false, }, - pageInfo: { - type: Object, - required: true, - }, groupSettings: { type: Object, required: false, @@ -179,11 +175,8 @@ export default { :hidden-delete="!canDeletePackages" :is-loading="isLoading" :items="list" - :pagination="pageInfo" :title="listTitle" @delete="setItemsToBeDeleted" - @prev-page="$emit('prev-page')" - @next-page="$emit('next-page')" > <template #default="{ selectItem, isSelected, item, first }"> <packages-list-row diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js index ae0f6d18d99..1a9b192e2c8 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -4,7 +4,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue'; import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; -import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; +import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; import createRouter from './router'; Vue.use(Translate); @@ -60,7 +60,7 @@ export default () => { }); return { - attachBreadcrumb: renderBreadcrumb(router, apolloProvider, RegistryBreadcrumb), + attachBreadcrumb: () => injectVueAppBreadcrumbs(router, RegistryBreadcrumb, apolloProvider), attachMainComponent, }; }; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 6de89748708..a187c7a70d2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -18,6 +18,12 @@ import DeletePackages from '~/packages_and_registries/package_registry/component import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; +import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue'; +import { + getPageParams, + getNextPageParams, + getPreviousPageParams, +} from '~/packages_and_registries/package_registry/utils'; export default { components: { @@ -28,6 +34,7 @@ export default { PackageList, PackageTitle, PackageSearch, + PersistedPagination, DeletePackages, }, directives: { @@ -39,7 +46,7 @@ export default { packagesResource: {}, sort: '', filters: {}, - mutationLoading: false, + isDeleteInProgress: false, pageParams: {}, }; }, @@ -100,7 +107,7 @@ export default { : this.$options.i18n.noResultsTitle; }, isLoading() { - return this.$apollo.queries.packagesResource.loading || this.mutationLoading; + return this.$apollo.queries.packagesResource.loading || this.isDeleteInProgress; }, refetchQueriesData() { return [ @@ -124,23 +131,16 @@ export default { historyReplaceState(cleanUrl); } }, - handleSearchUpdate({ sort, filters }) { - this.pageParams = {}; + handleSearchUpdate({ sort, filters, pageInfo }) { + this.pageParams = getPageParams(pageInfo); this.sort = sort; this.filters = { ...filters }; }, fetchNextPage() { - this.pageParams = { - first: GRAPHQL_PAGE_SIZE, - after: this.pageInfo?.endCursor, - }; + this.pageParams = getNextPageParams(this.pageInfo.endCursor); }, fetchPreviousPage() { - this.pageParams = { - first: null, - last: GRAPHQL_PAGE_SIZE, - before: this.pageInfo?.startCursor, - }; + this.pageParams = getPreviousPageParams(this.pageInfo.startCursor); }, }, i18n: { @@ -176,17 +176,14 @@ export default { <delete-packages :refetch-queries="refetchQueriesData" show-success-alert - @start="mutationLoading = true" - @end="mutationLoading = false" + @start="isDeleteInProgress = true" + @end="isDeleteInProgress = false" > <template #default="{ deletePackages }"> <package-list :group-settings="groupSettings" :list="packages.nodes" :is-loading="isLoading" - :page-info="pageInfo" - @prev-page="fetchPreviousPage" - @next-page="fetchNextPage" @delete="deletePackages" > <template #empty-state> @@ -210,5 +207,13 @@ export default { </package-list> </template> </delete-packages> + <div v-if="!isDeleteInProgress" class="gl-display-flex gl-justify-content-center"> + <persisted-pagination + class="gl-mt-3" + :pagination="pageInfo" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/utils.js b/app/assets/javascripts/packages_and_registries/package_registry/utils.js index 4ff8edb8f66..35ff3d5ea63 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/utils.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/utils.js @@ -1,6 +1,7 @@ import { capitalize } from 'lodash'; import { s__ } from '~/locale'; import { + GRAPHQL_PAGE_SIZE, PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN, PACKAGE_TYPE_NPM, @@ -46,3 +47,26 @@ export const packageTypeToTrackCategory = (type) => `UI::${capitalize(type)}Pack export const sortableFields = (isGroupPage) => SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage); + +export const getNextPageParams = (cursor) => ({ + after: cursor, + first: GRAPHQL_PAGE_SIZE, +}); + +export const getPreviousPageParams = (cursor) => ({ + first: null, + before: cursor, + last: GRAPHQL_PAGE_SIZE, +}); + +export const getPageParams = (pageInfo = {}) => { + if (pageInfo.before) { + return getPreviousPageParams(pageInfo.before); + } + + if (pageInfo.after) { + return getNextPageParams(pageInfo.after); + } + + return {}; +}; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue index dc61f3c788c..e00681f4183 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import Tracking from '~/tracking'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { @@ -16,8 +16,8 @@ const trackingLabel = 'quickstart_dropdown'; export default { components: { - GlDropdown, CodeInstruction, + GlDisclosureDropdown, }, mixins: [Tracking.mixin({ label: trackingLabel })], props: { @@ -47,14 +47,13 @@ export default { }; </script> <template> - <gl-dropdown - :text="$options.i18n.QUICK_START" + <gl-disclosure-dropdown + :toggle-text="$options.i18n.QUICK_START" variant="confirm" - right + placement="right" @shown="track('click_dropdown')" > - <!-- This li is used as a container since gl-dropdown produces a root ul, this mimics the functionality exposed by b-dropdown-form --> - <li role="presentation" class="px-2 py-1"> + <div class="gl-px-3 gl-py-2"> <code-instruction :label="$options.i18n.LOGIN_COMMAND_LABEL" :instruction="dockerLoginCommand" @@ -79,6 +78,6 @@ export default { tracking-action="click_copy_push" :tracking-label="$options.trackingLabel" /> - </li> - </gl-dropdown> + </div> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue index 95343a3a09b..529086e7f8c 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue @@ -87,6 +87,7 @@ export default { sort: this.parsedSorting, filters: this.filters, pageInfo: this.pageInfo, + sorting: this.sorting, }); }, }, diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index bda0839092e..a19c8ed5866 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import { queryToObject } from '~/lib/utils/url_utility'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -47,28 +46,3 @@ export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGr return `../commit/${pipeline.sha}`; }; - -export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) => () => { - const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li'); - const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1]; - const lastCrumb = breadCrumbEl.children[0]; - const crumbs = [lastCrumb]; - const nestedBreadcrumbEl = document.createElement('div'); - breadCrumbEl.replaceChild(nestedBreadcrumbEl, lastCrumb); - return new Vue({ - el: nestedBreadcrumbEl, - router, - apolloProvider, - components: { - RegistryBreadcrumb, - }, - render(createElement) { - return createElement('registry-breadcrumb', { - class: breadCrumbEl.className, - props: { - crumbs, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js deleted file mode 100644 index 29e92a8abad..00000000000 --- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js +++ /dev/null @@ -1,38 +0,0 @@ -import $ from 'jquery'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { truncate } from '~/lib/utils/text_utility'; - -const MAX_MESSAGE_LENGTH = 500; -const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; - -export default class AbuseReports { - constructor() { - $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage); - $(document) - .off('click', MESSAGE_CELL_SELECTOR) - .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation); - } - - truncateLongMessage() { - const $messageCellElement = $(this); - const reportMessage = $messageCellElement.text(); - if (reportMessage.length > MAX_MESSAGE_LENGTH) { - $messageCellElement.data('originalMessage', reportMessage); - $messageCellElement.data('messageTruncated', 'true'); - $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH)); - } - } - - toggleMessageTruncation() { - const $messageCellElement = $(this); - const originalMessage = $messageCellElement.data('originalMessage'); - if (!originalMessage) return; - if (parseBoolean($messageCellElement.data('messageTruncated'))) { - $messageCellElement.data('messageTruncated', 'false'); - $messageCellElement.text(originalMessage); - } else { - $messageCellElement.data('messageTruncated', 'true'); - $messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH)); - } - } -} diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index 7634f131e4d..78fef1b5531 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,10 +1,3 @@ import { initAbuseReportsApp } from '~/admin/abuse_reports'; -import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; -import UsersSelect from '~/users_select'; -import AbuseReports from './abuse_reports'; -new AbuseReports(); /* eslint-disable-line no-new */ -new UsersSelect(); /* eslint-disable-line no-new */ - -initDeprecatedRemoveRowBehavior(); initAbuseReportsApp(); diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index 8a810ca649c..d0593c82ac1 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,3 +1,4 @@ +import { initSilentModeSettings } from '~/silent_mode_settings'; import initAccountAndLimitsSection from '../account_and_limits'; import initGitpod from '../gitpod'; import initSignupRestrictions from '../signup_restrictions'; @@ -6,4 +7,5 @@ import initSignupRestrictions from '../signup_restrictions'; initAccountAndLimitsSection(); initGitpod(); initSignupRestrictions(); + initSilentModeSettings(); })(); diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql deleted file mode 100644 index 8c59230b2b8..00000000000 --- a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query getAllJobsCount($statuses: [CiJobStatus!]) { - jobs(statuses: $statuses) { - count - } -} diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 9c2a255a1a3..52e4d5dd19f 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,12 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; -import { CANCEL_JOBS_MODAL_ID } from '../components/constants'; -import CancelJobsModal from '../components/cancel_jobs_modal.vue'; -import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue'; -import cacheConfig from '../components/table/graphql/cache_config'; +import AdminJobsTableApp from '~/ci/admin/jobs_table/admin_jobs_table_app.vue'; +import cacheConfig from '~/ci/admin/jobs_table/graphql/cache_config'; Vue.use(Translate); Vue.use(VueApollo); @@ -17,35 +14,7 @@ const apolloProvider = new VueApollo({ defaultClient: client, }); -function initJobs() { - const buttonId = 'js-stop-jobs-button'; - const cancelJobsButton = document.getElementById(buttonId); - if (cancelJobsButton) { - // eslint-disable-next-line no-new - new Vue({ - el: `#js-${CANCEL_JOBS_MODAL_ID}`, - components: { - CancelJobsModal, - }, - mounted() { - cancelJobsButton.classList.remove('disabled'); - cancelJobsButton.addEventListener('click', () => { - this.$root.$emit(BV_SHOW_MODAL, CANCEL_JOBS_MODAL_ID, `#${buttonId}`); - }); - }, - render(createElement) { - return createElement(CANCEL_JOBS_MODAL_ID, { - props: { - url: cancelJobsButton.dataset.url, - modalId: CANCEL_JOBS_MODAL_ID, - }, - }); - }, - }); - } -} - -export function initAdminJobsApp() { +const initAdminJobsApp = () => { const containerEl = document.getElementById('admin-jobs-app'); if (!containerEl) return false; @@ -64,10 +33,6 @@ export function initAdminJobsApp() { return createElement(AdminJobsTableApp); }, }); -} +}; -if (gon.features.adminJobsVue) { - initAdminJobsApp(); -} else { - initJobs(); -} +initAdminJobsApp(); diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js index c14848c4798..fbfc6c07904 100644 --- a/app/assets/javascripts/pages/dashboard/groups/index/index.js +++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js @@ -1,3 +1,4 @@ +import EmptyState from '~/groups/components/empty_states/groups_dashboard_empty_state.vue'; import initGroupsList from '~/groups'; -initGroupsList(); +initGroupsList(EmptyState); diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js index 9b60b1f51a8..c6a9cf50d9a 100644 --- a/app/assets/javascripts/pages/explore/groups/index.js +++ b/app/assets/javascripts/pages/explore/groups/index.js @@ -1,8 +1,9 @@ +import EmptyState from '~/groups/components/empty_states/groups_explore_empty_state.vue'; import initGroupsList from '~/groups'; import Landing from '~/groups/landing'; function exploreGroups() { - initGroupsList(); + initGroupsList(EmptyState); const landingElement = document.querySelector('.js-explore-groups-landing'); if (!landingElement) return; const exploreGroupsLanding = new Landing( diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue index f0c4ecbe3eb..3a73d0c9f40 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue @@ -19,9 +19,14 @@ export default { <template> <bitbucket-status-table v-bind="$attrs"> <template #actions> - <gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{ - __('Reconfigure') - }}</gl-button> + <gl-button + category="secondary" + variant="confirm" + class="gl-ml-3" + data-method="post" + :href="reconfigurePath" + >{{ __('Reconfigure') }}</gl-button + > </template> </bitbucket-status-table> </template> diff --git a/app/assets/javascripts/pages/organizations/organizations/show/index.js b/app/assets/javascripts/pages/organizations/organizations/show/index.js new file mode 100644 index 00000000000..f9f9d2db692 --- /dev/null +++ b/app/assets/javascripts/pages/organizations/organizations/show/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsShow } from '~/organizations/show'; + +initOrganizationsShow(); diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index a83c4f1c0d2..a2ff45b454a 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -1,7 +1,3 @@ import { initShow } from '~/issues'; -import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initWorkItemLinks from '~/work_items/components/work_item_links'; initShow(); -initSidebarBundle(); -initWorkItemLinks(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index 0844e322de2..ead15143072 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,5 +1,5 @@ import { initFilteredSearchServiceDesk } from '~/issues'; -import { mountServiceDeskListApp } from '~/service_desk'; +import { mountServiceDeskListApp } from '~/issues/service_desk'; initFilteredSearchServiceDesk(); diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index c92958cd8c7..a2ff45b454a 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,10 +1,3 @@ import { initShow } from '~/issues'; -import { store } from '~/notes/stores'; -import { initRelatedIssues } from '~/related_issues'; -import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initWorkItemLinks from '~/work_items/components/work_item_links'; initShow(); -initSidebarBundle(store); -initRelatedIssues(); -initWorkItemLinks(); diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index eb3a24f38a8..9b5ad804750 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -1,3 +1,3 @@ -import initJobsTable from '~/jobs/components/table'; +import initJobsPage from '~/ci/jobs_page'; -initJobsTable(); +initJobsPage(); diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js index 6fef057dee0..cd83f2b7b64 100644 --- a/app/assets/javascripts/pages/projects/jobs/show/index.js +++ b/app/assets/javascripts/pages/projects/jobs/show/index.js @@ -1,3 +1,3 @@ -import initJobDetails from '~/jobs'; +import initJobDetails from '~/ci/job_details'; initJobDetails(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/branch_finder.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/branch_finder.js new file mode 100644 index 00000000000..ee84f54978a --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/branch_finder.js @@ -0,0 +1 @@ +export const findTargetBranch = async () => {}; 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 f71a1041068..d23a0615bb8 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 @@ -2,6 +2,8 @@ import Vue from 'vue'; import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor'; +import { findTargetBranch } from 'ee_else_ce/pages/projects/merge_requests/creations/new/branch_finder'; + import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; import CompareApp from '~/merge_requests/components/compare_app.vue'; @@ -13,14 +15,15 @@ if (mrNewCompareNode) { const targetCompareEl = document.getElementById('js-target-project-dropdown'); 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); // eslint-disable-next-line no-new new Vue({ el: sourceCompareEl, name: 'SourceCompareApp', provide: { currentProject: JSON.parse(sourceCompareEl.dataset.currentProject), - currentBranch: JSON.parse(sourceCompareEl.dataset.currentBranch), branchCommitPath: compareEl.dataset.sourceBranchUrl, inputs: { project: { @@ -40,20 +43,35 @@ if (mrNewCompareNode) { project: 'js-source-project', branch: 'js-source-branch gl-font-monospace', }, - branchQaSelector: 'source_branch_dropdown', + }, + methods: { + async selectedBranch(branchName) { + const targetBranchName = await findTargetBranch(branchName); + + if (targetBranchName) { + targetBranch.name = targetBranchName; + } + }, }, render(h) { - return h(CompareApp); + return h(CompareApp, { + props: { + currentBranch: currentSourceBranch, + }, + on: { + 'select-branch': this.selectedBranch, + }, + }); }, }); + const currentTargetBranch = JSON.parse(targetCompareEl.dataset.currentBranch); // eslint-disable-next-line no-new new Vue({ el: targetCompareEl, name: 'TargetCompareApp', provide: { currentProject: JSON.parse(targetCompareEl.dataset.currentProject), - currentBranch: JSON.parse(targetCompareEl.dataset.currentBranch), projectsPath: targetCompareEl.dataset.targetProjectsPath, branchCommitPath: compareEl.dataset.targetBranchUrl, inputs: { @@ -75,8 +93,17 @@ if (mrNewCompareNode) { branch: 'js-target-branch gl-font-monospace', }, }, + computed: { + currentBranch() { + if (targetBranch.name) { + return { text: targetBranch.name, value: targetBranch.name }; + } + + return currentTargetBranch; + }, + }, render(h) { - return h(CompareApp); + return h(CompareApp, { props: { currentBranch: this.currentBranch } }); }, }); } else { diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 30734f0b698..2cdbf0fb830 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -4,7 +4,7 @@ import { s__ } from '~/locale'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; import { initIssuableSidebar } from '~/issuable'; -import StatusBox from '~/issuable/components/status_box.vue'; +import MergeRequestStatusBadge from '~/merge_requests/components/merge_request_status_badge.vue'; import createDefaultClient from '~/lib/graphql'; import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; @@ -24,24 +24,24 @@ export default function initMergeRequestShow() { initMrExperienceSurvey(); const el = document.querySelector('.js-mr-status-box'); - const { iid, issuableType, projectPath } = el.dataset; - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); + const { iid, issuableType, projectPath, state } = el.dataset; + // eslint-disable-next-line no-new new Vue({ el, name: 'IssuableStatusBoxRoot', - apolloProvider, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { query: getStateQuery, iid, projectPath, }, - render(h) { - return h(StatusBox, { + render(createElement) { + return createElement(MergeRequestStatusBadge, { props: { - initialState: el.dataset.state, + initialState: state, issuableType, }, }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js index 75e308e706f..f7b522f7c85 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/page.js +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import initMrNotes from 'ee_else_ce/mr_notes'; +import { mountHeaderMetadata } from '~/merge_requests'; import StickyHeader from '~/merge_requests/components/sticky_header.vue'; -import { initIssuableHeaderWarnings } from '~/issuable'; import { start as startCodeReviewMessaging } from '~/code_review/signals'; import diffsEventHub from '~/diffs/event_hub'; import store from '~/mr_notes/stores'; @@ -24,7 +24,7 @@ export function initMrPage() { requestIdleCallback(() => { initSidebarBundle(store); - initIssuableHeaderWarnings(store); + mountHeaderMetadata(store); const el = document.getElementById('js-merge-sticky-header'); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js index a51c2e9c47b..f48c38b776f 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js @@ -1,8 +1,3 @@ import initPipelineSchedulesFormApp from '~/ci/pipeline_schedules/mount_pipeline_schedules_form_app'; -import initForm from '../shared/init_form'; -if (gon.features?.pipelineSchedulesVue) { - initPipelineSchedulesFormApp('#pipeline-schedules-form-edit', true); -} else { - initForm(); -} +initPipelineSchedulesFormApp('#pipeline-schedules-form-edit', true); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index 4bdbb70d942..0eff9110412 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,75 +1,3 @@ -import Vue from 'vue'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import initPipelineSchedulesApp from '~/ci/pipeline_schedules/mount_pipeline_schedules_app'; -import PipelineSchedulesTakeOwnershipModalLegacy from '~/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue'; -import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; -function initPipelineSchedulesCallout() { - const el = document.getElementById('pipeline-schedules-callout'); - - if (!el) { - return; - } - - const { docsUrl, illustrationUrl } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - name: 'PipelineSchedulesCalloutRoot', - provide: { - docsUrl, - illustrationUrl, - }, - render(createElement) { - return createElement(PipelineSchedulesCallout); - }, - }); -} - -// TODO: move take ownership feature into new Vue app -// located in directory app/assets/javascripts/pipeline_schedules/components -function initTakeownershipModal() { - const modalId = 'pipeline-take-ownership-modal'; - const buttonSelector = 'js-take-ownership-button'; - const el = document.getElementById(modalId); - const takeOwnershipButtons = document.querySelectorAll(`.${buttonSelector}`); - - if (!el) { - return; - } - - // eslint-disable-next-line no-new - new Vue({ - el, - data() { - return { - url: '', - }; - }, - mounted() { - takeOwnershipButtons.forEach((button) => { - button.addEventListener('click', () => { - const { url } = button.dataset; - - this.url = url; - this.$root.$emit(BV_SHOW_MODAL, modalId, `.${buttonSelector}`); - }); - }); - }, - render(createElement) { - return createElement(PipelineSchedulesTakeOwnershipModalLegacy, { - props: { - ownershipUrl: this.url, - }, - }); - }, - }); -} - -if (gon.features?.pipelineSchedulesVue) { - initPipelineSchedulesApp(); -} else { - initPipelineSchedulesCallout(); - initTakeownershipModal(); -} +initPipelineSchedulesApp(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js index d8ba7bbd752..672e3d014bd 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js @@ -1,8 +1,3 @@ import initPipelineSchedulesFormApp from '~/ci/pipeline_schedules/mount_pipeline_schedules_form_app'; -import initForm from '../shared/init_form'; -if (gon.features?.pipelineSchedulesVue) { - initPipelineSchedulesFormApp('#pipeline-schedules-form-new'); -} else { - initForm(); -} +initPipelineSchedulesFormApp('#pipeline-schedules-form-new'); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 5f6a73782c3..642fd56eab1 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -69,7 +69,8 @@ export default { formattedTime() { if (this.randomHour > 12) { return `${this.randomHour - 12}:00pm`; - } else if (this.randomHour === 12) { + } + if (this.randomHour === 12) { return `12:00pm`; } return `${this.randomHour}:00am`; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue deleted file mode 100644 index b3ad50f395b..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import Vue from 'vue'; -import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; -import Translate from '~/vue_shared/translate'; - -Vue.use(Translate); - -const cookieKey = 'pipeline_schedules_callout_dismissed'; - -export default { - name: 'PipelineSchedulesCallout', - components: { - GlButton, - }, - inject: ['docsUrl', 'illustrationUrl'], - data() { - return { - calloutDismissed: parseBoolean(getCookie(cookieKey)), - }; - }, - methods: { - dismissCallout() { - this.calloutDismissed = true; - setCookie(cookieKey, this.calloutDismissed); - }, - }, -}; -</script> -<template> - <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout"> - <div class="bordered-box landing content-block gl-p-5!" data-testid="innerContent"> - <gl-button - category="tertiary" - icon="close" - :aria-label="__('Dismiss')" - class="gl-absolute gl-top-2 gl-right-2" - @click="dismissCallout" - /> - <div class="svg-content"> - <img :src="illustrationUrl" /> - </div> - <div class="user-callout-copy"> - <h4>{{ __('Scheduling Pipelines') }}</h4> - <p> - {{ - __(`The pipelines schedule runs pipelines in the future, -repeatedly, for specific branches or tags. -Those scheduled pipelines will inherit limited project access based on their associated user.`) - }} - </p> - <p> - {{ __('Learn more in the') }} - <a :href="docsUrl" target="_blank" rel="nofollow"> - {{ __('pipeline schedules documentation') }}</a - >. - <!-- oneline to prevent extra space before period --> - </p> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js deleted file mode 100644 index 8440d0e77cd..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ /dev/null @@ -1,94 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import { __ } from '~/locale'; -import RefSelector from '~/ref/components/ref_selector.vue'; -import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; -import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; -import GlFieldErrors from '~/gl_field_errors'; -import Translate from '~/vue_shared/translate'; -import { initTimezoneDropdown } from '../../../profiles/init_timezone_dropdown'; -import IntervalPatternInput from './components/interval_pattern_input.vue'; - -Vue.use(Translate); - -function initIntervalPatternInput() { - const intervalPatternMount = document.getElementById('interval-pattern-input'); - const initialCronInterval = intervalPatternMount?.dataset?.initialInterval; - const dailyLimit = intervalPatternMount.dataset?.dailyLimit; - - return new Vue({ - el: intervalPatternMount, - components: { - IntervalPatternInput, - }, - render(createElement) { - return createElement('interval-pattern-input', { - props: { - initialCronInterval, - dailyLimit, - }, - }); - }, - }); -} - -function getEnabledRefTypes() { - return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; -} - -function initTargetRefDropdown() { - const $refField = document.getElementById('schedule_ref'); - const el = document.querySelector('.js-target-ref-dropdown'); - const { projectId, defaultBranch } = el.dataset; - - if (!$refField.value) { - $refField.value = defaultBranch; - } - - const refDropdown = new Vue({ - el, - render(h) { - return h(RefSelector, { - props: { - enabledRefTypes: getEnabledRefTypes(), - projectId, - value: $refField.value, - useSymbolicRefNames: true, - translations: { - dropdownHeader: __('Select target branch or tag'), - }, - }, - class: 'gl-w-full', - }); - }, - }); - - refDropdown.$children[0].$on('input', (newRef) => { - $refField.value = newRef; - }); - - return refDropdown; -} - -export default () => { - /* Most of the form is written in haml, but for fields with more complex behaviors, - * you should mount individual Vue components here. If at some point components need - * to share state, it may make sense to refactor the whole form to Vue */ - - initIntervalPatternInput(); - - // Initialize non-Vue JS components in the form - - const formElement = document.getElementById('new-pipeline-schedule-form'); - - gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement); - - initTargetRefDropdown(); - - setupNativeFormVariableList({ - container: $('.js-ci-variable-list-section'), - formField: 'schedule', - }); -}; - -initTimezoneDropdown(); diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index 63b1f2bf975..a0ddf96ede2 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -1,3 +1,3 @@ -import { initPipelinesIndex } from '~/pipelines/pipelines_index'; +import { initPipelinesIndex } from '~/ci/pipeline_details/pipelines_index'; initPipelinesIndex(); diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js index d3f46b7e025..225479c5194 100644 --- a/app/assets/javascripts/pages/projects/pipelines/show/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js @@ -1,4 +1,4 @@ -import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; +import initPipelineDetails from '~/ci/pipeline_details/pipeline_details_bundle'; import initPipelines from '../init_pipelines'; initPipelines(); 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 4a5d5580c08..dce40c1f322 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 @@ -12,7 +12,6 @@ import initSettingsPanels from '~/settings_panels'; import { initTokenAccess } from '~/token_access'; import { initCiSecureFiles } from '~/ci_secure_files'; import initDeployTokens from '~/deploy_tokens'; -import { initProjectRunners } from '~/ci/runner/project_runners'; import { initProjectRunnersRegistrationDropdown } from '~/ci/runner/project_runners/register'; import { initGeneralPipelinesOptions } from '~/ci_settings_general_pipeline'; @@ -45,7 +44,6 @@ initDeployFreeze(); initSettingsPipelinesTriggers(); initArtifactsSettings(); -initProjectRunners(); initProjectRunnersRegistrationDropdown(); initSharedRunnersToggle(); initRefSwitcherBadges(); diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index c43a0eb597c..bee0731d711 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,13 +1,11 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; - import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert'; import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; -import Star from '~/projects/star'; +import { initStarButton } from '~/projects/project_star_button'; import initTerraformNotification from '~/projects/terraform_notification'; import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; - import initForksButton from '~/forks/init_forks_button'; // Project show page loads different overview content based on user preferences @@ -46,7 +44,7 @@ initClustersDeprecationAlert(); initTerraformNotification(); initReadMore(); -new Star(); // eslint-disable-line no-new +initStarButton(); if (document.querySelector('.js-autodevops-banner')) { import(/* webpackChunkName: 'userCallOut' */ '~/user_callout') diff --git a/app/assets/javascripts/pages/projects/tracing/index/index.js b/app/assets/javascripts/pages/projects/tracing/index/index.js deleted file mode 100644 index 64ca303f8ba..00000000000 --- a/app/assets/javascripts/pages/projects/tracing/index/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import { initSimpleApp } from '~/helpers/init_simple_app_helper'; -import ListIndex from '~/tracing/list_index.vue'; - -initSimpleApp('#js-tracing', ListIndex); diff --git a/app/assets/javascripts/pages/projects/tracing/show/index.js b/app/assets/javascripts/pages/projects/tracing/show/index.js deleted file mode 100644 index 107c004aa5f..00000000000 --- a/app/assets/javascripts/pages/projects/tracing/show/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import { initSimpleApp } from '~/helpers/init_simple_app_helper'; -import DetailsIndex from '~/tracing/details_index.vue'; - -initSimpleApp('#js-tracing-details', DetailsIndex); diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 5bef7e6e322..b53e2709f83 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -67,7 +67,8 @@ export default { metricDetailsLabel() { if (this.metricDetails.duration && this.metricDetails.calls) { return `${this.metricDetails.duration} / ${this.metricDetails.calls}`; - } else if (this.metricDetails.calls) { + } + if (this.metricDetails.calls) { return this.metricDetails.calls; } diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index e7f2662ae09..cea01852630 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -19,13 +19,11 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-namespace-storage-alert', '.js-web-hook-disabled-callout', '.js-merge-request-settings-callout', - '.js-ultimate-feature-removal-banner', '.js-geo-enable-hashed-storage-callout', '.js-geo-migrate-hashed-storage-callout', '.js-unlimited-members-during-trial-alert', '.js-branch-rules-info-callout', '.js-new-navigation-callout', - '.js-code-suggestions-third-party-callout', '.js-namespace-over-storage-users-combined-alert', ]; diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js deleted file mode 100644 index 578ff498358..00000000000 --- a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - props: { - hasUpstream: { - type: Boolean, - required: false, - default: false, - }, - }, - methods: { - buildConnnectorClass(index) { - return index === 0 && (!this.isFirstColumn || this.hasUpstream) ? 'left-connector' : ''; - }, - }, -}; diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index e21f8557d68..947bf7acd5c 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -30,7 +30,6 @@ export default class Profile { bindEvents() { $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); - $('#user_email_opted_in').on('change', this.submitForm); $('#user_notified_of_own_activity').on('change', this.submitForm); this.form.on('submit', this.onSubmitForm); } diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 6ff9bd7390f..b7355b909a1 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -2,16 +2,13 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql'; import { formatStages } from '../utils'; import { COMMIT_BOX_POLL_INTERVAL } from '../constants'; diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue index 71f53613a3b..ccecc914cf1 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue @@ -2,10 +2,7 @@ import { GlLoadingIcon, GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { createAlert } from '~/alert'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import getLatestPipelineStatusQuery from '../graphql/queries/get_latest_pipeline_status.query.graphql'; import { COMMIT_BOX_POLL_INTERVAL, PIPELINE_STATUS_FETCH_ERROR } from '../constants'; diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue deleted file mode 100644 index 034bae3066d..00000000000 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue +++ /dev/null @@ -1,156 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; - -export default { - components: { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - }, - props: { - refsProjectPath: { - type: String, - required: true, - }, - revisionText: { - type: String, - required: true, - }, - paramsName: { - type: String, - required: true, - }, - paramsBranch: { - type: String, - required: false, - default: null, - }, - }, - data() { - return { - branches: [], - tags: [], - loading: true, - searchTerm: '', - selectedRevision: this.getDefaultBranch(), - }; - }, - computed: { - filteredBranches() { - return this.branches.filter((branch) => - branch.toLowerCase().includes(this.searchTerm.toLowerCase()), - ); - }, - hasFilteredBranches() { - return this.filteredBranches.length; - }, - filteredTags() { - return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase())); - }, - hasFilteredTags() { - return this.filteredTags.length; - }, - }, - watch: { - paramsBranch(newBranch) { - this.setSelectedRevision(newBranch); - }, - }, - mounted() { - this.fetchBranchesAndTags(); - }, - methods: { - fetchBranchesAndTags() { - const endpoint = this.refsProjectPath; - - return axios - .get(endpoint) - .then(({ data }) => { - this.branches = data.Branches || []; - this.tags = data.Tags || []; - }) - .catch(() => { - createAlert({ - message: `${s__( - 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.', - )}`, - }); - }) - .finally(() => { - this.loading = false; - }); - }, - getDefaultBranch() { - return this.paramsBranch || s__('CompareRevisions|Select branch/tag'); - }, - onClick(revision) { - this.setSelectedRevision(revision); - }, - onSearchEnter() { - this.setSelectedRevision(this.searchTerm); - }, - setSelectedRevision(revision) { - this.selectedRevision = revision || s__('CompareRevisions|Select branch/tag'); - this.$emit('selectRevision', { direction: this.paramsName, revision }); - }, - }, -}; -</script> - -<template> - <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`"> - <div class="input-group inline-input-group"> - <span class="input-group-prepend"> - <div class="input-group-text"> - {{ revisionText }} - </div> - </span> - <input type="hidden" :name="paramsName" :value="selectedRevision" /> - <gl-dropdown - class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace" - toggle-class="form-control compare-dropdown-toggle gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!" - :text="selectedRevision" - header-text="Select Git revision" - :loading="loading" - > - <template #header> - <gl-search-box-by-type - v-model.trim="searchTerm" - :placeholder="s__('CompareRevisions|Filter by Git revision')" - @keyup.enter="onSearchEnter" - /> - </template> - <gl-dropdown-section-header v-if="hasFilteredBranches"> - {{ s__('CompareRevisions|Branches') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="(branch, index) in filteredBranches" - :key="`branch${index}`" - is-check-item - :is-checked="selectedRevision === branch" - data-testid="branches-dropdown-item" - @click="onClick(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-section-header v-if="hasFilteredTags"> - {{ s__('CompareRevisions|Tags') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="(tag, index) in filteredTags" - :key="`tag${index}`" - is-check-item - :is-checked="selectedRevision === tag" - data-testid="tags-dropdown-item" - @click="onClick(tag)" - > - {{ tag }} - </gl-dropdown-item> - </gl-dropdown> - </div> - </div> -</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js index c13824a9952..dcec77ac6a4 100644 --- a/app/assets/javascripts/projects/pipelines/charts/constants.js +++ b/app/assets/javascripts/projects/pipelines/charts/constants.js @@ -21,5 +21,5 @@ export const LOAD_PIPELINES_FAILURE = 'load_analytics_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; export const SNOWPLOW_LABEL = 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly'; -export const SNOWPLOW_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-0'; +export const SNOWPLOW_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-1'; export const SNOWPLOW_DATA_SOURCE = 'redis_hll'; diff --git a/app/assets/javascripts/projects/project_star_button.js b/app/assets/javascripts/projects/project_star_button.js new file mode 100644 index 00000000000..06f982b500d --- /dev/null +++ b/app/assets/javascripts/projects/project_star_button.js @@ -0,0 +1,46 @@ +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { spriteIcon } from '~/lib/utils/common_utils'; +import { __, s__ } from '~/locale'; + +export function initStarButton(containerSelector = '.project-home-panel') { + const container = document.querySelector(containerSelector); + const starToggle = container?.querySelector('.toggle-star'); + + if (!starToggle) { + return; + } + + starToggle.addEventListener('click', function toggleStarClickCallback() { + const starSpan = starToggle.querySelector('span'); + const starIcon = starToggle.querySelector('svg'); + const iconClasses = Array.from(starIcon.classList.values()); + + axios + .post(starToggle.dataset.endpoint) + .then(({ data }) => { + const isStarred = starSpan.classList.contains('starred'); + starToggle.parentNode.querySelector('.count').textContent = data.star_count; + + if (isStarred) { + starSpan.classList.remove('starred'); + starSpan.textContent = s__('StarProject|Star'); + starIcon.remove(); + // eslint-disable-next-line no-unsanitized/method + starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses)); + } else { + starSpan.classList.add('starred'); + starSpan.textContent = __('Unstar'); + starIcon.remove(); + + // eslint-disable-next-line no-unsanitized/method + starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses)); + } + }) + .catch(() => + createAlert({ + message: __('Star toggle failed. Try again later.'), + }), + ); + }); +} diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js deleted file mode 100644 index 75d72f719e5..00000000000 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ /dev/null @@ -1,611 +0,0 @@ -/* eslint-disable no-underscore-dangle, class-methods-use-this */ -import { escape, find, countBy } from 'lodash'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/alert'; -import { n__, s__, __, sprintf } from '~/locale'; -import { renderAvatar } from '~/helpers/avatar_helper'; -import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api'; -import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; - -export default class AccessDropdown { - constructor(options) { - const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options; - this.options = options; - this.hasLicense = hasLicense; - this.groups = []; - this.accessLevel = accessLevel; - this.accessLevelsData = accessLevelsData.roles; - this.$dropdown = $dropdown; - this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`); - this.defaultLabel = this.$dropdown.data('defaultLabel'); - - this.setSelectedItems([]); - this.persistPreselectedItems(); - - this.noOneObj = this.accessLevelsData.find((level) => level.id === ACCESS_LEVEL_NONE); - - this.initDropdown(); - } - - initDropdown() { - const { onSelect, onHide } = this.options; - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.getData.bind(this), - selectable: true, - filterable: true, - filterRemote: true, - multiSelect: this.$dropdown.hasClass('js-multiselect'), - renderRow: this.renderRow.bind(this), - toggleLabel: this.toggleLabel.bind(this), - hidden() { - if (onHide) { - onHide(); - } - }, - clicked: (options) => { - const { $el, e } = options; - const item = options.selectedObj; - const fossWithMergeAccess = !this.hasLicense && this.accessLevel === ACCESS_LEVELS.MERGE; - - e.preventDefault(); - - if (fossWithMergeAccess) { - // We're not multiselecting quite yet in "Merge" access dropdown, on FOSS: - // remove all preselected items before selecting this item - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37499 - this.accessLevelsData.forEach((level) => { - this.removeSelectedItem(level); - }); - } - - if ($el.is('.is-active')) { - if (this.noOneObj) { - if (item.id === this.noOneObj.id && !fossWithMergeAccess) { - // remove all others selected items - this.accessLevelsData.forEach((level) => { - if (level.id !== item.id) { - this.removeSelectedItem(level); - } - }); - - // remove selected item visually - this.$wrap.find(`.item-${item.type}`).removeClass('is-active'); - } else { - const $noOne = this.$wrap.find( - `.is-active.item-${item.type}[data-role-id="${this.noOneObj.id}"]`, - ); - if ($noOne.length) { - $noOne.removeClass('is-active'); - this.removeSelectedItem(this.noOneObj); - } - } - } - - // make element active right away - $el.addClass(`is-active item-${item.type}`); - - // Add "No one" - this.addSelectedItem(item); - } else { - this.removeSelectedItem(item); - } - - if (onSelect) { - onSelect(item, $el, this); - } - }, - }); - - this.$dropdown.find('.dropdown-toggle-text').text(this.toggleLabel()); - } - - persistPreselectedItems() { - const itemsToPreselect = this.$dropdown.data('preselectedItems'); - - if (!itemsToPreselect || !itemsToPreselect.length) { - return; - } - - const persistedItems = itemsToPreselect.map((item) => { - const persistedItem = { ...item }; - persistedItem.persisted = true; - return persistedItem; - }); - - this.setSelectedItems(persistedItems); - } - - setSelectedItems(items = []) { - this.items = items; - } - - getSelectedItems() { - return this.items.filter((item) => !item._destroy); - } - - getAllSelectedItems() { - return this.items; - } - - // Return dropdown as input data ready to submit - getInputData() { - const selectedItems = this.getAllSelectedItems(); - - const accessLevels = selectedItems.map((item) => { - const obj = {}; - - if (typeof item.id !== 'undefined') { - obj.id = item.id; - } - - if (typeof item._destroy !== 'undefined') { - obj._destroy = item._destroy; - } - - if (item.type === LEVEL_TYPES.ROLE) { - obj.access_level = item.access_level; - } else if (item.type === LEVEL_TYPES.USER) { - obj.user_id = item.user_id; - } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { - obj.deploy_key_id = item.deploy_key_id; - } else if (item.type === LEVEL_TYPES.GROUP) { - obj.group_id = item.group_id; - } - - return obj; - }); - - return accessLevels; - } - - addSelectedItem(selectedItem) { - let itemToAdd = {}; - - let index = -1; - let alreadyAdded = false; - const selectedItems = this.getAllSelectedItems(); - - // Compare IDs based on selectedItem.type - selectedItems.forEach((item, i) => { - let comparator; - switch (selectedItem.type) { - case LEVEL_TYPES.ROLE: - comparator = LEVEL_ID_PROP.ROLE; - // If the item already exists, just use it - if (item[comparator] === selectedItem.id) { - alreadyAdded = true; - } - break; - case LEVEL_TYPES.GROUP: - comparator = LEVEL_ID_PROP.GROUP; - break; - case LEVEL_TYPES.DEPLOY_KEY: - comparator = LEVEL_ID_PROP.DEPLOY_KEY; - break; - case LEVEL_TYPES.USER: - comparator = LEVEL_ID_PROP.USER; - break; - default: - break; - } - - if (selectedItem.id === item[comparator]) { - index = i; - } - }); - - if (alreadyAdded) { - return; - } - - if (index !== -1 && selectedItems[index]._destroy) { - delete selectedItems[index]._destroy; - return; - } - - itemToAdd.type = selectedItem.type; - - if (selectedItem.type === LEVEL_TYPES.USER) { - itemToAdd = { - user_id: selectedItem.id, - name: selectedItem.name || '_name1', - username: selectedItem.username || '_username1', - avatar_url: selectedItem.avatar_url || '_avatar_url1', - type: LEVEL_TYPES.USER, - }; - } else if (selectedItem.type === LEVEL_TYPES.ROLE) { - itemToAdd = { - access_level: selectedItem.id, - type: LEVEL_TYPES.ROLE, - }; - } else if (selectedItem.type === LEVEL_TYPES.GROUP) { - itemToAdd = { - group_id: selectedItem.id, - type: LEVEL_TYPES.GROUP, - }; - } else if (selectedItem.type === LEVEL_TYPES.DEPLOY_KEY) { - itemToAdd = { - deploy_key_id: selectedItem.id, - type: LEVEL_TYPES.DEPLOY_KEY, - }; - } - - this.items.push(itemToAdd); - } - - removeSelectedItem(itemToDelete) { - let index = -1; - const selectedItems = this.getAllSelectedItems(); - - // To find itemToDelete on selectedItems, first we need the index - selectedItems.every((item, i) => { - if (item.type !== itemToDelete.type) { - return true; - } - - if ( - (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) || - (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) || - (item.type === LEVEL_TYPES.DEPLOY_KEY && item.deploy_key_id === itemToDelete.id) || - (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) - ) { - index = i; - } - - // Break once we have index set - return !(index > -1); - }); - - // if ItemToDelete is not really selected do nothing - if (index === -1) { - return; - } - - if (selectedItems[index].persisted) { - // If we toggle an item that has been already marked with _destroy - if (selectedItems[index]._destroy) { - delete selectedItems[index]._destroy; - } else { - selectedItems[index]._destroy = '1'; - } - } else { - selectedItems.splice(index, 1); - } - } - - toggleLabel() { - const currentItems = this.getSelectedItems(); - const $dropdownToggleText = this.$dropdown.find('.dropdown-toggle-text'); - - if (currentItems.length === 0) { - $dropdownToggleText.addClass('is-default'); - return this.defaultLabel; - } - - $dropdownToggleText.removeClass('is-default'); - - if (currentItems.length === 1 && currentItems[0].type === LEVEL_TYPES.ROLE) { - const roleData = this.accessLevelsData.find( - (data) => data.id === currentItems[0].access_level, - ); - return roleData.text; - } - - const labelPieces = []; - const counts = countBy(currentItems, (item) => item.type); - - if (counts[LEVEL_TYPES.ROLE] > 0) { - labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE])); - } - - if (counts[LEVEL_TYPES.USER] > 0) { - labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER])); - } - - if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) { - labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY])); - } - - if (counts[LEVEL_TYPES.GROUP] > 0) { - labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); - } - - return labelPieces.join(', '); - } - - getData(query, callback) { - if (this.hasLicense) { - Promise.all([ - getDeployKeys(query), - getUsers(query), - this.groupsData ? Promise.resolve(this.groupsData) : getGroups(), - ]) - .then(([deployKeysResponse, usersResponse, groupsResponse]) => { - this.groupsData = groupsResponse; - callback( - this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data), - ); - }) - .catch(() => { - createAlert({ message: __('Failed to load groups, users and deploy keys.') }); - }); - } else { - getDeployKeys(query) - .then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data))) - .catch(() => createAlert({ message: __('Failed to load deploy keys.') })); - } - } - - consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) { - let consolidatedData = []; - - // ID property is handled differently locally from the server - // - // For Groups - // In dropdown: `id` - // For submit: `group_id` - // - // For Roles - // In dropdown: `id` - // For submit: `access_level` - // - // For Users - // In dropdown: `id` - // For submit: `user_id` - // - // For Deploy Keys - // In dropdown: `id` - // For submit: `deploy_key_id` - - /* - * Build roles - */ - const roles = this.accessLevelsData.map((level) => { - /* eslint-disable no-param-reassign */ - // This re-assignment is intentional as - // level.type property is being used in removeSelectedItem() - // for comparision, and accessLevelsData is provided by - // gon.create_access_levels which doesn't have `type` included. - // See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823 - level.type = LEVEL_TYPES.ROLE; - return level; - }); - - if (roles.length) { - consolidatedData = consolidatedData.concat( - [{ type: 'header', content: s__('AccessDropdown|Roles') }], - roles, - ); - } - - if (this.hasLicense) { - const map = []; - const selectedItems = this.getSelectedItems(); - /* - * Build groups - */ - const groups = groupsResponse.map((group) => ({ - ...group, - type: LEVEL_TYPES.GROUP, - })); - - /* - * Build users - */ - const users = selectedItems - .filter((item) => item.type === LEVEL_TYPES.USER) - .map((item) => { - // Save identifiers for easy-checking more later - map.push(LEVEL_TYPES.USER + item.user_id); - - return { - id: item.user_id, - name: item.name, - username: item.username, - avatar_url: item.avatar_url, - type: LEVEL_TYPES.USER, - }; - }); - - // Has to be checked against server response - // because the selected item can be in filter results - if (gon.current_project_id) { - usersResponse.forEach((response) => { - // Add is it has not been added - if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) { - const user = { ...response }; - user.type = LEVEL_TYPES.USER; - users.push(user); - } - }); - } - - if (groups.length) { - if (roles.length) { - consolidatedData = consolidatedData.concat([{ type: 'divider' }]); - } - - consolidatedData = consolidatedData.concat( - [{ type: 'header', content: s__('AccessDropdown|Groups') }], - groups, - ); - } - - if (users.length) { - consolidatedData = consolidatedData.concat( - [{ type: 'divider' }], - [{ type: 'header', content: s__('AccessDropdown|Users') }], - users, - ); - } - } - - const deployKeys = deployKeysResponse.map((response) => { - const { - id, - fingerprint, - fingerprint_sha256: fingerprintSha256, - title, - owner: { avatar_url, name, username }, - } = response; - - const availableFingerprint = fingerprintSha256 || fingerprint; - const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`; - - return { - id, - title: title.concat(' ', shortFingerprint), - avatar_url, - fullname: name, - username, - type: LEVEL_TYPES.DEPLOY_KEY, - }; - }); - - if (this.accessLevel === ACCESS_LEVELS.PUSH) { - if (deployKeys.length) { - consolidatedData = consolidatedData.concat( - [{ type: 'divider' }], - [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }], - deployKeys, - ); - } - } - - if (this.accessLevel === ACCESS_LEVELS.CREATE && deployKeys.length) { - consolidatedData = consolidatedData.concat( - [{ type: 'divider' }], - [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }], - deployKeys, - ); - } - - return consolidatedData; - } - - renderRow(item) { - let criteria = {}; - let groupRowEl; - - // Dectect if the current item is already saved so we can add - // the `is-active` class so the item looks as marked - switch (item.type) { - case LEVEL_TYPES.USER: - criteria = { user_id: item.id }; - break; - case LEVEL_TYPES.ROLE: - criteria = { access_level: item.id }; - break; - case LEVEL_TYPES.DEPLOY_KEY: - criteria = { deploy_key_id: item.id }; - break; - case LEVEL_TYPES.GROUP: - criteria = { group_id: item.id }; - break; - default: - break; - } - - const isActive = find(this.getSelectedItems(), criteria) ? 'is-active' : ''; - - switch (item.type) { - case LEVEL_TYPES.USER: - groupRowEl = this.userRowHtml(item, isActive); - break; - case LEVEL_TYPES.ROLE: - groupRowEl = this.roleRowHtml(item, isActive); - break; - case LEVEL_TYPES.DEPLOY_KEY: - groupRowEl = - this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE - ? this.deployKeyRowHtml(item, isActive) - : ''; - - break; - case LEVEL_TYPES.GROUP: - groupRowEl = this.groupRowHtml(item, isActive); - break; - default: - groupRowEl = ''; - break; - } - - return groupRowEl; - } - - userRowHtml(user, isActive) { - const isActiveClass = isActive || ''; - const avatarEl = renderAvatar(user, { - sizeClass: 's32', - }); - - return ` - <li> - <a href="#" class="${isActiveClass}"> - <div class="gl-avatar-labeled"> - ${avatarEl} - <div> - <strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong> - <span class="gl-avatar-labeled-sublabel dropdown-menu-user-username">@${ - user.username - }</span> - </div> - </div> - </a> - </li> - `; - } - - deployKeyRowHtml(key, isActive) { - const isActiveClass = isActive || ''; - - return ` - <li> - <a href="#" class="${isActiveClass}"> - <strong>${escape(key.title)}</strong> - <p> - ${sprintf( - __('Owned by %{image_tag}'), - { - image_tag: `<img src="${key.avatar_url}" class="avatar avatar-inline s26" width="30">`, - }, - false, - )} - <strong class="dropdown-menu-user-full-name gl-display-inline">${escape( - key.fullname, - )}</strong> - <span class="dropdown-menu-user-username gl-display-inline">${key.username}</span> - </p> - </a> - </li> - `; - } - - groupRowHtml(group, isActive) { - const isActiveClass = isActive || ''; - const avatarEl = group.avatar_url - ? `<img src="${group.avatar_url}" class="avatar avatar-inline" width="30">` - : ''; - - return ` - <li> - <a href="#" class="${isActiveClass}"> - ${avatarEl} - <span class="dropdown-menu-group-groupname">${group.name}</span> - </a> - </li> - `; - } - - roleRowHtml(role, isActive) { - const isActiveClass = isActive || ''; - - return ` - <li> - <a href="#" class="${isActiveClass} item-${role.type}" data-role-id="${role.id}"> - ${escape(role.text)} - </a> - </li> - `; - } -} 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..b886bf43b57 100644 --- a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js +++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js @@ -1,7 +1,9 @@ +import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import { ACCESS_LEVEL_DEVELOPER_INTEGER } from '~/access_level/constants'; -const USERS_PATH = '/-/autocomplete/users.json'; const GROUPS_PATH = '/-/autocomplete/project_groups.json'; +const USERS_PATH = '/-/autocomplete/users.json'; const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json'; const buildUrl = (urlRoot, url) => { @@ -26,10 +28,14 @@ export const getUsers = (query, states) => { }; export const getGroups = () => { - return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), { - params: { - project_id: gon.current_project_id, - }, + if (gon.current_project_id) { + return Api.projectGroups(gon.current_project_id, { + with_shared: true, + shared_min_access_level: ACCESS_LEVEL_DEVELOPER_INTEGER, + }); + } + return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH)).then(({ data }) => { + return data; }); }; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index a2e4827cbfa..ca24e948f69 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -12,13 +12,14 @@ import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } fro import { createAlert } from '~/alert'; import { __, s__, n__ } from '~/locale'; import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api'; -import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants'; +import { LEVEL_TYPES, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from '../constants'; export const i18n = { - selectUsers: s__('ProtectedEnvironment|Select users'), + defaultLabel: s__('AccessDropdown|Select'), rolesSectionHeader: s__('AccessDropdown|Roles'), groupsSectionHeader: s__('AccessDropdown|Groups'), usersSectionHeader: s__('AccessDropdown|Users'), + noRole: s__('AccessDropdown|No role'), deployKeysSectionHeader: s__('AccessDropdown|Deploy Keys'), ownedBy: __('Owned by %{image_tag}'), }; @@ -51,7 +52,7 @@ export default { label: { type: String, required: false, - default: i18n.selectUsers, + default: i18n.defaultLabel, }, disabled: { type: Boolean, @@ -68,6 +69,31 @@ export default { required: false, default: () => [], }, + toggleClass: { + type: String, + required: false, + default: '', + }, + searchEnabled: { + type: Boolean, + required: false, + default: true, + }, + block: { + type: Boolean, + required: false, + default: false, + }, + testId: { + type: String, + required: false, + default: undefined, + }, + showUsers: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -96,6 +122,9 @@ export default { this.deployKeys.length ); }, + isAccessesLevelNoneSelected() { + return this.selected.role[0].id === ACCESS_LEVEL_NONE; + }, toggleLabel() { const counts = Object.entries(this.selected).reduce((acc, [key, value]) => { acc[key] = value.length; @@ -115,7 +144,11 @@ export default { const labelPieces = []; if (counts[LEVEL_TYPES.ROLE] > 0) { - labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE])); + if (this.isAccessesLevelNoneSelected) { + labelPieces.push(this.$options.i18n.noRole); + } else { + labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE])); + } } if (counts[LEVEL_TYPES.USER] > 0) { @@ -132,8 +165,14 @@ export default { return labelPieces.join(', ') || this.label; }, - toggleClass() { - return this.toggleLabel === this.label ? 'gl-text-gray-500!' : ''; + fossWithMergeAccess() { + return !this.hasLicense && this.accessLevel === ACCESS_LEVELS.MERGE; + }, + dropdownToggleClass() { + return { + 'gl-text-gray-500!': this.toggleLabel === this.label, + [this.toggleClass]: true, + }; }, selection() { return [ @@ -180,7 +219,7 @@ export default { ); }, focusInput() { - this.$refs.search.focusInput(); + this.$refs.search?.focusInput(); }, getData({ initial = false } = {}) { this.initialLoading = initial; @@ -190,10 +229,10 @@ export default { Promise.all([ getDeployKeys(this.query), getUsers(this.query), - this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), + this.groups.length ? Promise.resolve(this.groups) : getGroups(), ]) .then(([deployKeysResponse, usersResponse, groupsResponse]) => { - this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data); + this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse); this.setSelected({ initial }); }) .catch(() => @@ -224,13 +263,18 @@ export default { if (this.hasLicense) { this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP })); - this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({ - id, - name, - username, - avatar_url, - type: LEVEL_TYPES.USER, - })); + + // Has to be checked against server response + // because the selected item can be in filter results + if (this.showUsers) { + this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({ + id, + name, + username, + avatar_url, + type: LEVEL_TYPES.USER, + })); + } } this.deployKeys = deployKeysResponse.map((response) => { @@ -328,14 +372,38 @@ export default { return [...added, ...removed, ...preserved]; }, onItemClick(item) { - this.toggleSelection(this.selected[item.type], item); + this.toggleSelection(item); this.emitUpdate(); }, - toggleSelection(arr, item) { - const itemIndex = arr.findIndex(({ id }) => id === item.id); - if (itemIndex > -1) { - arr.splice(itemIndex, 1); - } else arr.push(item); + toggleSelection(item) { + if (item.id === ACCESS_LEVEL_NONE) { + this.selected[LEVEL_TYPES.ROLE] = [item]; + return; + } + + const itemSelected = this.isSelected(item); + if (itemSelected) { + this.selected[item.type] = this.selected[item.type].filter(({ id }) => id !== item.id); + return; + } + + // We're not multiselecting quite yet in "Merge" access dropdown, on FOSS: + // remove all preselected items before selecting this item + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37499 + if (this.fossWithMergeAccess) this.clearSelection(); + else if (item.type === LEVEL_TYPES.ROLE) this.unselectNone(); + + this.selected[item.type].push(item); + }, + unselectNone() { + this.selected[LEVEL_TYPES.ROLE] = this.selected[LEVEL_TYPES.ROLE].filter( + ({ id }) => id !== ACCESS_LEVEL_NONE, + ); + }, + clearSelection() { + Object.values(LEVEL_TYPES).forEach((level) => { + this.selected[level] = []; + }); }, isSelected(item) { return this.selected[item.type].some((selected) => selected.id === item.id); @@ -346,6 +414,10 @@ export default { onHide() { this.$emit('hidden', this.selection); }, + onShown() { + this.$emit('shown'); + this.focusInput(); + }, }, }; </script> @@ -354,13 +426,15 @@ export default { <gl-dropdown :disabled="disabled || initialLoading" :text="toggleLabel" - class="gl-min-w-20" - :toggle-class="toggleClass" + :block="block" + class="gl-min-w-20 gl-p-0!" + :toggle-class="dropdownToggleClass" aria-labelledby="allowed-users-label" - @shown="focusInput" + :data-testid="testId" + @shown="onShown" @hidden="onHide" > - <template #header> + <template v-if="searchEnabled" #header> <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" /> </template> <template v-if="roles.length"> diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js index 595cbc9c991..37a9fe0c741 100644 --- a/app/assets/javascripts/projects/settings/constants.js +++ b/app/assets/javascripts/projects/settings/constants.js @@ -7,13 +7,6 @@ export const LEVEL_TYPES = { GROUP: 'group', }; -export const LEVEL_ID_PROP = { - ROLE: 'access_level', - USER: 'user_id', - DEPLOY_KEY: 'deploy_key_id', - GROUP: 'group_id', -}; - export const ACCESS_LEVELS = { MERGE: 'merge_access_levels', PUSH: 'push_access_levels', diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js index 941efaef3bc..67afbee3854 100644 --- a/app/assets/javascripts/projects/settings/init_access_dropdown.js +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -7,8 +7,8 @@ export const initAccessDropdown = (el, options) => { return null; } - const { accessLevelsData, accessLevel } = options; - const { label, disabled, preselectedItems } = el.dataset; + const { accessLevelsData, ...props } = options; + const { label, disabled, preselectedItems } = el.dataset || {}; let preselected = []; try { preselected = JSON.parse(preselectedItems); @@ -18,20 +18,35 @@ export const initAccessDropdown = (el, options) => { return new Vue({ el, + name: 'AccessDropdownRoot', + data() { + return { preselected }; + }, + methods: { + setPreselectedItems(items) { + this.preselected = items; + }, + }, render(createElement) { const vm = this; return createElement(AccessDropdown, { props: { - accessLevel, - accessLevelsData: accessLevelsData.roles, - preselectedItems: preselected, label, disabled, + accessLevelsData: accessLevelsData.roles, + preselectedItems: this.preselected, + ...props, }, on: { select(selected) { vm.$emit('select', selected); }, + shown() { + vm.$emit('shown'); + }, + hidden() { + vm.$emit('hidden'); + }, }, }); }, diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue index 4affcd926d4..09bc275cbd4 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue @@ -1,5 +1,14 @@ <script> -import { GlButton, GlForm, GlFormGroup, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; +import { + GlButton, + GlForm, + GlFormGroup, + GlFormInputGroup, + GlFormInput, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { isEmptyValue, hasMinimumLength, isIntegerGreaterThan, isEmail } from '~/lib/utils/forms'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { @@ -23,6 +32,9 @@ import { } from '../custom_email_constants'; export default { + customEmailHelpUrl: helpPagePath('user/project/service_desk/configure.html', { + anchor: 'custom-email-address', + }), components: { ClipboardButton, GlButton, @@ -30,6 +42,8 @@ export default { GlFormGroup, GlFormInputGroup, GlFormInput, + GlLink, + GlSprintf, }, I18N_FORM_INTRODUCTION_PARAGRAPH, I18N_FORM_CUSTOM_EMAIL_LABEL, @@ -137,7 +151,19 @@ export default { <template> <div> - <p>{{ $options.I18N_FORM_INTRODUCTION_PARAGRAPH }}</p> + <p> + <gl-sprintf :message="$options.I18N_FORM_INTRODUCTION_PARAGRAPH"> + <template #link="{ content }"> + <gl-link + :href="$options.customEmailHelpUrl" + class="gl-display-inline-block" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> <gl-form class="js-quick-submit" @submit.prevent="onSubmit"> <gl-form-group :label="$options.I18N_FORM_FORWARDING_LABEL" @@ -149,7 +175,6 @@ export default { id="custom-email-form-forwarding" ref="service-desk-incoming-email" type="text" - data-testid="custom-email-form-forwarding" :aria-label="$options.I18N_FORM_FORWARDING_LABEL" :value="incomingEmail" :disabled="true" @@ -167,7 +192,6 @@ export default { <gl-form-group :label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL" label-for="custom-email-form-custom-email" - data-testid="form-group-custom-email" :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL" class="gl-mt-3" :description="$options.I18N_FORM_CUSTOM_EMAIL_DESCRIPTION" @@ -191,7 +215,6 @@ export default { <gl-form-group :label="$options.I18N_FORM_SMTP_ADDRESS_LABEL" label-for="custom-email-form-smtp-address" - data-testid="form-group-smtp-address" :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS" class="gl-mt-3" > diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue index 7e040e6001a..03ba99bcf71 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue @@ -233,6 +233,7 @@ export default { <gl-link :href="$options.FEEDBACK_ISSUE_URL" target="_blank" + data-testid="feedback-link" class="gl-text-blue-600 font-size-inherit" >{{ content }} </gl-link> diff --git a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js index cdf2e53982e..aafd77bd25e 100644 --- a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js +++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js @@ -17,7 +17,7 @@ export const I18N_TOAST_ENABLED = s__('ServiceDesk|Custom email enabled.'); export const I18N_TOAST_DISABLED = s__('ServiceDesk|Custom email disabled.'); export const I18N_FORM_INTRODUCTION_PARAGRAPH = s__( - 'ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials.', + 'ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials. %{linkStart}Learn more about prerequisites and the verification process%{linkEnd}.', ); export const I18N_FORM_FORWARDING_LABEL = s__( 'ServiceDesk|Service Desk email address to forward emails to', diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js deleted file mode 100644 index f294811dfff..00000000000 --- a/app/assets/javascripts/projects/star.js +++ /dev/null @@ -1,43 +0,0 @@ -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { spriteIcon } from '~/lib/utils/common_utils'; -import { __, s__ } from '~/locale'; - -export default class Star { - constructor(containerSelector = '.project-home-panel') { - const container = document.querySelector(containerSelector); - const starToggle = container.querySelector('.toggle-star'); - starToggle?.addEventListener('click', function toggleStarClickCallback() { - const starSpan = starToggle.querySelector('span'); - const starIcon = starToggle.querySelector('svg'); - const iconClasses = Array.from(starIcon.classList.values()); - - axios - .post(starToggle.dataset.endpoint) - .then(({ data }) => { - const isStarred = starSpan.classList.contains('starred'); - starToggle.parentNode.querySelector('.count').textContent = data.star_count; - - if (isStarred) { - starSpan.classList.remove('starred'); - starSpan.textContent = s__('StarProject|Star'); - starIcon.remove(); - // eslint-disable-next-line no-unsanitized/method - starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses)); - } else { - starSpan.classList.add('starred'); - starSpan.textContent = __('Unstar'); - starIcon.remove(); - - // eslint-disable-next-line no-unsanitized/method - starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses)); - } - }) - .catch(() => - createAlert({ - message: __('Star toggle failed. Try again later.'), - }), - ); - }); - } -} diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index a11201627a4..49c55efca7b 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -4,16 +4,15 @@ import { createAlert, VARIANT_SUCCESS } from '~/alert'; import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; -import AccessDropdown from '~/projects/settings/access_dropdown'; import { initToggle } from '~/toggles'; import { expandSection } from '~/settings_panels'; import { scrollToElement } from '~/lib/utils/common_utils'; +import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; import { BRANCH_RULES_ANCHOR, PROTECTED_BRANCHES_ANCHOR, IS_PROTECTED_BRANCH_CREATED, ACCESS_LEVELS, - LEVEL_TYPES, } from './constants'; export default class ProtectedBranchCreate { @@ -21,14 +20,17 @@ export default class ProtectedBranchCreate { this.hasLicense = options.hasLicense; this.$form = $('.js-new-protected-branch'); this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); - this.currentProjectUserDefaults = {}; - this.buildDropdowns(); - this.forcePushToggle = initToggle(document.querySelector('.js-force-push-toggle')); - if (this.hasLicense) { this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle')); } + + this.selectedItems = { + [ACCESS_LEVELS.PUSH]: [], + [ACCESS_LEVELS.MERGE]: [], + }; + this.initDropdowns(); + this.showSuccessAlertIfNeeded(); this.bindEvents(); } @@ -37,29 +39,26 @@ export default class ProtectedBranchCreate { this.$form.on('submit', this.onFormSubmit.bind(this)); } - buildDropdowns() { - const $allowedToMergeDropdown = this.$form.find('.js-allowed-to-merge'); - const $allowedToPushDropdown = this.$form.find('.js-allowed-to-push'); - + initDropdowns() { // Cache callback this.onSelectCallback = this.onSelect.bind(this); // Allowed to Merge dropdown - this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({ - $dropdown: $allowedToMergeDropdown, - accessLevelsData: gon.merge_access_levels, - onSelect: this.onSelectCallback, + const allowedToMergeSelector = 'js-allowed-to-merge'; + this[`${ACCESS_LEVELS.MERGE}_dropdown`] = this.buildDropdown({ + selector: allowedToMergeSelector, accessLevel: ACCESS_LEVELS.MERGE, - hasLicense: this.hasLicense, + accessLevelsData: gon.merge_access_levels, + testId: 'allowed-to-merge-dropdown', }); // Allowed to Push dropdown - this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({ - $dropdown: $allowedToPushDropdown, - accessLevelsData: gon.push_access_levels, - onSelect: this.onSelectCallback, + const allowedToPushSelector = 'js-allowed-to-push'; + this[`${ACCESS_LEVELS.PUSH}_dropdown`] = this.buildDropdown({ + selector: allowedToPushSelector, accessLevel: ACCESS_LEVELS.PUSH, - hasLicense: this.hasLicense, + accessLevelsData: gon.push_access_levels, + testId: 'allowed-to-push-dropdown', }); this.createItemDropdown = new CreateItemDropdown({ @@ -71,14 +70,40 @@ export default class ProtectedBranchCreate { }); } + buildDropdown({ selector, accessLevel, accessLevelsData, testId }) { + const [el] = this.$form.find(`.${selector}`); + if (!el) return undefined; + + const projectId = gon.current_project_id; + const dropdown = initAccessDropdown(el, { + toggleClass: `${selector} gl-form-input-lg`, + hasLicense: this.hasLicense, + searchEnabled: el.dataset.filter !== undefined, + showUsers: projectId !== undefined, + block: true, + accessLevel, + accessLevelsData, + testId, + }); + + dropdown.$on('select', (selected) => { + this.selectedItems[accessLevel] = selected; + this.onSelectCallback(); + }); + + dropdown.$on('shown', () => { + this.createItemDropdown.close(); + }); + + return dropdown; + } + // Enable submit button after selecting an option onSelect() { - const $allowedToMerge = this[`${ACCESS_LEVELS.MERGE}_dropdown`].getSelectedItems(); - const $allowedToPush = this[`${ACCESS_LEVELS.PUSH}_dropdown`].getSelectedItems(); const toggle = !( this.$form.find('input[name="protected_branch[name]"]').val() && - $allowedToMerge.length && - $allowedToPush.length + this.selectedItems[ACCESS_LEVELS.MERGE].length && + this.selectedItems[ACCESS_LEVELS.PUSH].length ); this.$form.find('button[type="submit"]').attr('disabled', toggle); @@ -137,32 +162,8 @@ export default class ProtectedBranchCreate { }, }; - Object.keys(ACCESS_LEVELS).forEach((level) => { - const accessLevel = ACCESS_LEVELS[level]; - const selectedItems = this[`${accessLevel}_dropdown`].getSelectedItems(); - const levelAttributes = []; - - selectedItems.forEach((item) => { - if (item.type === LEVEL_TYPES.USER) { - levelAttributes.push({ - user_id: item.user_id, - }); - } else if (item.type === LEVEL_TYPES.ROLE) { - levelAttributes.push({ - access_level: item.access_level, - }); - } else if (item.type === LEVEL_TYPES.GROUP) { - levelAttributes.push({ - group_id: item.group_id, - }); - } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { - levelAttributes.push({ - deploy_key_id: item.deploy_key_id, - }); - } - }); - - formData.protected_branch[`${accessLevel}_attributes`] = levelAttributes; + Object.values(ACCESS_LEVELS).forEach((level) => { + formData.protected_branch[`${level}_attributes`] = this.selectedItems[level]; }); return formData; diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index b6c86750723..29034b3bc0e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -2,28 +2,23 @@ import { find } from 'lodash'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import AccessDropdown from '~/projects/settings/access_dropdown'; import { initToggle } from '~/toggles'; +import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchEdit { constructor(options) { this.hasLicense = options.hasLicense; - this.$wraps = {}; this.hasChanges = false; this.$wrap = options.$wrap; - this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); - this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); - this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest( - `.${ACCESS_LEVELS.MERGE}-container`, - ); - this.$wraps[ACCESS_LEVELS.PUSH] = this.$allowedToPushDropdown.closest( - `.${ACCESS_LEVELS.PUSH}-container`, - ); + this.selectedItems = { + [ACCESS_LEVELS.PUSH]: [], + [ACCESS_LEVELS.MERGE]: [], + }; + this.initDropdowns(); - this.buildDropdowns(); this.initToggles(); } @@ -67,90 +62,96 @@ export default class ProtectedBranchEdit { } } - updateProtectedBranch(formData, callback) { - axios - .patch(this.$wrap.data('url'), { - protected_branch: formData, - }) - .then(callback) - .catch(() => { - createAlert({ message: __('Failed to update branch!') }); - }); + initDropdowns() { + // Allowed to Merge dropdown + this[`${ACCESS_LEVELS.MERGE}_dropdown`] = this.buildDropdown( + 'js-allowed-to-merge', + ACCESS_LEVELS.MERGE, + gon.merge_access_levels, + 'protected-branch-allowed-to-merge', + ); + + // Allowed to Push dropdown + this[`${ACCESS_LEVELS.PUSH}_dropdown`] = this.buildDropdown( + 'js-allowed-to-push', + ACCESS_LEVELS.PUSH, + gon.push_access_levels, + 'protected-branch-allowed-to-push', + ); } - buildDropdowns() { - // Allowed to merge dropdown - this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({ - accessLevel: ACCESS_LEVELS.MERGE, - accessLevelsData: gon.merge_access_levels, - $dropdown: this.$allowedToMergeDropdown, - onSelect: this.onSelectOption.bind(this), - onHide: this.onDropdownHide.bind(this), + buildDropdown(selector, accessLevel, accessLevelsData, testId) { + const [el] = this.$wrap.find(`.${selector}`); + if (!el) return undefined; + + const projectId = gon.current_project_id; + const dropdown = initAccessDropdown(el, { + toggleClass: selector, hasLicense: this.hasLicense, + searchEnabled: el.dataset.filter !== undefined, + showUsers: projectId !== undefined, + block: true, + accessLevel, + accessLevelsData, + testId, }); - // Allowed to push dropdown - this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({ - accessLevel: ACCESS_LEVELS.PUSH, - accessLevelsData: gon.push_access_levels, - $dropdown: this.$allowedToPushDropdown, - onSelect: this.onSelectOption.bind(this), - onHide: this.onDropdownHide.bind(this), - hasLicense: this.hasLicense, + dropdown.$on('select', (selected) => this.onSelectItems(accessLevel, selected)); + dropdown.$on('hidden', () => this.onDropdownHide()); + + this.initSelectedItems(dropdown, accessLevel); + return dropdown; + } + + initSelectedItems(dropdown, accessLevel) { + this.selectedItems[accessLevel] = dropdown.preselected.map((item) => { + if (item.type === LEVEL_TYPES.USER) return { id: item.id, user_id: item.user_id }; + if (item.type === LEVEL_TYPES.ROLE) return { id: item.id, access_level: item.access_level }; + if (item.type === LEVEL_TYPES.GROUP) return { id: item.id, group_id: item.group_id }; + return { id: item.id, deploy_key_id: item.deploy_key_id }; }); } - onSelectOption() { + onSelectItems(accessLevel, selected) { + this.selectedItems[accessLevel] = selected; this.hasChanges = true; } onDropdownHide() { - if (!this.hasChanges) { - return; - } - - this.hasChanges = true; + if (!this.hasChanges) return; this.updatePermissions(); } - updatePermissions() { - const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => { - const accessLevelName = ACCESS_LEVELS[level]; - const inputData = this[`${accessLevelName}_dropdown`].getInputData(accessLevelName); - acc[`${accessLevelName}_attributes`] = inputData; - - return acc; - }, {}); - + updateProtectedBranch(formData, callback) { axios .patch(this.$wrap.data('url'), { protected_branch: formData, }) - .then(({ data }) => { - this.hasChanges = false; - - Object.keys(ACCESS_LEVELS).forEach((level) => { - const accessLevelName = ACCESS_LEVELS[level]; - - // The data coming from server will be the new persisted *state* for each dropdown - this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`); - }); - this.$allowedToMergeDropdown.enable(); - this.$allowedToPushDropdown.enable(); - }) + .then(callback) .catch(() => { - this.$allowedToMergeDropdown.enable(); - this.$allowedToPushDropdown.enable(); createAlert({ message: __('Failed to update branch!') }); }); } - setSelectedItemsToDropdown(items = [], dropdownName) { + updatePermissions() { + const formData = Object.values(ACCESS_LEVELS).reduce((acc, level) => { + acc[`${level}_attributes`] = this.selectedItems[level]; + return acc; + }, {}); + this.updateProtectedBranch(formData, ({ data }) => { + this.hasChanges = false; + Object.values(ACCESS_LEVELS).forEach((level) => { + this.setSelectedItemsToDropdown(data[level], level); + }); + }); + } + + setSelectedItemsToDropdown(items = [], accessLevel) { const itemsToAdd = items.map((currentItem) => { if (currentItem.user_id) { // Do this only for users for now // get the current data for selected items - const selectedItems = this[dropdownName].getSelectedItems(); + const selectedItems = this.selectedItems[accessLevel]; const currentSelectedItem = find(selectedItems, { user_id: currentItem.user_id, }); @@ -164,7 +165,8 @@ export default class ProtectedBranchEdit { username: currentSelectedItem.username, avatar_url: currentSelectedItem.avatar_url, }; - } else if (currentItem.group_id) { + } + if (currentItem.group_id) { return { id: currentItem.id, group_id: currentItem.group_id, @@ -181,6 +183,7 @@ export default class ProtectedBranchEdit { }; }); - this[dropdownName].setSelectedItems(itemsToAdd); + this.selectedItems[accessLevel] = itemsToAdd; + this[`${accessLevel}_dropdown`]?.setPreselectedItems(itemsToAdd); } } diff --git a/app/assets/javascripts/protected_tags/constants.js b/app/assets/javascripts/protected_tags/constants.js index 758b820c4c4..d332ba1635f 100644 --- a/app/assets/javascripts/protected_tags/constants.js +++ b/app/assets/javascripts/protected_tags/constants.js @@ -1,7 +1,3 @@ -import { s__ } from '~/locale'; - -export const FAILED_TO_UPDATE_TAG_MESSAGE = s__('ProjectSettings|Failed to update tag!'); - export const ACCESS_LEVELS = { CREATE: 'create_access_levels', }; diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index 365b9a3b142..b5661af352c 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -3,8 +3,8 @@ import CreateItemDropdown from '~/create_item_dropdown'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { s__, __ } from '~/locale'; -import AccessDropdown from '~/projects/settings/access_dropdown'; -import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; +import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; +import { ACCESS_LEVELS } from './constants'; export default class ProtectedTagCreate { constructor({ hasLicense }) { @@ -12,6 +12,7 @@ export default class ProtectedTagCreate { this.$form = $('.js-new-protected-tag'); this.buildDropdowns(); this.bindEvents(); + this.selectedItems = []; } bindEvents() { @@ -19,20 +20,9 @@ export default class ProtectedTagCreate { } buildDropdowns() { - const $allowedToCreateDropdown = this.$form.find('.js-allowed-to-create'); - // Cache callback this.onSelectCallback = this.onSelect.bind(this); - // Allowed to Create dropdown - this.protectedTagAccessDropdown = new AccessDropdown({ - $dropdown: $allowedToCreateDropdown, - accessLevelsData: gon.create_access_levels, - onSelect: this.onSelectCallback, - accessLevel: ACCESS_LEVELS.CREATE, - hasLicense: this.hasLicense, - }); - // Protected tag dropdown this.createItemDropdown = new CreateItemDropdown({ $dropdown: this.$form.find('.js-protected-tag-select'), @@ -41,17 +31,36 @@ export default class ProtectedTagCreate { onSelect: this.onSelectCallback, getData: ProtectedTagCreate.getProtectedTags, }); + + // Allowed to Create dropdown + const createTagSelector = 'js-allowed-to-create'; + const [dropdownEl] = this.$form.find(`.${createTagSelector}`); + this.protectedTagAccessDropdown = initAccessDropdown(dropdownEl, { + toggleClass: createTagSelector, + hasLicense: this.hasLicense, + accessLevel: ACCESS_LEVELS.CREATE, + accessLevelsData: gon.create_access_levels, + searchEnabled: dropdownEl.dataset.filter !== undefined, + testId: 'allowed_to_create_dropdown', + }); + + this.protectedTagAccessDropdown.$on('select', (selected) => { + this.selectedItems = selected; + this.onSelectCallback(); + }); + + this.protectedTagAccessDropdown.$on('shown', () => { + this.createItemDropdown.close(); + }); } // This will run after clicked callback onSelect() { // Enable submit button const $tagInput = this.$form.find('input[name="protected_tag[name]"]'); - const $allowedToCreateInput = this.protectedTagAccessDropdown.getSelectedItems(); - this.$form .find('button[type="submit"]') - .prop('disabled', !($tagInput.val() && $allowedToCreateInput.length)); + .prop('disabled', !($tagInput.val() && this.selectedItems.length)); } static getProtectedTags(term, callback) { @@ -65,35 +74,7 @@ export default class ProtectedTagCreate { name: this.$form.find('input[name="protected_tag[name]"]').val(), }, }; - - Object.keys(ACCESS_LEVELS).forEach((level) => { - const accessLevel = ACCESS_LEVELS[level]; - const selectedItems = this.protectedTagAccessDropdown.getSelectedItems(); - const levelAttributes = []; - - selectedItems.forEach((item) => { - if (item.type === LEVEL_TYPES.USER) { - levelAttributes.push({ - user_id: item.user_id, - }); - } else if (item.type === LEVEL_TYPES.ROLE) { - levelAttributes.push({ - access_level: item.access_level, - }); - } else if (item.type === LEVEL_TYPES.GROUP) { - levelAttributes.push({ - group_id: item.group_id, - }); - } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { - levelAttributes.push({ - deploy_key_id: item.deploy_key_id, - }); - } - }); - - formData.protected_tag[`${accessLevel}_attributes`] = levelAttributes; - }); - + formData.protected_tag[`${ACCESS_LEVELS.CREATE}_attributes`] = this.selectedItems; return formData; } diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js deleted file mode 100644 index 4fa3ac3be4b..00000000000 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ /dev/null @@ -1,115 +0,0 @@ -import { find } from 'lodash'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import AccessDropdown from '~/projects/settings/access_dropdown'; -import { ACCESS_LEVELS, LEVEL_TYPES, FAILED_TO_UPDATE_TAG_MESSAGE } from './constants'; - -export default class ProtectedTagEdit { - constructor(options) { - this.hasLicense = options.hasLicense; - this.hasChanges = false; - this.$wrap = options.$wrap; - this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create'); - - this.$allowedToCreateDropdownContainer = this.$allowedToCreateDropdownButton.closest( - '.create_access_levels-container', - ); - - this.buildDropdowns(); - } - - buildDropdowns() { - // Allowed to create dropdown - this.protectedTagAccessDropdown = new AccessDropdown({ - accessLevel: ACCESS_LEVELS.CREATE, - accessLevelsData: gon.create_access_levels, - $dropdown: this.$allowedToCreateDropdownButton, - onSelect: this.onSelectOption.bind(this), - onHide: this.onDropdownHide.bind(this), - hasLicense: this.hasLicense, - }); - } - - onSelectOption() { - this.hasChanges = true; - } - - onDropdownHide() { - if (!this.hasChanges) { - return; - } - - this.hasChanges = true; - this.updatePermissions(); - } - - updatePermissions() { - const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => { - const accessLevelName = ACCESS_LEVELS[level]; - const inputData = this.protectedTagAccessDropdown.getInputData(accessLevelName); - acc[`${accessLevelName}_attributes`] = inputData; - - return acc; - }, {}); - - axios - .patch(this.$wrap.data('url'), { - protected_tag: formData, - }) - .then(({ data }) => { - this.hasChanges = false; - - Object.keys(ACCESS_LEVELS).forEach((level) => { - const accessLevelName = ACCESS_LEVELS[level]; - - // The data coming from server will be the new persisted *state* for each dropdown - this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`); - }); - }) - .catch(() => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - createAlert({ - message: FAILED_TO_UPDATE_TAG_MESSAGE, - }); - }); - } - - setSelectedItemsToDropdown(items = []) { - const itemsToAdd = items.map((currentItem) => { - if (currentItem.user_id) { - // Do this only for users for now - // get the current data for selected items - const selectedItems = this.protectedTagAccessDropdown.getSelectedItems(); - const currentSelectedItem = find(selectedItems, { - user_id: currentItem.user_id, - }); - - return { - id: currentItem.id, - user_id: currentItem.user_id, - type: LEVEL_TYPES.USER, - persisted: true, - name: currentSelectedItem.name, - username: currentSelectedItem.username, - avatar_url: currentSelectedItem.avatar_url, - }; - } else if (currentItem.group_id) { - return { - id: currentItem.id, - group_id: currentItem.group_id, - type: LEVEL_TYPES.GROUP, - persisted: true, - }; - } - - return { - id: currentItem.id, - access_level: currentItem.access_level, - type: LEVEL_TYPES.ROLE, - persisted: true, - }; - }); - - this.protectedTagAccessDropdown.setSelectedItems(itemsToAdd); - } -} diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.vue b/app/assets/javascripts/protected_tags/protected_tag_edit.vue new file mode 100644 index 00000000000..82b2ecc5f5c --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.vue @@ -0,0 +1,113 @@ +<script> +import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; + +export const i18n = { + failureMessage: s__('ProjectSettings|Failed to update tag!'), +}; + +export default { + i18n, + ACCESS_LEVELS, + name: 'ProtectedTagEdit', + components: { + AccessDropdown, + }, + props: { + url: { + type: String, + required: true, + }, + accessLevelsData: { + type: Array, + required: true, + }, + hasLicense: { + required: false, + type: Boolean, + default: true, + }, + preselectedItems: { + type: Array, + required: false, + default: () => [], + }, + searchEnabled: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + selected: this.preselectedItems, + }; + }, + methods: { + hasChanges(permissions) { + return permissions.some(({ id, _destroy }) => id === undefined || _destroy); + }, + updatePermissions(permissions) { + if (!this.hasChanges(permissions)) return; + axios + .patch(this.url, { + protected_tag: { + [`${ACCESS_LEVELS.CREATE}_attributes`]: permissions, + }, + }) + .then(this.setSelected) + .catch(() => { + createAlert({ + message: i18n.failureMessage, + parent: this.parentContainer, + }); + }); + }, + setSelected({ data }) { + if (!data) return; + this.selected = data[ACCESS_LEVELS.CREATE].map( + ({ id, user_id: userId, group_id: groupId, access_level: accessLevel }) => { + if (userId) { + return { + id, + user_id: userId, + type: LEVEL_TYPES.USER, + }; + } + + if (groupId) { + return { + id, + group_id: groupId, + type: LEVEL_TYPES.GROUP, + }; + } + + return { + id, + access_level: accessLevel, + type: LEVEL_TYPES.ROLE, + }; + }, + ); + }, + }, +}; +</script> + +<template> + <access-dropdown + toggle-class="js-allowed-to-create gl-max-w-34" + test-id="allowed_to_create_dropdown" + :has-license="hasLicense" + :access-level="$options.ACCESS_LEVELS.CREATE" + :access-levels-data="accessLevelsData" + :preselected-items="selected" + :search-enabled="searchEnabled" + :block="true" + @hidden="updatePermissions" + /> +</template> diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js index 8ceb970bf03..444d6e9cf76 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js @@ -1,21 +1,49 @@ -/* eslint-disable no-new */ - -import $ from 'jquery'; -import ProtectedTagEdit from './protected_tag_edit'; +import Vue from 'vue'; +import * as Sentry from '@sentry/browser'; +import ProtectedTagEdit from './protected_tag_edit.vue'; export default class ProtectedTagEditList { constructor(options) { this.hasLicense = options.hasLicense; - this.$wrap = $('.protected-tags-list'); this.initEditForm(); } initEditForm() { - this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => { - new ProtectedTagEdit({ - $wrap: $(el), - hasLicense: this.hasLicense, + document + .querySelector('.protected-tags-list') + .querySelectorAll('.js-protected-tag-edit-form') + ?.forEach((el) => { + const accessDropdownEl = el.querySelector('.js-allowed-to-create'); + this.initAccessDropdown(accessDropdownEl, { + url: el.dataset.url, + hasLicense: this.hasLicense, + accessLevelsData: gon.create_access_levels.roles, + }); }); + } + + // eslint-disable-next-line class-methods-use-this + initAccessDropdown(el, options) { + if (!el) return null; + + let preselected = []; + try { + preselected = JSON.parse(el.dataset.preselectedItems); + } catch (e) { + Sentry.captureException(e); + } + + return new Vue({ + el, + render(createElement) { + return createElement(ProtectedTagEdit, { + props: { + preselectedItems: preselected, + searchEnabled: el.dataset.filter !== undefined, + ...options, + }, + }); + }, }); } } diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index b3033ddf3b6..d36b29f69a5 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -115,7 +115,8 @@ export default { addRelatedErrorMessage() { if (this.itemAddFailureMessage) { return this.itemAddFailureMessage; - } else if (this.itemAddFailureType === itemAddFailureTypesMap.NOT_FOUND) { + } + if (this.itemAddFailureType === itemAddFailureTypesMap.NOT_FOUND) { return addRelatedIssueErrorMap[this.issuableType]; } // Only other failure is MAX_NUMBER_OF_CHILD_EPICS at the moment diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js index 23620432feb..cc0dae355b6 100644 --- a/app/assets/javascripts/related_issues/index.js +++ b/app/assets/javascripts/related_issues/index.js @@ -1,10 +1,9 @@ import Vue from 'vue'; -import { TYPE_ISSUE } from '~/issues/constants'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import { parseBoolean } from '~/lib/utils/common_utils'; import RelatedIssuesRoot from './components/related_issues_root.vue'; -export function initRelatedIssues(issueType = TYPE_ISSUE) { +export function initRelatedIssues() { const el = document.querySelector('.js-related-issues-root'); if (!el) { @@ -28,7 +27,7 @@ export function initRelatedIssues(issueType = TYPE_ISSUE) { canAdmin: parseBoolean(el.dataset.canAddRelatedIssues), helpPath: el.dataset.helpPath, showCategorizedIssues: parseBoolean(el.dataset.showCategorizedIssues), - issuableType: issueType, + issuableType: el.dataset.issuableType, autoCompleteEpics: false, }, }), diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 6f9f0a81dfd..6565c84fa11 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -7,11 +7,9 @@ import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constant import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue'; import CodeIntelligence from '~/code_navigation/components/app.vue'; import LineHighlighter from '~/blob/line_highlighter'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; @@ -19,15 +17,7 @@ import { addBlameLink } from '~/blob/blob_blame_link'; import highlightMixin from '~/repository/mixins/highlight_mixin'; import projectInfoQuery from '../queries/project_info.query.graphql'; import getRefMixin from '../mixins/get_ref'; -import userInfoQuery from '../queries/user_info.query.graphql'; -import applicationInfoQuery from '../queries/application_info.query.graphql'; -import { - DEFAULT_BLOB_INFO, - TEXT_FILE_TYPE, - LFS_STORAGE, - LEGACY_FILE_TYPES, - CODEOWNERS_FILE_NAME, -} from '../constants'; +import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE, LEGACY_FILE_TYPES } from '../constants'; import BlobButtonGroup from './blob_button_group.vue'; import ForkSuggestion from './fork_suggestion.vue'; import { loadViewer } from './blob_viewers'; @@ -38,10 +28,8 @@ export default { BlobButtonGroup, BlobContent, GlLoadingIcon, - CodeownersValidation: () => import('ee_component/blob/components/codeowners_validation.vue'), GlButton, ForkSuggestion, - WebIdeLink, CodeIntelligence, AiGenie: () => import('ee_component/ai/components/ai_genie.vue'), }, @@ -68,18 +56,6 @@ export default { this.userPermissions = project.userPermissions; }, }, - gitpodEnabled: { - query: applicationInfoQuery, - error() { - this.displayError(); - }, - }, - currentUser: { - query: userInfoQuery, - error() { - this.displayError(); - }, - }, project: { query: blobInfoQuery, variables() { @@ -94,32 +70,17 @@ export default { return queryVariables; }, result({ data }) { - const blob = data.project?.repository?.blobs?.nodes[0] || {}; - this.initHighlightWorker(blob); + const repository = data.project?.repository || {}; + this.blobInfo = repository.blobs?.nodes[0] || {}; + this.isEmptyRepository = repository.empty; + this.projectId = data.project?.id; - const urlHash = getLocationHash(); - const plain = this.$route?.query?.plain; + const usePlain = this.$route?.query?.plain === '1'; // When the 'plain' URL param is present, its value determines which viewer to render + const urlHash = getLocationHash(); // If there is a code line hash in the URL we render with the simple viewer + const useSimpleViewer = usePlain || urlHash?.startsWith('L') || !this.hasRichViewer; - // When the 'plain' URL param is present, its value determines which viewer to render: - // - when 0 and the rich viewer is available we render with it - // - otherwise we render the simple viewer - if (plain !== undefined) { - if (plain === '0' && this.hasRichViewer) { - this.switchViewer(RICH_BLOB_VIEWER); - } else { - this.switchViewer(SIMPLE_BLOB_VIEWER); - } - return; - } - - // If there is a code line hash in the URL we render with the simple viewer - if (urlHash && urlHash.startsWith('L')) { - this.switchViewer(SIMPLE_BLOB_VIEWER); - return; - } - - // By default, if present, use the rich viewer to render - this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER); + this.initHighlightWorker(this.blobInfo); + this.switchViewer(useSimpleViewer ? SIMPLE_BLOB_VIEWER : RICH_BLOB_VIEWER); // By default, if present, use the rich viewer to render }, error() { this.displayError(); @@ -127,9 +88,7 @@ export default { }, }, provide() { - return { - blobHash: uniqueId(), - }; + return { blobHash: uniqueId() }; }, props: { path: { @@ -156,11 +115,13 @@ export default { isRenderingLegacyTextViewer: false, activeViewerType: SIMPLE_BLOB_VIEWER, project: DEFAULT_BLOB_INFO.project, - gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled, currentUser: DEFAULT_BLOB_INFO.currentUser, useFallback: false, pathLocks: DEFAULT_BLOB_INFO.pathLocks, userPermissions: DEFAULT_BLOB_INFO.userPermissions, + blobInfo: {}, + isEmptyRepository: false, + projectId: null, }; }, computed: { @@ -173,17 +134,9 @@ export default { isBinaryFileType() { return this.isBinary || this.blobInfo.simpleViewer?.fileType !== TEXT_FILE_TYPE; }, - blobInfo() { - const nodes = this.project?.repository?.blobs?.nodes || []; - - return nodes[0] || {}; - }, currentRef() { return this.originalBranch || this.ref; }, - isCodeownersFile() { - return this.path.includes(CODEOWNERS_FILE_NAME); - }, viewer() { const { richViewer, simpleViewer } = this.blobInfo; return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer; @@ -249,22 +202,14 @@ export default { isUsingLfs() { return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; }, - projectIdAsNumber() { - return getIdFromGraphQLId(this.project?.id); - }, }, watch: { // Watch the URL 'plain' query value to know if the viewer needs changing. - // This is the case when the user switches the viewer and then goes back - // through the hystory. + // This is the case when the user switches the viewer and then goes back through the history '$route.query.plain': { handler(plainValue) { - this.switchViewer( - this.hasRichViewer && (plainValue === undefined || plainValue === '0') - ? RICH_BLOB_VIEWER - : SIMPLE_BLOB_VIEWER, - plainValue !== undefined, - ); + const useSimpleViewer = plainValue === '1' || !this.hasRichViewer; + this.switchViewer(useSimpleViewer ? SIMPLE_BLOB_VIEWER : RICH_BLOB_VIEWER); }, }, }, @@ -323,21 +268,11 @@ export default { this.loadLegacyViewer(); } }, - updateRouteQuery() { - const plain = this.activeViewerType === SIMPLE_BLOB_VIEWER ? '1' : '0'; - - if (this.$route?.query?.plain === plain) { - return; - } - - this.$router.push({ - path: this.$route.path, - query: { ...this.$route.query, plain }, - }); - }, handleViewerChanged(newViewer) { this.switchViewer(newViewer); - this.updateRouteQuery(); + const plain = newViewer === SIMPLE_BLOB_VIEWER ? '1' : '0'; + if (this.$route?.query?.plain === plain) return; + this.$router.push({ path: this.$route.path, query: { ...this.$route.query, plain } }); }, editBlob(target) { if (this.showForkSuggestion) { @@ -370,31 +305,15 @@ export default { :has-render-error="hasRenderError" :show-path="false" :override-copy="true" + :show-fork-suggestion="showForkSuggestion" + :project-path="projectPath" + :project-id="projectId" @viewer-changed="handleViewerChanged" @copy="onCopy" + @edit="editBlob" + @error="displayError" > <template #actions> - <web-ide-link - v-if="!blobInfo.archived" - :show-edit-button="!isBinaryFileType" - class="gl-mr-3" - :edit-url="blobInfo.editBlobPath" - :web-ide-url="blobInfo.ideEditPath" - :needs-to-fork="showForkSuggestion" - :show-pipeline-editor-button="Boolean(blobInfo.pipelineEditorPath)" - :pipeline-editor-url="blobInfo.pipelineEditorPath" - :gitpod-url="blobInfo.gitpodBlobUrl" - :show-gitpod-button="gitpodEnabled" - :gitpod-enabled="currentUser && currentUser.gitpodEnabled" - :project-path="projectPath" - :project-id="projectIdAsNumber" - :user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath" - :user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath" - is-blob - disable-fork-modal - @edit="editBlob" - /> - <blob-button-group v-if="isLoggedIn && !blobInfo.archived" :path="path" @@ -403,7 +322,7 @@ export default { :delete-path="blobInfo.webPath" :can-push-code="userPermissions.pushCode" :can-push-to-branch="blobInfo.canCurrentUserPushToBranch" - :empty-repo="project.repository.empty" + :empty-repo="isEmptyRepository" :project-path="projectPath" :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" @@ -418,12 +337,6 @@ export default { :fork-path="forkPath" @cancel="setForkTarget(null)" /> - <codeowners-validation - v-if="isCodeownersFile" - :current-ref="currentRef" - :project-path="projectPath" - :file-path="path" - /> <blob-content v-if="!blobViewer" class="js-syntax-highlight" @@ -441,6 +354,8 @@ export default { v-else :blob="blobInfo" :chunks="chunks" + :project-path="projectPath" + :current-ref="currentRef" class="blob-viewer" @error="onError" /> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index fa51ef30546..12edeeb0d2f 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import defaultAvatarUrl from 'images/no_avatar.png'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -20,13 +20,13 @@ export default { UserAvatarLink, TimeagoTooltip, ClipboardButton, - CiIcon, GlButton, GlButtonGroup, GlLink, GlLoadingIcon, UserAvatarImage, SignatureBadge, + CiBadgeLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -191,18 +191,14 @@ export default { > <signature-badge v-if="commit.signature" :signature="commit.signature" /> <div v-if="commit.pipeline" class="ci-status-link"> - <gl-link - v-gl-tooltip.left - :href="commit.pipeline.detailedStatus.detailsPath" - :title="statusTitle" + <ci-badge-link + :status="commit.pipeline.detailedStatus" + :details-path="commit.pipeline.detailedStatus.detailsPath" + :aria-label="statusTitle" + size="lg" + :show-text="false" class="js-commit-pipeline" - > - <ci-icon - :status="commit.pipeline.detailedStatus" - :size="24" - :aria-label="statusTitle" - /> - </gl-link> + /> </div> <gl-button-group class="gl-ml-4 js-commit-sha-group"> <gl-button label class="gl-font-monospace" data-testid="last-commit-id-label">{{ diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index c839d7a53cd..a76d822317a 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -123,7 +123,8 @@ export default { path: joinPaths('/-/blob', this.escapedRef, this.path), refType: this.refType, }); - } else if (this.isFolder) { + } + if (this.isFolder) { return buildURLwithRefType({ path: joinPaths('/-/tree', this.escapedRef, this.path), refType: this.refType, @@ -260,7 +261,9 @@ export default { </gl-intersection-observer> </td> <td class="tree-time-ago text-right cursor-default gl-text-secondary"> - <timeago-tooltip v-if="commitData" :time="commitData.committedDate" /> + <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared"> + <timeago-tooltip v-if="commitData" :time="commitData.committedDate" /> + </gl-intersection-observer> <gl-skeleton-loader v-if="showSkeletonLoader" :lines="1" /> </td> </tr> diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 3079ef0bfbb..c4d03120c2e 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -27,12 +27,6 @@ export const PDF_MAX_PAGE_LIMIT = 50; export const ROW_APPEAR_DELAY = 150; export const DEFAULT_BLOB_INFO = { - gitpodEnabled: false, - currentUser: { - gitpodEnabled: false, - preferencesGitpodPath: null, - profileEnableGitpodPath: null, - }, userPermissions: { pushCode: false, downloadCode: false, @@ -116,5 +110,3 @@ export const POLLING_INTERVAL_BACKOFF = 2; export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal'; export const FORK_UPDATED_EVENT = 'fork:updated'; - -export const CODEOWNERS_FILE_NAME = 'CODEOWNERS'; diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 87996d0bb85..2b9ec7b63d7 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -8,6 +8,7 @@ export * from './api/tags_api'; export * from './api/alert_management_alerts_api'; export * from './api/harbor_registry'; export * from './api/environments_api'; +export * from './api/application_settings_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 58e4553d00d..bc1f32930e7 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -64,15 +64,12 @@ Sidebar.prototype.addEventListeners = function () { }; Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { - const $this = $(this); - - if ($this.hasClass('right-sidebar-merge-requests')) return; - + const $toggleButtons = $('.js-sidebar-toggle'); const $collapseIcon = $('.js-sidebar-collapse'); const $expandIcon = $('.js-sidebar-expand'); const $toggleContainer = $('.js-sidebar-toggle-container'); const isExpanded = $toggleContainer.data('is-expanded'); - const tooltipLabel = isExpanded ? __('Collapse sidebar') : __('Expand sidebar'); + const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); e.preventDefault(); if (isExpanded) { @@ -93,10 +90,10 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { $('.layout-page').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); } - $this.attr('data-original-title', tooltipLabel); - $this.attr('title', tooltipLabel); - fixTitle($this); - hide($this); + $toggleButtons.attr('data-original-title', tooltipLabel); + $toggleButtons.attr('title', tooltipLabel); + fixTitle($toggleButtons); + hide($toggleButtons); if (!triggered) { setCookie('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed')); diff --git a/app/assets/javascripts/run_modules.js b/app/assets/javascripts/run_modules.js new file mode 100644 index 00000000000..fabdff9bb76 --- /dev/null +++ b/app/assets/javascripts/run_modules.js @@ -0,0 +1,9 @@ +export const runModules = (modules, prefix) => { + document + .querySelector('meta[name="controller-path"]') + .content.split('/') + .forEach((part, index, arr) => { + const path = `${prefix}${[...arr.slice(0, index), part].join('/')}/index.js`; + modules[path]?.(); + }); +}; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index f5684cebbf9..f83130213f2 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -10,12 +10,13 @@ import { initBlobRefSwitcher } from './under_topbar'; export const initSearchApp = () => { syntaxHighlight(document.querySelectorAll('.js-search-results')); const query = queryToObject(window.location.search, { gatherArrays: true }); - const { navigationJsonParsed: navigation } = sidebarInitState() || {}; + const { navigationJsonParsed: navigation, searchType } = sidebarInitState() || {}; const store = createStore({ query, navigation, useSidebarNavigation: gon.use_new_navigation, + searchType, }); initTopbar(store); diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 9962f711892..532a66affd8 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -3,29 +3,46 @@ import { mapState, mapGetters } from 'vuex'; import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; +import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; +import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB, SCOPE_PROJECTS } from '../constants'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import { + SCOPE_ISSUES, + SCOPE_MERGE_REQUESTS, + SCOPE_BLOB, + SCOPE_PROJECTS, + SCOPE_NOTES, + SCOPE_COMMITS, + SEARCH_TYPE_ADVANCED, +} from '../constants'; import IssuesFilters from './issues_filters.vue'; import MergeRequestsFilters from './merge_requests_filters.vue'; import BlobsFilters from './blobs_filters.vue'; import ProjectsFilters from './projects_filters.vue'; +import NotesFilters from './notes_filters.vue'; +import CommitsFilters from './commits_filters.vue'; export default { name: 'GlobalSearchSidebar', components: { IssuesFilters, - ScopeLegacyNavigation, - ScopeSidebarNavigation, - SidebarPortal, MergeRequestsFilters, BlobsFilters, ProjectsFilters, + NotesFilters, + ScopeLegacyNavigation, + ScopeSidebarNavigation, + SidebarPortal, + DomElementListener, + SmallScreenDrawerNavigation, + CommitsFilters, }, mixins: [glFeatureFlagsMixin()], computed: { // useSidebarNavigation refers to whether the new left sidebar navigation is enabled - ...mapState(['useSidebarNavigation']), + ...mapState(['useSidebarNavigation', 'searchType']), ...mapGetters(['currentScope']), showIssuesFilters() { return this.currentScope === SCOPE_ISSUES; @@ -34,11 +51,25 @@ export default { return this.currentScope === SCOPE_MERGE_REQUESTS; }, showBlobFilters() { - return this.currentScope === SCOPE_BLOB; + return this.currentScope === SCOPE_BLOB && this.searchType === SEARCH_TYPE_ADVANCED; }, showProjectsFilters() { - // for now the feature flag is here. Since we have only one filter in projects scope - return this.currentScope === SCOPE_PROJECTS && this.glFeatures.searchProjectsHideArchived; + return this.currentScope === SCOPE_PROJECTS; + }, + showNotesFilters() { + return ( + this.currentScope === SCOPE_NOTES && + this.searchType === SEARCH_TYPE_ADVANCED && + this.glFeatures.searchNotesHideArchivedProjects + ); + }, + showCommitsFilters() { + // for now, the feature flag is placed here. Since we have only one filter in commits scope + return ( + this.currentScope === SCOPE_COMMITS && + this.searchType === SEARCH_TYPE_ADVANCED && + this.glFeatures.searchCommitsHideArchivedProjects + ); }, showScopeNavigation() { // showScopeNavigation refers to whether the scope navigation should be shown @@ -47,27 +78,49 @@ export default { return Boolean(this.currentScope); }, }, + methods: { + toggleFiltersFromSidebar() { + toggleSuperSidebarCollapsed(); + }, + }, }; </script> <template> <section v-if="useSidebarNavigation"> + <dom-element-listener selector="#js-open-mobile-filters" @click="toggleFiltersFromSidebar" /> <sidebar-portal> <scope-sidebar-navigation /> <issues-filters v-if="showIssuesFilters" /> <merge-requests-filters v-if="showMergeRequestFilters" /> <blobs-filters v-if="showBlobFilters" /> <projects-filters v-if="showProjectsFilters" /> + <notes-filters v-if="showNotesFilters" /> + <commits-filters v-if="showCommitsFilters" /> </sidebar-portal> </section> + <section v-else-if="showScopeNavigation" - class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5" + class="gl-display-flex gl-flex-direction-column gl-lg-mr-0 gl-md-mr-5 gl-lg-mb-6 gl-lg-mt-5" > - <scope-legacy-navigation /> - <issues-filters v-if="showIssuesFilters" /> - <merge-requests-filters v-if="showMergeRequestFilters" /> - <blobs-filters v-if="showBlobFilters" /> - <projects-filters v-if="showProjectsFilters" /> + <div class="search-sidebar gl-display-none gl-lg-display-block"> + <scope-legacy-navigation /> + <issues-filters v-if="showIssuesFilters" /> + <merge-requests-filters v-if="showMergeRequestFilters" /> + <blobs-filters v-if="showBlobFilters" /> + <projects-filters v-if="showProjectsFilters" /> + <notes-filters v-if="showNotesFilters" /> + <commits-filters v-if="showCommitsFilters" /> + </div> + <small-screen-drawer-navigation class="gl-lg-display-none"> + <scope-legacy-navigation /> + <issues-filters v-if="showIssuesFilters" /> + <merge-requests-filters v-if="showMergeRequestFilters" /> + <blobs-filters v-if="showBlobFilters" /> + <projects-filters v-if="showProjectsFilters" /> + <notes-filters v-if="showNotesFilters" /> + <commits-filters v-if="showCommitsFilters" /> + </small-screen-drawer-navigation> </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js index 77efbdd9e60..5cddf5e744f 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js @@ -7,6 +7,11 @@ export const TRACKING_LABEL_CHECKBOX = 'checkbox'; const scopes = { PROJECTS: 'projects', + ISSUES: 'issues', + MERGE_REQUESTS: 'merge_requests', + NOTES: 'notes', + BLOBS: 'blobs', + COMMITS: 'commits', }; const filterParam = 'include_archived'; diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue index 1984e3a36c4..c31c46f2e6a 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue @@ -14,7 +14,7 @@ export default { GlFormCheckbox, }, computed: { - ...mapState(['urlQuery']), + ...mapState(['urlQuery', 'useSidebarNavigation']), selectedFilter: { get() { return [parseBoolean(this.urlQuery?.include_archived)]; @@ -41,7 +41,9 @@ export default { <template> <gl-form-checkbox-group v-model="selectedFilter"> - <h5>{{ $options.archivedFilterData.headerLabel }}</h5> + <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> + {{ $options.archivedFilterData.headerLabel }} + </h5> <gl-form-checkbox class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" :class="$options.LABEL_DEFAULT_CLASSES" diff --git a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue index 5f4d6fbd56c..ac36ae6b366 100644 --- a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue @@ -1,5 +1,10 @@ <script> +// eslint-disable-next-line no-restricted-imports +import { mapGetters, mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { HR_DEFAULT_CLASSES } from '../constants'; import LanguageFilter from './language_filter/index.vue'; +import ArchivedFilter from './archived_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; export default { @@ -7,6 +12,21 @@ export default { components: { LanguageFilter, FiltersTemplate, + ArchivedFilter, + }, + mixins: [glFeatureFlagsMixin()], + computed: { + ...mapGetters(['currentScope']), + ...mapState(['useSidebarNavigation', 'searchType']), + showArchivedFilter() { + return this.glFeatures.searchBlobsHideArchivedProjects; + }, + showDivider() { + return !this.useSidebarNavigation && this.showArchivedFilter; + }, + hrClasses() { + return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; + }, }, }; </script> @@ -14,5 +34,7 @@ export default { <template> <filters-template> <language-filter class="gl-mb-5" /> + <hr v-if="showDivider" :class="hrClasses" /> + <archived-filter v-if="showArchivedFilter" class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/commits_filters.vue b/app/assets/javascripts/search/sidebar/components/commits_filters.vue new file mode 100644 index 00000000000..4f9fdbe9551 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/commits_filters.vue @@ -0,0 +1,18 @@ +<script> +import ArchivedFilter from './archived_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'CommitsFilters', + components: { + ArchivedFilter, + FiltersTemplate, + }, +}; +</script> + +<template> + <filters-template> + <archived-filter class="gl-mb-5" /> + </filters-template> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/filters_template.vue b/app/assets/javascripts/search/sidebar/components/filters_template.vue index 3dae05ccc69..0f68bf92048 100644 --- a/app/assets/javascripts/search/sidebar/components/filters_template.vue +++ b/app/assets/javascripts/search/sidebar/components/filters_template.vue @@ -21,9 +21,6 @@ export default { computed: { ...mapState(['sidebarDirty', 'useSidebarNavigation']), ...mapGetters(['currentScope']), - hrClasses() { - return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; - }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -40,14 +37,15 @@ export default { this.resetQuery(); }, }, + HR_DEFAULT_CLASSES, }; </script> <template> <gl-form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking"> - <hr v-if="!useSidebarNavigation" :class="hrClasses" /> + <hr v-if="!useSidebarNavigation" :class="$options.HR_DEFAULT_CLASSES" /> <slot></slot> - <hr v-if="!useSidebarNavigation" :class="hrClasses" /> + <hr v-if="!useSidebarNavigation" :class="$options.HR_DEFAULT_CLASSES" /> <div class="gl-display-flex gl-align-items-center gl-mt-4"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 919bd2b2e49..dbd52978163 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -2,13 +2,15 @@ // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { HR_DEFAULT_CLASSES } from '../constants/index'; +import { HR_DEFAULT_CLASSES, SEARCH_TYPE_ADVANCED } from '../constants'; import { confidentialFilterData } from './confidentiality_filter/data'; import { statusFilterData } from './status_filter/data'; import ConfidentialityFilter from './confidentiality_filter/index.vue'; import { labelFilterData } from './label_filter/data'; +import { archivedFilterData } from './archived_filter/data'; import LabelFilter from './label_filter/index.vue'; import StatusFilter from './status_filter/index.vue'; +import ArchivedFilter from './archived_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; @@ -19,11 +21,12 @@ export default { ConfidentialityFilter, LabelFilter, FiltersTemplate, + ArchivedFilter, }, mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentScope']), - ...mapState(['useSidebarNavigation']), + ...mapState(['useSidebarNavigation', 'searchType']), showConfidentialityFilter() { return Object.values(confidentialFilterData.scopes).includes(this.currentScope); }, @@ -33,7 +36,15 @@ export default { showLabelFilter() { return ( Object.values(labelFilterData.scopes).includes(this.currentScope) && - this.glFeatures.searchIssueLabelAggregation + this.glFeatures.searchIssueLabelAggregation && + this.searchType === SEARCH_TYPE_ADVANCED + ); + }, + showArchivedFilter() { + return ( + Object.values(archivedFilterData.scopes).includes(this.currentScope) && + this.glFeatures.searchIssuesHideArchivedProjects && + this.searchType === SEARCH_TYPE_ADVANCED ); }, showDivider() { @@ -52,6 +63,8 @@ export default { <hr v-if="showConfidentialityFilter && showDivider" :class="hrClasses" /> <confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" /> <hr v-if="showLabelFilter && showDivider" :class="hrClasses" /> - <label-filter v-if="showLabelFilter" /> + <label-filter v-if="showLabelFilter" class="gl-mb-5" /> + <hr v-if="showArchivedFilter && showDivider" :class="hrClasses" /> + <archived-filter v-if="showArchivedFilter" class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index ca1503d7c64..784207cc702 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -74,7 +74,7 @@ export default { </script> <template> - <div v-if="hasBuckets" class="gl-my-0 language-filter-checkbox"> + <div v-if="hasBuckets" class="language-filter-checkbox"> <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> {{ $options.languageFilterData.header }} </h5> diff --git a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue index bc5b797dd56..2845eb2049b 100644 --- a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue @@ -1,18 +1,49 @@ <script> +// eslint-disable-next-line no-restricted-imports +import { mapGetters, mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { HR_DEFAULT_CLASSES, SEARCH_TYPE_ADVANCED } from '../constants'; +import { statusFilterData } from './status_filter/data'; import StatusFilter from './status_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; +import { archivedFilterData } from './archived_filter/data'; +import ArchivedFilter from './archived_filter/index.vue'; export default { name: 'MergeRequestsFilters', components: { StatusFilter, FiltersTemplate, + ArchivedFilter, + }, + mixins: [glFeatureFlagsMixin()], + computed: { + ...mapGetters(['currentScope']), + ...mapState(['useSidebarNavigation', 'searchType']), + showArchivedFilter() { + return ( + Object.values(archivedFilterData.scopes).includes(this.currentScope) && + this.glFeatures.searchMergeRequestsHideArchivedProjects && + this.searchType === SEARCH_TYPE_ADVANCED + ); + }, + showStatusFilter() { + return Object.values(statusFilterData.scopes).includes(this.currentScope); + }, + showDivider() { + return !this.useSidebarNavigation; + }, + hrClasses() { + return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; + }, }, }; </script> <template> <filters-template> - <status-filter class="gl-mb-5" /> + <status-filter v-if="showStatusFilter" class="gl-mb-5" /> + <hr v-if="showArchivedFilter && showDivider" :class="hrClasses" /> + <archived-filter v-if="showArchivedFilter" class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/notes_filters.vue b/app/assets/javascripts/search/sidebar/components/notes_filters.vue new file mode 100644 index 00000000000..3a9f582d554 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/notes_filters.vue @@ -0,0 +1,18 @@ +<script> +import ArchivedFilter from './archived_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'NotesFilters', + components: { + ArchivedFilter, + FiltersTemplate, + }, +}; +</script> + +<template> + <filters-template> + <archived-filter class="gl-mb-5" /> + </filters-template> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue index e8d5de4d769..a4c1119736f 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue @@ -57,7 +57,7 @@ export default { </script> <template> - <nav data-testid="search-filter"> + <nav data-testid="search-filter" class="gl-border-none"> <gl-nav vertical pills> <gl-nav-item v-for="(item, scope) in navigation" @@ -81,6 +81,5 @@ export default { </span> </gl-nav-item> </gl-nav> - <hr class="gl-mt-5 gl-mx-5 gl-mb-0 gl-border-gray-100 gl-md-display-none" /> </nav> </template> diff --git a/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue b/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue new file mode 100644 index 00000000000..e966b8d877e --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue @@ -0,0 +1,61 @@ +<script> +import { GlDrawer } from '@gitlab/ui'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'SmallScreenDrawerNavigation', + components: { + GlDrawer, + DomElementListener, + }, + i18n: { + smallScreenFiltersDrawerHeader: s__('GlobalSearch|Filters'), + }, + data() { + return { + openSmallScreenFilters: false, + }; + }, + computed: { + getDrawerHeaderHeight() { + if (!this.openSmallScreenFilters) return '0'; + return getContentWrapperHeight(); + }, + }, + methods: { + closeSmallScreenFilters() { + this.openSmallScreenFilters = false; + }, + toggleSmallScreenFilters() { + this.openSmallScreenFilters = !this.openSmallScreenFilters; + }, + }, + DRAWER_Z_INDEX, +}; +</script> +<template> + <dom-element-listener selector="#js-open-mobile-filters" @click="toggleSmallScreenFilters"> + <gl-drawer + :header-height="getDrawerHeaderHeight" + :z-index="$options.DRAWER_Z_INDEX" + variant="sidebar" + class="small-screen-drawer-navigation" + :open="openSmallScreenFilters" + @close="closeSmallScreenFilters" + > + <template #title> + <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24"> + {{ $options.i18n.smallScreenFiltersDrawerHeader }} + </h2> + </template> + <template #default> + <div> + <slot></slot> + </div> + </template> + </gl-drawer> + </dom-element-listener> +</template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 01d0aad206c..19df875c292 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -2,6 +2,8 @@ export const SCOPE_ISSUES = 'issues'; export const SCOPE_MERGE_REQUESTS = 'merge_requests'; export const SCOPE_BLOB = 'blobs'; export const SCOPE_PROJECTS = 'projects'; +export const SCOPE_NOTES = 'notes'; +export const SCOPE_COMMITS = 'commits'; export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', @@ -19,3 +21,7 @@ export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block']; export const TRACKING_ACTION_CLICK = 'search:filters:click'; export const TRACKING_LABEL_APPLY = 'Apply Filters'; export const TRACKING_LABEL_RESET = 'Reset Filters'; + +export const SEARCH_TYPE_BASIC = 'basic'; +export const SEARCH_TYPE_ADVANCED = 'advanced'; +export const SEARCH_TYPE_ZOEKT = 'zoekt'; diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js index 415f6f7454c..3a699355dc9 100644 --- a/app/assets/javascripts/search/sidebar/index.js +++ b/app/assets/javascripts/search/sidebar/index.js @@ -8,9 +8,11 @@ export const sidebarInitState = () => { const el = document.getElementById('js-search-sidebar'); if (!el) return {}; - const { navigationJson } = el.dataset; + const { navigationJson, searchType } = el.dataset; + const navigationJsonParsed = JSON.parse(navigationJson); - return { navigationJsonParsed }; + + return { navigationJsonParsed, searchType }; }; export const initSidebar = (store) => { diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index a68a0f75a2f..211bbaf82cd 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -138,7 +138,7 @@ export const setLabelFilterSearch = ({ commit }, { value }) => { 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) { + if (!navItem.active && navItem.count_link) { return axios .get(navItem.count_link) .then(({ data: { count } }) => { diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index 65bb21f1b8a..b248681f053 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -33,7 +33,7 @@ export default { state.frequentItems[key] = data; }, [types.RECEIVE_NAVIGATION_COUNT](state, { key, count }) { - const item = { ...state.navigation[key], count }; + const item = { ...state.navigation[key], count, count_link: null }; state.navigation = { ...state.navigation, [key]: item }; }, [types.REQUEST_AGGREGATIONS](state) { diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 5407b08fa83..b4cd2af65ba 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; -const createState = ({ query, navigation, useSidebarNavigation }) => ({ +const createState = ({ query, navigation, useSidebarNavigation, searchType }) => ({ urlQuery: cloneDeep(query), query, groups: [], @@ -21,6 +21,7 @@ const createState = ({ query, navigation, useSidebarNavigation }) => ({ data: [], }, searchLabelString: '', + searchType, }); export default createState; diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index 2f02ef3475c..b15f89fc6a2 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -68,7 +68,8 @@ export const setFrequentItemToLS = (key, data, itemData) => { frequentItems.sort((a, b) => { if (a.frequency > b.frequency) { return -1; - } else if (a.frequency < b.frequency) { + } + if (a.frequency < b.frequency) { return 1; } return b.lastUsed - a.lastUsed; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index c7d89113895..32d46a0d4af 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,13 +1,18 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; +import Api from '~/api'; import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import { SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT } from '~/tracking/constants'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; -import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; +import { + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + TAB_VULNERABILITY_MANAGEMENT_INDEX, +} from './constants'; import FeatureCard from './feature_card.vue'; import TrainingProviderList from './training_provider_list.vue'; @@ -123,6 +128,11 @@ export default { dismissAlert() { this.errorMessage = ''; }, + tabChange(value) { + if (value === TAB_VULNERABILITY_MANAGEMENT_INDEX) { + Api.trackRedisHllUserEvent(SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT); + } + }, }, autoDevopsEnabledAlertStorageKey: AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, }; @@ -167,6 +177,7 @@ export default { data-testid="security-configuration-container" sync-active-tab-with-query-params lazy + @input="tabChange" > <gl-tab data-testid="security-testing-tab" diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index b427820144d..da213b0ed43 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -1,5 +1,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { __, s__ } from '~/locale'; +import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue'; import { REPORT_TYPE_SAST, @@ -210,6 +211,7 @@ export const securityFeatures = [ configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, type: REPORT_TYPE_DEPENDENCY_SCANNING, anchor: 'dependency-scanning', + slotComponent: ContinuousVulnerabilityScan, }, { name: CONTAINER_SCANNING_NAME, @@ -326,3 +328,5 @@ export const TEMP_PROVIDER_URLS = { [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', SecureFlag: 'https://www.secureflag.com/', }; + +export const TAB_VULNERABILITY_MANAGEMENT_INDEX = 1; diff --git a/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue b/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue new file mode 100644 index 00000000000..61cbde2107c --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue @@ -0,0 +1,127 @@ +<script> +import { GlBadge, GlIcon, GlToggle, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; +import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + name: 'ContinuousVulnerabilityscan', + components: { GlBadge, GlIcon, GlToggle, GlLink, GlSprintf, GlAlert }, + mixins: [glFeatureFlagsMixin()], + inject: ['continuousVulnerabilityScansEnabled', 'projectFullPath'], + i18n: { + badgeLabel: __('Experiment'), + title: s__('CVS|Continuous Vulnerability Scan'), + description: s__( + 'CVS|Detect vulnerabilities outside a pipeline as new data is added to the GitLab Advisory Database.', + ), + learnMore: __('Learn more'), + testingAgreementMessage: s__( + 'CVS|By enabling this feature, you accept the %{linkStart}Testing Terms of Use%{linkEnd}', + ), + }, + props: { + feature: { + type: Object, + required: true, + }, + }, + data() { + return { + toggleValue: this.continuousVulnerabilityScansEnabled, + errorMessage: '', + isAlertDismissed: false, + }; + }, + computed: { + isFeatureConfigured() { + return this.feature.available && this.feature.configured; + }, + shouldShowAlert() { + return this.errorMessage && !this.isAlertDismissed; + }, + }, + methods: { + reportError(error) { + this.errorMessage = error; + this.isAlertDismissed = false; + }, + async toggleCVS(checked) { + try { + const { data } = await this.$apollo.mutate({ + mutation: ProjectSetContinuousVulnerabilityScanning, + variables: { + input: { + projectPath: this.projectFullPath, + enable: checked, + }, + }, + }); + + const { errors } = data.projectSetContinuousVulnerabilityScanning; + + if (errors.length > 0) { + this.reportError(errors[0].message); + } + if (data.projectSetContinuousVulnerabilityScanning !== null) { + this.toggleValue = checked; + } + } catch (error) { + this.reportError(error); + } + }, + }, + CVSHelpPagePath: helpPagePath( + 'user/application_security/continuous_vulnerability_scanning/index', + ), + experimentHelpPagePath: helpPagePath('policy/experiment-beta-support', { anchor: 'experiment' }), +}; +</script> + +<template> + <div v-if="glFeatures.dependencyScanningOnAdvisoryIngestion"> + <h4 class="gl-font-base gl-m-0 gl-mt-6"> + {{ $options.i18n.title }} + <gl-badge + ref="badge" + :href="$options.experimentHelpPagePath" + target="_blank" + size="sm" + variant="neutral" + class="gl-cursor-pointer" + >{{ $options.i18n.badgeLabel }}</gl-badge + > + </h4> + <gl-alert + v-if="shouldShowAlert" + class="gl-mb-5 gl-mt-2" + variant="danger" + @dismiss="isAlertDismissed = true" + >{{ errorMessage }}</gl-alert + > + <gl-toggle + class="gl-mt-5" + :disabled="!isFeatureConfigured" + :value="toggleValue" + :label="s__('CVS|Toggle CVS')" + label-position="hidden" + @change="toggleCVS" + /> + + <p class="gl-mb-0 gl-mt-5"> + {{ $options.i18n.description }} + <gl-link :href="$options.CVSHelpPagePath" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link> + <br /> + <gl-sprintf :message="$options.i18n.testingAgreementMessage"> + <template #link="{ content }"> + <gl-link href="https://about.gitlab.com/handbook/legal/testing-agreement" target="_blank"> + {{ content }} <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index a757657339b..7f0a049a6ad 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -73,6 +73,9 @@ export default { hasSecondary() { return Boolean(this.feature.secondary); }, + hasSlotComponent() { + return Boolean(this.feature.slotComponent); + }, // This condition is a temporary hack to not display any wrong information // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307. // More Information: https://gitlab.com/gitlab-org/gitlab/-/issues/350307#note_825447417 @@ -215,5 +218,9 @@ export default { {{ $options.i18n.configurationGuide }} </gl-button> </div> + + <div v-if="hasSlotComponent"> + <component :is="feature.slotComponent" :feature="feature" /> + </div> </gl-card> </template> diff --git a/app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql b/app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql new file mode 100644 index 00000000000..79f4316d106 --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql @@ -0,0 +1,8 @@ +mutation ProjectSetContinuousVulnerabilityScanning( + $input: ProjectSetContinuousVulnerabilityScanningInput! +) { + projectSetContinuousVulnerabilityScanning(input: $input) { + continuousVulnerabilityScanningEnabled + errors + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index aa3c9c87622..4b498091134 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -26,6 +26,7 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, + continuousVulnerabilityScansEnabled, } = el.dataset; const { augmentedSecurityFeatures } = augmentFeatures( @@ -43,6 +44,7 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, + continuousVulnerabilityScansEnabled, }, render(createElement) { return createElement(SecurityConfigurationApp, { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 72e6d870e13..7f0caf1af46 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,5 +1,6 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; +import { REPORT_TYPE_DAST } from '~/vue_shared/security_reports/constants'; /** * This function takes in 3 arrays of objects, securityFeatures and features. @@ -29,6 +30,10 @@ export const augmentFeatures = (securityFeatures, features = []) => { augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] }; } + if (augmented.type === REPORT_TYPE_DAST && !augmented.onDemandAvailable) { + delete augmented.badge; + } + if (augmented.badge && augmented.metaInfoPath) { augmented.badge.badgeHref = augmented.metaInfoPath; } diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js index cf6a79fe939..940caea3322 100644 --- a/app/assets/javascripts/sentry/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -1,35 +1,4 @@ import '../webpack'; +import { initSentry } from './init_sentry'; -import * as Sentry from 'sentrybrowser7'; -import SentryConfig from './sentry_config'; - -const index = function index() { - // Configuration for newer versions of Sentry SDK (v7) - SentryConfig.init({ - dsn: gon.sentry_dsn, - environment: gon.sentry_environment, - currentUserId: gon.current_user_id, - allowUrls: - process.env.NODE_ENV === 'production' - ? [gon.gitlab_url] - : [gon.gitlab_url, 'webpack-internal://'], - release: gon?.version, - tags: { - revision: gon?.revision, - feature_category: gon?.feature_category, - page: document?.body?.dataset?.page, - }, - }); -}; - -index(); - -// The _Sentry object is globally exported so it can be used by -// ./sentry_browser_wrapper.js -// This hack allows us to load a single version of `@sentry/browser` -// in the browser, see app/views/layouts/_head.html.haml to find how it is imported. - -// eslint-disable-next-line no-underscore-dangle -window._Sentry = Sentry; - -export default index; +initSentry(); diff --git a/app/assets/javascripts/sentry/init_sentry.js b/app/assets/javascripts/sentry/init_sentry.js new file mode 100644 index 00000000000..dbd12dc36ce --- /dev/null +++ b/app/assets/javascripts/sentry/init_sentry.js @@ -0,0 +1,77 @@ +import { + BrowserClient, + getCurrentHub, + defaultStackParser, + makeFetchTransport, + defaultIntegrations, + + // exports + captureException, + captureMessage, + withScope, + SDK_VERSION, +} from 'sentrybrowser'; + +const initSentry = () => { + if (!gon?.sentry_dsn) { + return; + } + + const hub = getCurrentHub(); + + const client = new BrowserClient({ + // Sentry.init(...) options + dsn: gon.sentry_dsn, + release: gon.version, + allowUrls: + process.env.NODE_ENV === 'production' + ? [gon.gitlab_url] + : [gon.gitlab_url, 'webpack-internal://'], + environment: gon.sentry_environment, + + // Browser tracing configuration + tracePropagationTargets: [/^\//], // only trace internal requests + tracesSampleRate: gon.sentry_clientside_traces_sample_rate || 0, + + // This configuration imitates the Sentry.init() default configuration + // https://github.com/getsentry/sentry-javascript/blob/7.66.0/MIGRATION.md#explicit-client-options + transport: makeFetchTransport, + stackParser: defaultStackParser, + integrations: defaultIntegrations, + }); + + hub.bindClient(client); + + hub.setTags({ + revision: gon.revision, + feature_category: gon.feature_category, + page: document?.body?.dataset?.page, + }); + + if (gon.current_user_id) { + hub.setUser({ + id: gon.current_user_id, + }); + } + + // The option `autoSessionTracking` is only avaialble on Sentry.init + // this manually starts a session in a similar way. + // See: https://github.com/getsentry/sentry-javascript/blob/7.66.0/packages/browser/src/sdk.ts#L204 + hub.startSession({ ignoreDuration: true }); // `ignoreDuration` counts only the page view. + hub.captureSession(); + + // The _Sentry object is globally exported so it can be used by + // ./sentry_browser_wrapper.js + // This hack allows us to load a single version of `@sentry/browser` + // in the browser, see app/views/layouts/_head.html.haml to find how it is imported. + + // eslint-disable-next-line no-underscore-dangle + window._Sentry = { + captureException, + captureMessage, + withScope, + SDK_VERSION, // used to verify compatibility with the Sentry instance + }; +}; + +export { initSentry }; diff --git a/app/assets/javascripts/sentry/sentry_browser_wrapper.js b/app/assets/javascripts/sentry/sentry_browser_wrapper.js index 0382827f82c..fbfd5d4f458 100644 --- a/app/assets/javascripts/sentry/sentry_browser_wrapper.js +++ b/app/assets/javascripts/sentry/sentry_browser_wrapper.js @@ -5,6 +5,8 @@ // This module wraps methods used by our production code. // Each export is names as we cannot export the entire namespace from *. + +/** @type {import('@sentry/core').captureException} */ export const captureException = (...args) => { // eslint-disable-next-line no-underscore-dangle const Sentry = window._Sentry; @@ -12,6 +14,7 @@ export const captureException = (...args) => { Sentry?.captureException(...args); }; +/** @type {import('@sentry/core').captureMessage} */ export const captureMessage = (...args) => { // eslint-disable-next-line no-underscore-dangle const Sentry = window._Sentry; @@ -19,6 +22,7 @@ export const captureMessage = (...args) => { Sentry?.captureMessage(...args); }; +/** @type {import('@sentry/core').withScope} */ export const withScope = (...args) => { // eslint-disable-next-line no-underscore-dangle const Sentry = window._Sentry; diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js deleted file mode 100644 index 80f087691f4..00000000000 --- a/app/assets/javascripts/sentry/sentry_config.js +++ /dev/null @@ -1,31 +0,0 @@ -import * as Sentry from 'sentrybrowser7'; - -const SentryConfig = { - init(options = {}) { - this.options = options; - - this.configure(); - if (this.options.currentUserId) this.setUser(); - }, - - configure() { - const { dsn, release, tags, allowUrls, environment } = this.options; - - Sentry.init({ - dsn, - release, - allowUrls, - environment, - }); - - Sentry.setTags(tags); - }, - - setUser() { - Sentry.setUser({ - id: this.options.currentUserId, - }); - }, -}; - -export default SentryConfig; diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index fe5b21713a2..da948cc85b6 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { InternalEvents } from '~/tracking'; import { __ } from './locale'; /** @@ -47,6 +48,15 @@ export function toggleSection($section) { } } +export function initTrackProductAnalyticsExpanded() { + const $analyticsSection = $('#js-product-analytics-settings'); + $analyticsSection.on('click.toggleSection', '.js-settings-toggle', () => { + if (isExpanded($analyticsSection)) { + InternalEvents.track_event('user_viewed_cluster_configuration'); + } + }); +} + export default function initSettingsPanels() { $('.settings').each((i, elm) => { const $section = $(elm); @@ -64,4 +74,6 @@ export default function initSettingsPanels() { } } }); + + initTrackProductAnalyticsExpanded(); } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index 319699b88f3..cf77a5ca82c 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,6 +1,6 @@ <script> import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { assigneesQueries } from '../../constants'; +import { assigneesQueries } from '../../queries/constants'; export default { subscription: null, diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 577c01c50ff..8a912b00df1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -84,7 +84,8 @@ export default { if (mergeLength === this.users.length) { return ''; - } else if (mergeLength > 0) { + } + if (mergeLength > 0) { return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { mergeLength, usersLength: this.users.length, 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 ae81dcb95de..4ff12824008 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -6,7 +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 { assigneesQueries } from '../../constants'; +import { assigneesQueries } from '../../queries/constants'; import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarAssigneesRealtime from './assignees_realtime.vue'; import IssuableAssignees from './issuable_assignees.vue'; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index b41d126be68..232cdcd2198 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -15,7 +15,7 @@ export default { }, computed: { triggerSource() { - return `${this.issuableType}-assignee-dropdown`; + return `${this.issuableType}_assignee_dropdown`; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 930e7ff12d9..ef7f12f273f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,4 +1,5 @@ <script> +import { GlButton } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { __, sprintf } from '~/locale'; @@ -9,6 +10,7 @@ const DEFAULT_RENDER_COUNT = 5; export default { components: { + GlButton, AssigneeAvatarLink, UserNameWithStatus, }, @@ -97,10 +99,11 @@ export default { </assignee-avatar-link> </div> </div> - <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800"> - <button - type="button" - class="btn-link gl-button gl-reset-color!" + <div v-if="renderShowMoreSection" class="gl-hover-text-blue-800" data-testid="user-list-more"> + <gl-button + category="tertiary" + size="small" + data-testid="user-list-more-button" data-qa-selector="more_assignees_link" @click="toggleShowLess" > @@ -108,7 +111,7 @@ export default { {{ hiddenAssigneesLabel }} </template> <template v-else>{{ __('- show less') }}</template> - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index 3038cec03eb..7a1853b1b46 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,9 +1,9 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { TYPE_ISSUE } from '~/issues/constants'; +import { TYPE_ISSUE, TYPE_TEST_CASE, IssuableTypeText } from '~/issues/constants'; import { __, sprintf } from '~/locale'; -import { confidentialityQueries } from '../../constants'; +import { confidentialityQueries } from '../../queries/constants'; export default { i18n: { @@ -11,7 +11,7 @@ export default { 'You are going to turn on confidentiality. Only %{context} members with %{strongStart}%{permissions}%{strongEnd} can view or be notified about this %{issuableType}.', ), confidentialityOffWarning: __( - 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.', + 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see%{commentText} this %{issuableType}.', ), }, components: { @@ -56,11 +56,17 @@ export default { isIssue() { return this.issuableType === TYPE_ISSUE; }, + isTestCase() { + return this.issuableType === TYPE_TEST_CASE; + }, + isIssueOrTestCase() { + return this.isIssue || this.isTestCase; + }, context() { - return this.isIssue ? __('project') : __('group'); + return this.isIssueOrTestCase ? __('project') : __('group'); }, workspacePath() { - return this.isIssue + return this.isIssueOrTestCase ? { projectPath: this.fullPath, } @@ -73,6 +79,12 @@ export default { ? __('at least the Reporter role, the author, and assignees') : __('at least the Reporter role'); }, + issuableTypeText() { + return IssuableTypeText[this.issuableType]; + }, + commentText() { + return this.isTestCase ? '' : __(' and leave a comment on'); + }, }, methods: { submitForm() { @@ -108,7 +120,7 @@ export default { message: sprintf( __('Something went wrong while setting %{issuableType} confidentiality.'), { - issuableType: this.issuableType, + issuableType: this.issuableTypeText, }, ), }); @@ -135,7 +147,8 @@ export default { </strong> </template> <template #context>{{ context }}</template> - <template #issuableType>{{ issuableType }}</template> + <template #commentText>{{ commentText }}</template> + <template #issuableType>{{ issuableTypeText }}</template> </gl-sprintf> </p> <div class="sidebar-item-warning-message-actions"> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index 9177baec246..295d37671cc 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -3,7 +3,8 @@ import produce from 'immer'; import Vue from 'vue'; import { createAlert } from '~/alert'; import { __, sprintf } from '~/locale'; -import { confidentialityQueries, Tracking } from '../../constants'; +import { Tracking } from '../../constants'; +import { confidentialityQueries } from '../../queries/constants'; import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue'; import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue'; diff --git a/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue index 3287539e502..7a488bb379f 100644 --- a/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue +++ b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -import { referenceQueries } from '../../constants'; +import { referenceQueries } from '../../queries/constants'; import CopyableField from './copyable_field.vue'; export default { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 5a9545f3460..89bc4b126d6 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -4,7 +4,8 @@ import { createAlert } from '~/alert'; import { TYPE_ISSUE } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; -import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants'; +import { dateFields, dateTypes, Tracking } from '../../constants'; +import { dueDateQueries, startDateQueries } from '../../queries/constants'; import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarFormattedDate from './sidebar_formatted_date.vue'; import SidebarInheritDate from './sidebar_inherit_date.vue'; diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue index 6db332a82da..576043963de 100644 --- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue @@ -3,11 +3,8 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { logError } from '~/lib/logger'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; -import { - escalationStatusQuery, - escalationStatusMutation, - INCIDENTS_I18N as i18n, -} from '../../constants'; +import { INCIDENTS_I18N as i18n } from '../../constants'; +import { escalationStatusQuery, escalationStatusMutation } from '../../queries/constants'; import { getStatusLabel } from '../../utils'; import SidebarEditableItem from '../sidebar_editable_item.vue'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js index 03ace6286e0..3ab7757d34d 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js @@ -19,7 +19,8 @@ export const dropdownButtonText = (state, getters) => { if (!selectedLabels.length) { return state.dropdownButtonText || __('Label'); - } else if (selectedLabels.length > 1) { + } + if (selectedLabels.length > 1) { return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { firstLabelName: selectedLabels[0].title, remainingLabelCount: selectedLabels.length - 1, diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue index 53582aacabd..a513c247be7 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue @@ -101,7 +101,8 @@ export default { buttonText() { if (!this.localSelectedLabels.length) { return this.dropdownButtonText || __('Label'); - } else if (this.localSelectedLabels.length > 1) { + } + if (this.localSelectedLabels.length > 1) { return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { firstLabelName: this.localSelectedLabels[0].title, remainingLabelCount: this.localSelectedLabels.length - 1, 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 45778640957..93e3cfba309 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,4 +1,5 @@ <script> +import { get } from 'lodash'; import { GlAlert, GlTooltipDirective, @@ -11,8 +12,7 @@ import produce from 'immer'; import { createAlert } from '~/alert'; import { WORKSPACE_GROUP } from '~/issues/constants'; import { __ } from '~/locale'; -import { workspaceLabelsQueries } from '../../../constants'; -import createLabelMutation from './graphql/create_label.mutation.graphql'; +import { workspaceLabelsQueries, workspaceCreateLabelMutation } from '../../../queries/constants'; import { DEFAULT_LABEL_COLOR } from './constants'; const errorMessage = __('Error creating label.'); @@ -68,13 +68,19 @@ export default { return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); }, mutationVariables() { - const attributePath = this.labelCreateType === WORKSPACE_GROUP ? 'groupPath' : 'projectPath'; - - return { + const variables = { title: this.labelTitle, color: this.selectedColor, - [attributePath]: this.attrWorkspacePath, }; + + if (this.labelCreateType) { + const attributePath = + this.labelCreateType === WORKSPACE_GROUP ? 'groupPath' : 'projectPath'; + + return { ...variables, [attributePath]: this.attrWorkspacePath }; + } + + return variables; }, }, methods: { @@ -88,7 +94,7 @@ export default { this.selectedColor = this.getColorCode(color); }, updateLabelsInCache(store, label) { - const { query } = workspaceLabelsQueries[this.workspaceType]; + const { query, dataPath } = workspaceLabelsQueries[this.workspaceType]; const sourceData = store.readQuery({ query, @@ -97,7 +103,7 @@ export default { const collator = new Intl.Collator('en'); const data = produce(sourceData, (draftData) => { - const { nodes } = draftData.workspace.labels; + const { nodes } = get(draftData, dataPath); nodes.push(label); nodes.sort((a, b) => collator.compare(a.title, b.title)); }); @@ -114,7 +120,7 @@ export default { const { data: { labelCreate }, } = await this.$apollo.mutate({ - mutation: createLabelMutation, + mutation: workspaceCreateLabelMutation[this.workspaceType], variables: this.mutationVariables, update: ( store, diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue index 19fe78aca87..fc8834a97d4 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue @@ -4,7 +4,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { workspaceLabelsQueries } from '../../../constants'; +import { workspaceLabelsQueries } from '../../../queries/constants'; import LabelItem from './label_item.vue'; export default { @@ -135,6 +135,16 @@ export default { this.handleLabelClick(this.visibleLabels[0]); } }, + handleFocus(event, index) { + if (index === 0 && event.target.classList.contains('is-focused')) { + event.target.classList.remove('is-focused'); + + // Focus next element (if available) as the first item was already focused. + if (event.target.parentNode?.nextElementSibling?.querySelector('button')) { + event.target.parentNode.nextElementSibling.querySelector('button').focus(); + } + } + }, }, }; </script> @@ -157,6 +167,7 @@ export default { :active="shouldHighlightFirstItem && index === 0" active-class="is-focused" data-testid="labels-list" + @focus.native.capture="handleFocus($event, index)" @click.native.capture.stop="handleLabelClick(label)" > <label-item :label="label" /> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue index e67e704ffb8..d6b43698766 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue @@ -13,7 +13,13 @@ export default { }, footerManageLabelTitle: { type: String, - required: true, + required: false, + default: '', + }, + }, + computed: { + showManageLabelsItem() { + return this.footerManageLabelTitle && this.labelsManagePath; }, }, }; @@ -28,7 +34,12 @@ export default { > {{ footerCreateLabelTitle }} </gl-dropdown-item> - <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> + <gl-dropdown-item + v-if="showManageLabelsItem" + data-testid="manage-labels-button" + :href="labelsManagePath" + @click.capture.native.stop + > {{ footerManageLabelTitle }} </gl-dropdown-item> </div> 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 74c3f08a47b..f9a9cc316c1 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 @@ -7,7 +7,7 @@ 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 { issuableLabelsQueries } from '../../../constants'; +import { issuableLabelsQueries } from '../../../queries/constants'; import SidebarEditableItem from '../../sidebar_editable_item.vue'; import { DEBOUNCE_DROPDOWN_DELAY, VARIANT_SIDEBAR } from './constants'; import DropdownContents from './dropdown_contents.vue'; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 7b288e15a3e..99d36a61632 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -138,13 +138,10 @@ export default { </a> </div> </div> - <div v-if="hasMoreParticipants" class="participants-more hide-collapsed"> - <gl-button - variant="link" - button-text-classes="gl-text-secondary" - @click="toggleMoreParticipants" - >{{ toggleLabel }}</gl-button - > + <div v-if="hasMoreParticipants" class="hide-collapsed"> + <gl-button category="tertiary" size="small" @click="toggleMoreParticipants">{{ + toggleLabel + }}</gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue index b0556e22a8d..b764d660d63 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -import { participantsQueries } from '../../constants'; +import { participantsQueries } from '../../queries/constants'; import Participants from './participants.vue'; export default { diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 88a74784dd2..415c40b4779 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -52,7 +52,8 @@ export default { if (mergeLength === this.users.length) { return ''; - } else if (mergeLength > 0) { + } + if (mergeLength > 0) { return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { mergeLength, usersLength: this.users.length, diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue index 50b4284cde0..c9450244b40 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue @@ -20,13 +20,13 @@ import { defaultEpicSort, dropdowni18nText, epicIidPattern, - issuableAttributesQueries, IssuableAttributeState, IssuableAttributeType, IssuableAttributeTypeKeyMap, LocalizedIssuableAttributeType, noAttributeId, } from 'ee_else_ce/sidebar/constants'; +import { issuableAttributesQueries } from 'ee_else_ce/sidebar/queries/constants'; import { createAlert } from '~/alert'; import { PathIdSeparator } from '~/related_issues/constants'; diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 4721c6fee61..7fde43a360d 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -11,10 +11,10 @@ import { dropdowni18nText, LocalizedIssuableAttributeType, IssuableAttributeTypeKeyMap, - issuableAttributesQueries, IssuableAttributeType, Tracking, } from 'ee_else_ce/sidebar/constants'; +import { issuableAttributesQueries } from 'ee_else_ce/sidebar/queries/constants'; import SidebarDropdown from './sidebar_dropdown.vue'; import SidebarEditableItem from './sidebar_editable_item.vue'; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index d6e1847aecb..568962cddc7 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -13,7 +13,8 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import toast from '~/vue_shared/plugins/global_toast'; -import { subscribedQueries, Tracking } from '../../constants'; +import { Tracking } from '../../constants'; +import { subscribedQueries } from '../../queries/constants'; import SidebarEditableItem from '../sidebar_editable_item.vue'; const ICON_ON = 'notifications'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 465f971717f..ac05ae3896b 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -42,11 +42,14 @@ export default { divClass() { if (this.showComparisonState) { return 'compare'; - } else if (this.showEstimateOnlyState) { + } + if (this.showEstimateOnlyState) { return 'estimate-only'; - } else if (this.showSpentOnlyState) { + } + if (this.showSpentOnlyState) { return 'spend-only'; - } else if (this.showNoTimeTrackingState) { + } + if (this.showNoTimeTrackingState) { return 'no-tracking'; } @@ -55,9 +58,11 @@ export default { spanClass() { if (this.showComparisonState) { return ''; - } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { + } + if (this.showEstimateOnlyState || this.showSpentOnlyState) { return 'bold'; - } else if (this.showNoTimeTrackingState) { + } + if (this.showNoTimeTrackingState) { return 'no-value collapse-truncated-title gl-pt-2 gl-px-3 gl-font-sm'; } @@ -66,11 +71,14 @@ export default { text() { if (this.showComparisonState) { return `${this.timeSpentHumanReadable} / ${this.timeEstimateHumanReadable}`; - } else if (this.showEstimateOnlyState) { + } + if (this.showEstimateOnlyState) { return `-- / ${this.timeEstimateHumanReadable}`; - } else if (this.showSpentOnlyState) { + } + if (this.showSpentOnlyState) { return `${this.timeSpentHumanReadable} / --`; - } else if (this.showNoTimeTrackingState) { + } + if (this.showNoTimeTrackingState) { return __('None'); } diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 70d8024f46a..9bd4c7f5c68 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -7,7 +7,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; -import { timelogQueries } from '../../constants'; +import { timelogQueries } from '../../queries/constants'; import deleteTimelogMutation from '../../queries/delete_timelog.mutation.graphql'; const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 1d427a871e1..aff592d48e0 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -12,7 +12,8 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; -import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '../../constants'; +import { HOW_TO_TRACK_TIME } from '../../constants'; +import { timeTrackingQueries } from '../../queries/constants'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; @@ -122,9 +123,11 @@ export default { // 3. issuableIid and fullPath are not provided if (!this.issuableType || !timeTrackingQueries[this.issuableType]) { return true; - } else if (this.initialTimeTracking) { + } + if (this.initialTimeTracking) { return true; - } else if (!this.issuableIid || !this.fullPath) { + } + if (!this.issuableIid || !this.fullPath) { return true; } return false; 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 551d306a9c4..1099dcb832f 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,8 @@ 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 { todoQueries, TodoMutationTypes, todoMutations } from '../../constants'; +import { TodoMutationTypes } from '../../constants'; +import { todoQueries, todoMutations } from '../../queries/constants'; import { todoLabel } from '../../utils'; import TodoButton from './todo_button.vue'; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 0f82182c6e2..f13f613733b 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,173 +1,10 @@ import { invert } from 'lodash'; import { s__, __, sprintf } from '~/locale'; -import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; -import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; -import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; -import { - TYPE_ALERT, - TYPE_EPIC, - TYPE_ISSUE, - TYPE_MERGE_REQUEST, - TYPE_TEST_CASE, - WORKSPACE_GROUP, - WORKSPACE_PROJECT, -} from '~/issues/constants'; -import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; -import issuableDatesUpdatedSubscription from '../graphql_shared/subscriptions/work_item_dates.subscription.graphql'; -import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; -import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; -import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; -import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.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 epicConfidentialQuery from './queries/epic_confidential.query.graphql'; -import epicDueDateQuery from './queries/epic_due_date.query.graphql'; -import epicParticipantsQuery from './queries/epic_participants.query.graphql'; -import epicReferenceQuery from './queries/epic_reference.query.graphql'; -import epicStartDateQuery from './queries/epic_start_date.query.graphql'; -import epicSubscribedQuery from './queries/epic_subscribed.query.graphql'; -import epicTodoQuery from './queries/epic_todo.query.graphql'; -import issuableAssigneesSubscription from './queries/issuable_assignees.subscription.graphql'; -import issueConfidentialQuery from './queries/issue_confidential.query.graphql'; -import issueDueDateQuery from './queries/issue_due_date.query.graphql'; -import issueReferenceQuery from './queries/issue_reference.query.graphql'; -import issueSubscribedQuery from './queries/issue_subscribed.query.graphql'; -import issueTimeTrackingQuery from './queries/issue_time_tracking.query.graphql'; -import issueTodoQuery from './queries/issue_todo.query.graphql'; -import mergeRequestMilestone from './queries/merge_request_milestone.query.graphql'; -import mergeRequestReferenceQuery from './queries/merge_request_reference.query.graphql'; -import mergeRequestSubscribed from './queries/merge_request_subscribed.query.graphql'; -import mergeRequestTimeTrackingQuery from './queries/merge_request_time_tracking.query.graphql'; -import mergeRequestTodoQuery from './queries/merge_request_todo.query.graphql'; -import todoCreateMutation from './queries/todo_create.mutation.graphql'; -import todoMarkDoneMutation from './queries/todo_mark_done.mutation.graphql'; -import updateEpicConfidentialMutation from './queries/update_epic_confidential.mutation.graphql'; -import updateEpicDueDateMutation from './queries/update_epic_due_date.mutation.graphql'; -import updateEpicStartDateMutation from './queries/update_epic_start_date.mutation.graphql'; -import updateEpicSubscriptionMutation from './queries/update_epic_subscription.mutation.graphql'; -import updateIssueConfidentialMutation from './queries/update_issue_confidential.mutation.graphql'; -import updateIssueDueDateMutation from './queries/update_issue_due_date.mutation.graphql'; -import updateIssueSubscriptionMutation from './queries/update_issue_subscription.mutation.graphql'; -import mergeRequestMilestoneMutation from './queries/update_merge_request_milestone.mutation.graphql'; -import updateMergeRequestLabelsMutation from './queries/update_merge_request_labels.mutation.graphql'; -import updateMergeRequestSubscriptionMutation from './queries/update_merge_request_subscription.mutation.graphql'; -import getAlertAssignees from './queries/get_alert_assignees.query.graphql'; -import getIssueAssignees from './queries/get_issue_assignees.query.graphql'; -import issueParticipantsQuery from './queries/get_issue_participants.query.graphql'; -import getIssueTimelogsQuery from './queries/get_issue_timelogs.query.graphql'; -import getMergeRequestAssignees from './queries/get_mr_assignees.query.graphql'; -import getMergeRequestParticipants from './queries/get_mr_participants.query.graphql'; -import getMrTimelogsQuery from './queries/get_mr_timelogs.query.graphql'; -import updateIssueAssigneesMutation from './queries/update_issue_assignees.mutation.graphql'; -import updateMergeRequestAssigneesMutation from './queries/update_mr_assignees.mutation.graphql'; -import getEscalationStatusQuery from './queries/escalation_status.query.graphql'; -import updateEscalationStatusMutation from './queries/update_escalation_status.mutation.graphql'; -import groupMilestonesQuery from './queries/group_milestones.query.graphql'; -import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; -import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; -import projectMilestonesQuery from './queries/project_milestones.query.graphql'; export const defaultEpicSort = 'TITLE_ASC'; export const epicIidPattern = /^&(?<iid>\d+)$/; -export const assigneesQueries = { - [TYPE_ISSUE]: { - query: getIssueAssignees, - subscription: issuableAssigneesSubscription, - mutation: updateIssueAssigneesMutation, - }, - [TYPE_MERGE_REQUEST]: { - query: getMergeRequestAssignees, - mutation: updateMergeRequestAssigneesMutation, - }, - [TYPE_ALERT]: { - query: getAlertAssignees, - mutation: updateAlertAssigneesMutation, - }, -}; - -export const participantsQueries = { - [TYPE_ISSUE]: { - query: issueParticipantsQuery, - }, - [TYPE_MERGE_REQUEST]: { - query: getMergeRequestParticipants, - }, - [TYPE_EPIC]: { - query: epicParticipantsQuery, - }, - [TYPE_ALERT]: { - query: '', - skipQuery: true, - }, -}; - -export const userSearchQueries = { - [TYPE_ISSUE]: { - query: userSearchQuery, - }, - [TYPE_MERGE_REQUEST]: { - query: userSearchWithMRPermissionsQuery, - }, -}; - -export const confidentialityQueries = { - [TYPE_ISSUE]: { - query: issueConfidentialQuery, - mutation: updateIssueConfidentialMutation, - }, - [TYPE_EPIC]: { - query: epicConfidentialQuery, - mutation: updateEpicConfidentialMutation, - }, -}; - -export const referenceQueries = { - [TYPE_ISSUE]: { - query: issueReferenceQuery, - }, - [TYPE_MERGE_REQUEST]: { - query: mergeRequestReferenceQuery, - }, - [TYPE_EPIC]: { - query: epicReferenceQuery, - }, -}; - -export const workspaceLabelsQueries = { - [WORKSPACE_PROJECT]: { - query: projectLabelsQuery, - }, - [WORKSPACE_GROUP]: { - query: groupLabelsQuery, - }, -}; - -export const issuableLabelsQueries = { - [TYPE_ISSUE]: { - issuableQuery: issueLabelsQuery, - mutation: updateIssueLabelsMutation, - mutationName: 'updateIssue', - }, - [TYPE_MERGE_REQUEST]: { - issuableQuery: mergeRequestLabelsQuery, - mutation: updateMergeRequestLabelsMutation, - mutationName: 'mergeRequestSetLabels', - }, - [TYPE_EPIC]: { - issuableQuery: epicLabelsQuery, - mutation: updateEpicLabelsMutation, - mutationName: 'updateEpic', - }, - [TYPE_TEST_CASE]: { - issuableQuery: issueLabelsQuery, - mutation: updateTestCaseLabelsMutation, - mutationName: 'updateTestCaseLabels', - }, -}; - export const dateTypes = { start: 'startDate', due: 'dueDate', @@ -186,91 +23,13 @@ export const dateFields = { }, }; -export const subscribedQueries = { - [TYPE_ISSUE]: { - query: issueSubscribedQuery, - mutation: updateIssueSubscriptionMutation, - }, - [TYPE_EPIC]: { - query: epicSubscribedQuery, - mutation: updateEpicSubscriptionMutation, - }, - [TYPE_MERGE_REQUEST]: { - query: mergeRequestSubscribed, - mutation: updateMergeRequestSubscriptionMutation, - }, -}; - export const Tracking = { editEvent: 'click_edit_button', rightSidebarLabel: 'right_sidebar', }; -export const timeTrackingQueries = { - [TYPE_ISSUE]: { - query: issueTimeTrackingQuery, - }, - [TYPE_MERGE_REQUEST]: { - query: mergeRequestTimeTrackingQuery, - }, -}; - -export const dueDateQueries = { - [TYPE_ISSUE]: { - query: issueDueDateQuery, - mutation: updateIssueDueDateMutation, - subscription: issuableDatesUpdatedSubscription, - }, - [TYPE_EPIC]: { - query: epicDueDateQuery, - mutation: updateEpicDueDateMutation, - }, -}; - -export const startDateQueries = { - [TYPE_EPIC]: { - query: epicStartDateQuery, - mutation: updateEpicStartDateMutation, - }, -}; - -export const timelogQueries = { - [TYPE_ISSUE]: { - query: getIssueTimelogsQuery, - }, - [TYPE_MERGE_REQUEST]: { - query: getMrTimelogsQuery, - }, -}; - export const noAttributeId = null; -export const issuableMilestoneQueries = { - [TYPE_ISSUE]: { - query: projectIssueMilestoneQuery, - mutation: projectIssueMilestoneMutation, - }, - [TYPE_MERGE_REQUEST]: { - query: mergeRequestMilestone, - mutation: mergeRequestMilestoneMutation, - }, -}; - -export const milestonesQueries = { - [TYPE_ISSUE]: { - query: { - [WORKSPACE_GROUP]: groupMilestonesQuery, - [WORKSPACE_PROJECT]: projectMilestonesQuery, - }, - }, - [TYPE_MERGE_REQUEST]: { - query: { - [WORKSPACE_GROUP]: groupMilestonesQuery, - [WORKSPACE_PROJECT]: projectMilestonesQuery, - }, - }, -}; - export const IssuableAttributeType = { Milestone: 'milestone', }; @@ -285,35 +44,11 @@ export const IssuableAttributeState = { [IssuableAttributeType.Milestone]: 'active', }; -export const issuableAttributesQueries = { - [IssuableAttributeType.Milestone]: { - current: issuableMilestoneQueries, - list: milestonesQueries, - }, -}; - -export const todoQueries = { - [TYPE_EPIC]: { - query: epicTodoQuery, - }, - [TYPE_ISSUE]: { - query: issueTodoQuery, - }, - [TYPE_MERGE_REQUEST]: { - query: mergeRequestTodoQuery, - }, -}; - export const TodoMutationTypes = { Create: 'create', MarkDone: 'mark-done', }; -export const todoMutations = { - [TodoMutationTypes.Create]: todoCreateMutation, - [TodoMutationTypes.MarkDone]: todoMarkDoneMutation, -}; - export function dropdowni18nText(issuableAttribute, issuableType) { return { noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { @@ -362,9 +97,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) { }; } -export const escalationStatusQuery = getEscalationStatusQuery; -export const escalationStatusMutation = updateEscalationStatusMutation; - export const HOW_TO_TRACK_TIME = __('How to track time'); export const statusDropdownOptions = [ diff --git a/app/assets/javascripts/sidebar/queries/constants.js b/app/assets/javascripts/sidebar/queries/constants.js new file mode 100644 index 00000000000..0844abc4599 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/constants.js @@ -0,0 +1,291 @@ +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import userAutocompleteQuery from '~/graphql_shared/queries/project_autocomplete_users.query.graphql'; +import userAutocompleteWithMRPermissionsQuery from '~/graphql_shared/queries/project_autocomplete_users_with_mr_permissions.query.graphql'; +import issuableDatesUpdatedSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; +import { + TYPE_ALERT, + TYPE_EPIC, + TYPE_ISSUE, + TYPE_MERGE_REQUEST, + TYPE_TEST_CASE, + WORKSPACE_GROUP, + WORKSPACE_PROJECT, +} from '~/issues/constants'; +import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import abuseReportLabelsQuery from '~/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql'; +import createAbuseReportLabelMutation from '~/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql'; +import createGroupOrProjectLabelMutation from '../components/labels/labels_select_widget/graphql/create_label.mutation.graphql'; +import updateTestCaseLabelsMutation from '../components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; +import epicLabelsQuery from '../components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; +import updateEpicLabelsMutation from '../components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; +import groupLabelsQuery from '../components/labels/labels_select_widget/graphql/group_labels.query.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 epicConfidentialQuery from './epic_confidential.query.graphql'; +import epicDueDateQuery from './epic_due_date.query.graphql'; +import epicParticipantsQuery from './epic_participants.query.graphql'; +import epicReferenceQuery from './epic_reference.query.graphql'; +import epicStartDateQuery from './epic_start_date.query.graphql'; +import epicSubscribedQuery from './epic_subscribed.query.graphql'; +import epicTodoQuery from './epic_todo.query.graphql'; +import issuableAssigneesSubscription from './issuable_assignees.subscription.graphql'; +import issueConfidentialQuery from './issue_confidential.query.graphql'; +import issueDueDateQuery from './issue_due_date.query.graphql'; +import issueReferenceQuery from './issue_reference.query.graphql'; +import issueSubscribedQuery from './issue_subscribed.query.graphql'; +import issueTimeTrackingQuery from './issue_time_tracking.query.graphql'; +import issueTodoQuery from './issue_todo.query.graphql'; +import mergeRequestMilestone from './merge_request_milestone.query.graphql'; +import mergeRequestReferenceQuery from './merge_request_reference.query.graphql'; +import mergeRequestSubscribed from './merge_request_subscribed.query.graphql'; +import mergeRequestTimeTrackingQuery from './merge_request_time_tracking.query.graphql'; +import mergeRequestTodoQuery from './merge_request_todo.query.graphql'; +import todoCreateMutation from './todo_create.mutation.graphql'; +import todoMarkDoneMutation from './todo_mark_done.mutation.graphql'; +import updateEpicConfidentialMutation from './update_epic_confidential.mutation.graphql'; +import updateEpicDueDateMutation from './update_epic_due_date.mutation.graphql'; +import updateEpicStartDateMutation from './update_epic_start_date.mutation.graphql'; +import updateEpicSubscriptionMutation from './update_epic_subscription.mutation.graphql'; +import updateIssueConfidentialMutation from './update_issue_confidential.mutation.graphql'; +import updateIssueDueDateMutation from './update_issue_due_date.mutation.graphql'; +import updateIssueSubscriptionMutation from './update_issue_subscription.mutation.graphql'; +import mergeRequestMilestoneMutation from './update_merge_request_milestone.mutation.graphql'; +import updateMergeRequestLabelsMutation from './update_merge_request_labels.mutation.graphql'; +import updateMergeRequestSubscriptionMutation from './update_merge_request_subscription.mutation.graphql'; +import getAlertAssignees from './get_alert_assignees.query.graphql'; +import getIssueAssignees from './get_issue_assignees.query.graphql'; +import issueParticipantsQuery from './get_issue_participants.query.graphql'; +import getIssueTimelogsQuery from './get_issue_timelogs.query.graphql'; +import getMergeRequestAssignees from './get_mr_assignees.query.graphql'; +import getMergeRequestParticipants from './get_mr_participants.query.graphql'; +import getMrTimelogsQuery from './get_mr_timelogs.query.graphql'; +import updateIssueAssigneesMutation from './update_issue_assignees.mutation.graphql'; +import updateMergeRequestAssigneesMutation from './update_mr_assignees.mutation.graphql'; +import getEscalationStatusQuery from './escalation_status.query.graphql'; +import updateEscalationStatusMutation from './update_escalation_status.mutation.graphql'; +import groupMilestonesQuery from './group_milestones.query.graphql'; +import projectIssueMilestoneMutation from './project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from './project_issue_milestone.query.graphql'; +import projectMilestonesQuery from './project_milestones.query.graphql'; +import testCaseConfidentialQuery from './test_case_confidential.query.graphql'; +import updateTestCaseConfidentialMutation from './update_test_case_confidential.mutation.graphql'; + +export const assigneesQueries = { + [TYPE_ISSUE]: { + query: getIssueAssignees, + subscription: issuableAssigneesSubscription, + mutation: updateIssueAssigneesMutation, + }, + [TYPE_MERGE_REQUEST]: { + query: getMergeRequestAssignees, + mutation: updateMergeRequestAssigneesMutation, + }, + [TYPE_ALERT]: { + query: getAlertAssignees, + mutation: updateAlertAssigneesMutation, + }, +}; + +export const participantsQueries = { + [TYPE_ISSUE]: { + query: issueParticipantsQuery, + }, + [TYPE_MERGE_REQUEST]: { + query: getMergeRequestParticipants, + }, + [TYPE_EPIC]: { + query: epicParticipantsQuery, + }, + [TYPE_ALERT]: { + query: '', + skipQuery: true, + }, +}; + +export const userSearchQueries = { + [TYPE_ISSUE]: { + query: userAutocompleteQuery, + }, + [TYPE_MERGE_REQUEST]: { + query: userAutocompleteWithMRPermissionsQuery, + }, +}; + +export const confidentialityQueries = { + [TYPE_ISSUE]: { + query: issueConfidentialQuery, + mutation: updateIssueConfidentialMutation, + }, + [TYPE_EPIC]: { + query: epicConfidentialQuery, + mutation: updateEpicConfidentialMutation, + }, + [TYPE_TEST_CASE]: { + query: testCaseConfidentialQuery, + mutation: updateTestCaseConfidentialMutation, + }, +}; + +export const referenceQueries = { + [TYPE_ISSUE]: { + query: issueReferenceQuery, + }, + [TYPE_MERGE_REQUEST]: { + query: mergeRequestReferenceQuery, + }, + [TYPE_EPIC]: { + query: epicReferenceQuery, + }, +}; + +export const workspaceLabelsQueries = { + [WORKSPACE_PROJECT]: { + query: projectLabelsQuery, + dataPath: 'workspace.labels', + }, + [WORKSPACE_GROUP]: { + query: groupLabelsQuery, + dataPath: 'workspace.labels', + }, + abuseReport: { + query: abuseReportLabelsQuery, + dataPath: 'labels', + }, +}; + +export const workspaceCreateLabelMutation = { + [WORKSPACE_PROJECT]: createGroupOrProjectLabelMutation, + [WORKSPACE_GROUP]: createGroupOrProjectLabelMutation, + abuseReport: createAbuseReportLabelMutation, +}; + +export const issuableLabelsQueries = { + [TYPE_ISSUE]: { + issuableQuery: issueLabelsQuery, + mutation: updateIssueLabelsMutation, + mutationName: 'updateIssue', + }, + [TYPE_MERGE_REQUEST]: { + issuableQuery: mergeRequestLabelsQuery, + mutation: updateMergeRequestLabelsMutation, + mutationName: 'mergeRequestSetLabels', + }, + [TYPE_EPIC]: { + issuableQuery: epicLabelsQuery, + mutation: updateEpicLabelsMutation, + mutationName: 'updateEpic', + }, + [TYPE_TEST_CASE]: { + issuableQuery: issueLabelsQuery, + mutation: updateTestCaseLabelsMutation, + mutationName: 'updateTestCaseLabels', + }, +}; + +export const subscribedQueries = { + [TYPE_ISSUE]: { + query: issueSubscribedQuery, + mutation: updateIssueSubscriptionMutation, + }, + [TYPE_EPIC]: { + query: epicSubscribedQuery, + mutation: updateEpicSubscriptionMutation, + }, + [TYPE_MERGE_REQUEST]: { + query: mergeRequestSubscribed, + mutation: updateMergeRequestSubscriptionMutation, + }, +}; + +export const timeTrackingQueries = { + [TYPE_ISSUE]: { + query: issueTimeTrackingQuery, + }, + [TYPE_MERGE_REQUEST]: { + query: mergeRequestTimeTrackingQuery, + }, +}; + +export const dueDateQueries = { + [TYPE_ISSUE]: { + query: issueDueDateQuery, + mutation: updateIssueDueDateMutation, + subscription: issuableDatesUpdatedSubscription, + }, + [TYPE_EPIC]: { + query: epicDueDateQuery, + mutation: updateEpicDueDateMutation, + }, +}; + +export const startDateQueries = { + [TYPE_EPIC]: { + query: epicStartDateQuery, + mutation: updateEpicStartDateMutation, + }, +}; + +export const timelogQueries = { + [TYPE_ISSUE]: { + query: getIssueTimelogsQuery, + }, + [TYPE_MERGE_REQUEST]: { + query: getMrTimelogsQuery, + }, +}; + +export const issuableMilestoneQueries = { + [TYPE_ISSUE]: { + query: projectIssueMilestoneQuery, + mutation: projectIssueMilestoneMutation, + }, + [TYPE_MERGE_REQUEST]: { + query: mergeRequestMilestone, + mutation: mergeRequestMilestoneMutation, + }, +}; + +export const milestonesQueries = { + [TYPE_ISSUE]: { + query: { + [WORKSPACE_GROUP]: groupMilestonesQuery, + [WORKSPACE_PROJECT]: projectMilestonesQuery, + }, + }, + [TYPE_MERGE_REQUEST]: { + query: { + [WORKSPACE_GROUP]: groupMilestonesQuery, + [WORKSPACE_PROJECT]: projectMilestonesQuery, + }, + }, +}; + +export const issuableAttributesQueries = { + [IssuableAttributeType.Milestone]: { + current: issuableMilestoneQueries, + list: milestonesQueries, + }, +}; + +export const todoQueries = { + [TYPE_EPIC]: { + query: epicTodoQuery, + }, + [TYPE_ISSUE]: { + query: issueTodoQuery, + }, + [TYPE_MERGE_REQUEST]: { + query: mergeRequestTodoQuery, + }, +}; + +export const todoMutations = { + [TodoMutationTypes.Create]: todoCreateMutation, + [TodoMutationTypes.MarkDone]: todoMarkDoneMutation, +}; + +export const escalationStatusQuery = getEscalationStatusQuery; + +export const escalationStatusMutation = updateEscalationStatusMutation; diff --git a/app/assets/javascripts/sidebar/queries/test_case_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/test_case_confidential.query.graphql new file mode 100644 index 00000000000..d8959b5ce3f --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/test_case_confidential.query.graphql @@ -0,0 +1,9 @@ +query testCaseConfidential($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + confidential + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_test_case_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_test_case_confidential.mutation.graphql new file mode 100644 index 00000000000..4094907cb95 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_test_case_confidential.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateTestCaseConfidential($input: IssueSetConfidentialInput!) { + issuableSetConfidential: issueSetConfidential(input: $input) { + issuable: issue { + id + confidential + } + errors + } +} diff --git a/app/assets/javascripts/silent_mode_settings/components/app.vue b/app/assets/javascripts/silent_mode_settings/components/app.vue new file mode 100644 index 00000000000..2dd0449448c --- /dev/null +++ b/app/assets/javascripts/silent_mode_settings/components/app.vue @@ -0,0 +1,70 @@ +<script> +import { GlToggle, GlBadge } from '@gitlab/ui'; +import { updateApplicationSettings } from '~/rest_api'; +import { createAlert } from '~/alert'; +import toast from '~/vue_shared/plugins/global_toast'; +import { sprintf, __, s__ } from '~/locale'; + +export default { + name: 'SilentModeSettingsApp', + i18n: { + toggleLabel: s__('SilentMode|Enable silent mode'), + saveSuccess: s__('SilentMode|Silent mode %{status}'), + saveError: s__('SilentMode|There was an error updating the Silent Mode Settings.'), + enabled: __('enabled'), + disabled: __('disabled'), + experiment: __('Experiment'), + }, + components: { + GlToggle, + GlBadge, + }, + props: { + isSilentModeEnabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isLoading: false, + silentModeEnabled: this.isSilentModeEnabled, + }; + }, + methods: { + updateSilentModeSettings() { + this.isLoading = true; + + updateApplicationSettings({ + silent_mode_enabled: this.silentModeEnabled, + }) + .then(() => { + const status = this.silentModeEnabled + ? this.$options.i18n.enabled + : this.$options.i18n.disabled; + toast(sprintf(this.$options.i18n.saveSuccess, { status })); + }) + .catch(() => { + createAlert({ message: this.$options.i18n.saveError }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> +<template> + <gl-toggle + v-model="silentModeEnabled" + label-id="silent-mode-toggle" + :label="$options.i18n.toggleLabel" + :is-loading="isLoading" + @change="updateSilentModeSettings" + > + <template #label + >{{ $options.i18n.toggleLabel }} <gl-badge>{{ $options.i18n.experiment }}</gl-badge></template + > + </gl-toggle> +</template> diff --git a/app/assets/javascripts/silent_mode_settings/index.js b/app/assets/javascripts/silent_mode_settings/index.js new file mode 100644 index 00000000000..b18f9c02964 --- /dev/null +++ b/app/assets/javascripts/silent_mode_settings/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import SilentModeSettingsApp from './components/app.vue'; + +Vue.use(Translate); + +export const initSilentModeSettings = () => { + const el = document.getElementById('js-silent-mode-settings'); + + if (!el) { + return false; + } + + const { silentModeEnabled } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(SilentModeSettingsApp, { + props: { + isSilentModeEnabled: parseBoolean(silentModeEnabled), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue index 0fdbc89a038..17312c2373b 100644 --- a/app/assets/javascripts/snippets/components/embed_dropdown.vue +++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue @@ -1,12 +1,5 @@ <script> -import { - GlButton, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownText, - GlFormInputGroup, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown, GlFormInputGroup, GlTooltipDirective } from '@gitlab/ui'; import { escape as esc } from 'lodash'; import { __ } from '~/locale'; @@ -17,9 +10,7 @@ const MSG_COPY = __('Copy'); export default { components: { GlButton, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownText, + GlDisclosureDropdown, GlFormInputGroup, }, directives: { @@ -45,22 +36,16 @@ export default { }; </script> <template> - <gl-dropdown - right - :text="$options.MSG_EMBED" - menu-class="gl-px-1! gl-pb-5! gl-dropdown-menu-wide" + <gl-disclosure-dropdown + :auto-close="false" + fluid-width + placement="right" + :toggle-text="$options.MSG_EMBED" > <template v-for="{ name, value } in sections"> - <gl-dropdown-section-header :key="`header_${name}`" data-testid="header">{{ - name - }}</gl-dropdown-section-header> - <gl-dropdown-text - :key="`input_${name}`" - tag="div" - class="gl-dropdown-text-py-0 gl-dropdown-text-block" - data-testid="input" - > - <gl-form-input-group :value="value" readonly select-on-click :label="name"> + <div :key="name" :data-testid="`section-${name}`" class="gl-px-4 gl-py-2"> + <h5 class="gl-font-sm gl-mt-1 gl-mb-2" data-testid="header">{{ name }}</h5> + <gl-form-input-group class="gl-w-31" :value="value" readonly select-on-click :label="name"> <template #append> <gl-button v-gl-tooltip.hover @@ -73,7 +58,7 @@ export default { /> </template> </gl-form-input-group> - </gl-dropdown-text> + </div> </template> - </gl-dropdown> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js index a228d6111ce..97f21654aae 100644 --- a/app/assets/javascripts/snippets/utils/blob.js +++ b/app/assets/javascripts/snippets/utils/blob.js @@ -34,7 +34,8 @@ const diff = ({ content, path }, origBlob) => { content, filePath: path, }; - } else if (origBlob.path !== path || origBlob.content !== content) { + } + if (origBlob.path !== path || origBlob.content !== content) { return { action: origBlob.path === path ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE, previousPath: origBlob.path, diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue index 1589f4978e1..02cf36fb053 100644 --- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue +++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue @@ -26,20 +26,28 @@ export default { <template> <a - v-gl-tooltip:super-sidebar.hover.noninteractive.bottom.ds500="$options.i18n.homepage" + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" class="brand-logo" :href="rootPath" - :title="$options.i18n.homepage" data-track-action="click_link" data-track-label="gitlab_logo_link" data-track-property="nav_core_menu" > + <span class="gl-sr-only">{{ $options.i18n.homepage }}</span> + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> <img v-if="logoUrl" + alt="" data-testid="brand-header-custom-logo" :src="logoUrl" class="gl-h-6 gl-max-w-full" /> - <span v-else v-safe-html="$options.logo" data-testid="brand-header-default-logo"></span> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + <span + v-else + v-safe-html="$options.logo" + aria-hidden + data-testid="brand-header-default-logo" + ></span> </a> </template> diff --git a/app/assets/javascripts/super_sidebar/components/context_header.vue b/app/assets/javascripts/super_sidebar/components/context_header.vue deleted file mode 100644 index 11b9840a409..00000000000 --- a/app/assets/javascripts/super_sidebar/components/context_header.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlTruncate, - GlAvatar, - GlIcon, - }, - props: { - /* - * Contains metadata about the current view, e.g. `id`, `title` and `avatar` - */ - context: { - type: Object, - required: true, - }, - tag: { - type: String, - required: false, - default: 'div', - }, - }, - computed: { - avatarShape() { - return this.context.avatar_shape || 'rect'; - }, - }, -}; -</script> - -<template> - <component - :is="tag" - class="border-top border-bottom gl-border-gray-a-08! gl-display-flex gl-align-items-center gl-gap-3 gl-font-weight-bold gl-w-full gl-h-8 gl-px-4 gl-flex-shrink-0" - > - <span - v-if="context.icon" - class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24" - > - <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" /> - </span> - <gl-avatar - v-else - :size="24" - :shape="avatarShape" - :entity-name="context.title" - :entity-id="context.id" - :src="context.avatar" - /> - <div class="gl-flex-grow-1 gl-overflow-auto gl-text-gray-900"> - <gl-truncate :text="context.title" /> - </div> - <slot name="end"></slot> - </component> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue deleted file mode 100644 index d4aa11b6e04..00000000000 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ /dev/null @@ -1,209 +0,0 @@ -<script> -import * as Sentry from '@sentry/browser'; -import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql'; -import { trackContextAccess, formatContextSwitcherItems } from '../utils'; -import NavItem from './nav_item.vue'; -import ProjectsList from './projects_list.vue'; -import GroupsList from './groups_list.vue'; -import ContextSwitcherToggle from './context_switcher_toggle.vue'; - -export default { - i18n: { - contextNavigation: s__('Navigation|Context navigation'), - switchTo: s__('Navigation|Switch context'), - searchPlaceholder: s__('Navigation|Search your projects or groups'), - searchingLabel: s__('Navigation|Retrieving search results'), - searchError: s__('Navigation|There was an error fetching search results.'), - }, - apollo: { - groupsAndProjects: { - query: searchUserProjectsAndGroups, - manual: true, - variables() { - return { - username: this.username, - search: this.searchString, - }; - }, - result(response) { - this.hasError = false; - try { - const { - data: { - projects: { nodes: projects }, - user: { - groups: { nodes: groups }, - }, - }, - } = response; - - this.projects = formatContextSwitcherItems(projects); - this.groups = formatContextSwitcherItems(groups); - } catch (e) { - this.handleError(e); - } - }, - error(e) { - this.handleError(e); - }, - skip() { - return !this.searchString; - }, - }, - }, - components: { - GlDisclosureDropdown, - ContextSwitcherToggle, - GlSearchBoxByType, - GlLoadingIcon, - GlAlert, - NavItem, - ProjectsList, - GroupsList, - }, - inject: ['contextSwitcherLinks'], - props: { - username: { - type: String, - required: true, - }, - projectsPath: { - type: String, - required: true, - }, - groupsPath: { - type: String, - required: true, - }, - currentContext: { - type: Object, - required: false, - default: () => ({}), - }, - contextHeader: { - type: Object, - required: true, - }, - }, - data() { - return { - searchString: '', - projects: [], - groups: [], - hasError: false, - isOpen: false, - }; - }, - computed: { - isSearch() { - return Boolean(this.searchString); - }, - isSearching() { - return this.$apollo.queries.groupsAndProjects.loading; - }, - }, - watch: { - isOpen(isOpen) { - this.$emit('toggle', isOpen); - - if (isOpen) { - this.focusInput(); - } - }, - }, - created() { - if (this.currentContext.namespace) { - trackContextAccess(this.username, this.currentContext); - } - }, - methods: { - close() { - this.$refs['disclosure-dropdown'].close(); - }, - focusInput() { - this.$refs['search-box'].focusInput(); - }, - handleError(e) { - Sentry.captureException(e); - this.hasError = true; - }, - onDisclosureDropdownShown() { - this.isOpen = true; - }, - onDisclosureDropdownHidden() { - this.isOpen = false; - }, - }, - DEFAULT_DEBOUNCE_AND_THROTTLE_MS, -}; -</script> - -<template> - <gl-disclosure-dropdown - ref="disclosure-dropdown" - class="context-switcher gl-w-full" - placement="center" - @shown="onDisclosureDropdownShown" - @hidden="onDisclosureDropdownHidden" - > - <template #toggle> - <context-switcher-toggle :context="contextHeader" :expanded="isOpen" /> - </template> - <div aria-hidden="true" class="gl-font-sm gl-font-weight-bold gl-px-4 gl-pt-3 gl-pb-4"> - {{ $options.i18n.switchTo }} - </div> - <div class="gl-p-1 gl-border-t gl-border-b gl-border-gray-50 gl-bg-white"> - <gl-search-box-by-type - ref="search-box" - v-model="searchString" - class="context-switcher-search-box" - :placeholder="$options.i18n.searchPlaceholder" - :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS" - borderless - /> - </div> - <gl-loading-icon - v-if="isSearching" - class="gl-mt-5" - size="md" - :label="$options.i18n.searchingLabel" - /> - <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2"> - {{ $options.i18n.searchError }} - </gl-alert> - <nav v-else :aria-label="$options.i18n.contextNavigation" data-testid="context-navigation"> - <ul class="gl-p-0 gl-m-0 gl-list-style-none"> - <li v-if="!isSearch"> - <ul - :aria-label="$options.i18n.switchTo" - class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2" - > - <nav-item - v-for="item in contextSwitcherLinks" - :key="item.link" - :item="item" - :link-classes="{ [item.link_classes]: item.link_classes }" - is-subitem - /> - </ul> - </li> - <projects-list - :username="username" - :view-all-link="projectsPath" - :is-search="isSearch" - :search-results="projects" - /> - <groups-list - class="gl-border-t gl-border-gray-50" - :username="username" - :view-all-link="groupsPath" - :is-search="isSearch" - :search-results="groups" - /> - </ul> - </nav> - </gl-disclosure-dropdown> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue deleted file mode 100644 index faa7eba6470..00000000000 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import ContextHeader from './context_header.vue'; - -export default { - components: { - GlIcon, - ContextHeader, - }, - props: { - /* - * Contains metadata about the current view, e.g. `id`, `title` and `avatar` - */ - context: { - type: Object, - required: true, - }, - expanded: { - type: Boolean, - required: true, - }, - }, - computed: { - collapseIcon() { - return this.expanded ? 'chevron-up' : 'chevron-down'; - }, - }, -}; -</script> - -<template> - <context-header - :context="context" - tag="button" - type="button" - class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 gl-box-shadow-none gl-text-left" - data-testid="context-switcher" - > - <template #end> - <gl-icon class="gl-text-gray-400" :name="collapseIcon" /> - </template> - </context-header> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index 3645606515f..d1e96479631 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -1,7 +1,7 @@ <script> import { GlDisclosureDropdown, - GlTooltip, + GlTooltipDirective, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, } from '@gitlab/ui'; @@ -14,7 +14,7 @@ import { import { DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; // Left offset required for the dropdown to be aligned with the super sidebar -const DROPDOWN_X_OFFSET_BASE = -147; +const DROPDOWN_X_OFFSET_BASE = -179; const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { @@ -22,9 +22,11 @@ export default { GlDisclosureDropdown, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, - GlTooltip, InviteMembersTrigger, }, + directives: { + GlTooltip: GlTooltipDirective, + }, i18n: { createNew: __('Create new...'), }, @@ -59,45 +61,35 @@ export default { </script> <template> - <div> - <gl-disclosure-dropdown - category="tertiary" - icon="plus" - no-caret - text-sr-only - :toggle-text="$options.i18n.createNew" - :toggle-id="$options.toggleId" - :dropdown-offset="dropdownOffset" - data-qa-selector="new_menu_toggle" - data-testid="new-menu-toggle" - @shown="dropdownOpen = true" - @hidden="dropdownOpen = false" - > - <gl-disclosure-dropdown-group - v-for="(group, index) in groups" - :key="group.name" - :bordered="index !== 0" - :group="group" - > - <template v-for="groupItem in group.items"> - <invite-members-trigger - v-if="isInvitedMembers(groupItem)" - :key="`${groupItem.text}-trigger`" - trigger-source="top-nav" - :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN" - /> - <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" /> - </template> - </gl-disclosure-dropdown-group> - </gl-disclosure-dropdown> - <gl-tooltip - v-if="!dropdownOpen" - :target="`#${$options.toggleId}`" - placement="bottom" - container="#super-sidebar" - noninteractive + <gl-disclosure-dropdown + v-gl-tooltip:super-sidebar.hover.bottom="dropdownOpen ? '' : $options.i18n.createNew" + category="tertiary" + icon="plus" + no-caret + text-sr-only + :toggle-text="$options.i18n.createNew" + :toggle-id="$options.toggleId" + :dropdown-offset="dropdownOffset" + data-qa-selector="new_menu_toggle" + data-testid="new-menu-toggle" + @shown="dropdownOpen = true" + @hidden="dropdownOpen = false" + > + <gl-disclosure-dropdown-group + v-for="(group, index) in groups" + :key="group.name" + :bordered="index !== 0" + :group="group" > - {{ $options.i18n.createNew }} - </gl-tooltip> - </div> + <template v-for="groupItem in group.items"> + <invite-members-trigger + v-if="isInvitedMembers(groupItem)" + :key="`${groupItem.text}-trigger`" + trigger-source="top_nav" + :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN" + /> + <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" /> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue index fa7960da2f4..e73b9b275ee 100644 --- a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue @@ -2,6 +2,23 @@ import { computePosition, autoUpdate, offset, flip, shift } from '@floating-ui/dom'; import NavItem from './nav_item.vue'; +// Flyout menus are shown when the MenuSection's title is hovered with the mouse. +// Their position is dynamically calculated with floating-ui. +// +// Since flyout menus show all NavItems of a section, they can be very long and +// a user might want to move their mouse diagonally from the section title down +// to last nav item in the flyout. But this mouse movement over other sections +// would loose hover and close the flyout, opening another section's flyout. +// To avoid this annoyance, our flyouts come with a "diagonal tolerance". This +// is an area between the current mouse position and the top- and bottom-left +// corner of the flyout itself. While the mouse stays within this area and +// reaches the flyout before a timer expires, the native browser hover stays +// within the component. +// This is done with an transparent SVG positioned left of the flyout menu, +// overlapping the sidebar. The SVG itself ignores pointer events but its two +// triangles, one above the section title, one below, do listen to events, +// keeping hover. + export default { name: 'FlyoutMenu', components: { NavItem }, @@ -15,13 +32,45 @@ export default { required: true, }, }, + data() { + return { + currentMouseX: 0, + flyoutX: 0, + flyoutY: 0, + flyoutHeight: 0, + hoverTimeoutId: null, + showSVG: true, + targetRect: null, + }; + }, cleanupFunction: undefined, + computed: { + topSVGPoints() { + const x = (this.currentMouseX / this.targetRect.width) * 100; + let y = ((this.targetRect.top - this.flyoutY) / this.flyoutHeight) * 100; + y += 1; // overlap title to not loose hover + + return `${x}, ${y} 100, 0 100, ${y}`; + }, + bottomSVGPoints() { + const x = (this.currentMouseX / this.targetRect.width) * 100; + let y = ((this.targetRect.bottom - this.flyoutY) / this.flyoutHeight) * 100; + y -= 1; // overlap title to not loose hover + + return `${x}, ${y} 100, ${y} 100, 100`; + }, + }, + created() { + const target = document.querySelector(`#${this.targetId}`); + target.addEventListener('mousemove', this.onMouseMove); + }, mounted() { const target = document.querySelector(`#${this.targetId}`); const flyout = document.querySelector(`#${this.targetId}-flyout`); + const sidebar = document.querySelector('#super-sidebar'); - function updatePosition() { - return computePosition(target, flyout, { + const updatePosition = () => + computePosition(target, flyout, { middleware: [offset({ alignmentAxis: -12 }), flip(), shift()], placement: 'right-start', strategy: 'fixed', @@ -30,13 +79,46 @@ export default { left: `${x}px`, top: `${y}px`, }); + this.flyoutX = x; + this.flyoutY = y; + this.flyoutHeight = flyout.clientHeight; + + // Flyout coordinates are relative to the sidebar which can be + // shifted down by the performance-bar etc. + // Adjust viewport coordinates from getBoundingClientRect: + const targetRect = target.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + this.targetRect = { + top: targetRect.top - sidebarRect.top, + bottom: targetRect.bottom - sidebarRect.top, + width: targetRect.width, + }; }); - } this.$options.cleanupFunction = autoUpdate(target, flyout, updatePosition); }, beforeUnmount() { this.$options.cleanupFunction(); + clearTimeout(this.hoverTimeoutId); + }, + beforeDestroy() { + const target = document.querySelector(`#${this.targetId}`); + target.removeEventListener('mousemove', this.onMouseMove); + }, + methods: { + startHoverTimeout() { + this.hoverTimeoutId = setTimeout(() => { + this.showSVG = false; + this.$emit('mouseleave'); + }, 1000); + }, + stopHoverTimeout() { + clearTimeout(this.hoverTimeoutId); + }, + onMouseMove(e) { + // add some wiggle room to the left of mouse cursor + this.currentMouseX = Math.max(0, e.clientX - 5); + }, }, }; </script> @@ -49,8 +131,8 @@ export default { @mouseleave="$emit('mouseleave')" > <ul - v-if="items.length > 0" class="gl-min-w-20 gl-max-w-34 gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-100 gl-shadow-md gl-bg-white gl-p-2 gl-pb-1 gl-list-style-none" + @mouseenter="showSVG = false" > <nav-item v-for="item of items" @@ -61,5 +143,44 @@ export default { @pin-remove="(itemId) => $emit('pin-remove', itemId)" /> </ul> + <svg + v-if="targetRect && showSVG" + :width="flyoutX" + :height="flyoutHeight" + viewBox="0 0 100 100" + preserveAspectRatio="none" + :style="{ + top: flyoutY + 'px', + }" + > + <polygon + ref="topSVG" + :points="topSVGPoints" + fill="transparent" + @mouseenter="startHoverTimeout" + @mouseleave="stopHoverTimeout" + /> + <polygon + ref="bottomSVG" + :points="bottomSVGPoints" + fill="transparent" + @mouseenter="startHoverTimeout" + @mouseleave="stopHoverTimeout" + /> + </svg> </div> </template> + +<style scoped> +svg { + pointer-events: none; + + position: fixed; + right: 0; +} + +svg polygon, +svg rect { + pointer-events: auto; +} +</style> diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue deleted file mode 100644 index fe1a907bd91..00000000000 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { - getItemsFromLocalStorage, - removeItemFromLocalStorage, - formatContextSwitcherItems, -} from '../utils'; -import ItemsList from './items_list.vue'; - -export default { - components: { - GlButton, - ItemsList, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - title: { - type: String, - required: true, - }, - pristineText: { - type: String, - required: true, - }, - storageKey: { - type: String, - required: true, - }, - maxItems: { - type: Number, - required: true, - }, - }, - data() { - return { - cachedFrequentItems: [], - }; - }, - computed: { - isEmpty() { - return !this.cachedFrequentItems.length; - }, - }, - created() { - this.cachedFrequentItems = formatContextSwitcherItems( - getItemsFromLocalStorage({ - storageKey: this.storageKey, - maxItems: this.maxItems, - }), - ); - }, - methods: { - handleItemRemove(item) { - removeItemFromLocalStorage({ - storageKey: this.storageKey, - item, - }); - - this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); - }, - }, - i18n: { - removeItem: __('Remove'), - }, -}; -</script> - -<template> - <li class="gl-py-3"> - <div - data-testid="list-title" - aria-hidden="true" - class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-semibold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" - > - <span class="gl-flex-grow-1 gl-px-3">{{ title }}</span> - </div> - <div - v-if="isEmpty" - data-testid="empty-text" - class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3" - > - {{ pristineText }} - </div> - <items-list :aria-label="title" :items="cachedFrequentItems"> - <template #actions="{ item }"> - <gl-button - v-gl-tooltip.right.viewport - size="small" - category="tertiary" - icon="dash" - class="show-on-focus-or-hover--target" - :aria-label="$options.i18n.removeItem" - :title="$options.i18n.removeItem" - data-testid="item-remove" - @click.stop.prevent="handleItemRemove(item)" - /> - </template> - <template #view-all-items> - <slot name="view-all-items"></slot> - </template> - </items-list> - </li> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue index bd79962f1a1..b85b163cea9 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue @@ -5,6 +5,7 @@ import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import Tracking from '~/tracking'; import { getFormattedItem } from '../utils'; import { @@ -18,6 +19,8 @@ import { PATH_GROUP_TITLE, GROUP_TITLES, MAX_ROWS, + TRACKING_ACTIVATE_COMMAND_PALETTE, + TRACKING_HANDLE_LABEL_MAP, } from './constants'; import SearchItem from './search_item.vue'; import { commandMapper, linksReducer, autocompleteQuery, fileMapper } from './utils'; @@ -29,6 +32,7 @@ export default { GlLoadingIcon, SearchItem, }, + mixins: [Tracking.mixin()], inject: [ 'commandPaletteCommands', 'commandPaletteLinks', @@ -134,10 +138,15 @@ export default { immediate: true, }, handle: { - handler() { - this.debouncedSearch(); + handler(value, oldValue) { + // Do not run search immediately on component creation + if (oldValue !== undefined) this.debouncedSearch(); + + // Track immediately on component creation + const label = TRACKING_HANDLE_LABEL_MAP[value] ?? 'unknown'; + this.track(TRACKING_ACTIVATE_COMMAND_PALETTE, { label }); }, - immediate: false, + immediate: true, }, }, updated() { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js index a43e621da44..f6f4e36e43a 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js @@ -6,6 +6,16 @@ export const PROJECT_HANDLE = ':'; export const ISSUE_HANDLE = '#'; export const PATH_HANDLE = '/'; +export const TRACKING_ACTIVATE_COMMAND_PALETTE = 'activate_command_palette'; +export const TRACKING_CLICK_COMMAND_PALETTE_ITEM = 'click_command_palette_item'; +export const TRACKING_HANDLE_LABEL_MAP = { + [COMMAND_HANDLE]: 'command', + [USER_HANDLE]: 'user', + [PROJECT_HANDLE]: 'project', + [PATH_HANDLE]: 'path', + // No ISSUE_HANDLE. See https://gitlab.com/gitlab-org/gitlab/-/issues/417434. +}; + export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE]; export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf( s__( diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js index 347a8ffb0b4..32abbbfd3c2 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js @@ -1,6 +1,11 @@ import { isNil, omitBy } from 'lodash'; import { objectToQuery, joinPaths } from '~/lib/utils/url_utility'; -import { SEARCH_SCOPE, GLOBAL_COMMANDS_GROUP_TITLE } from './constants'; +import { TRACKING_UNKNOWN_ID } from '~/super_sidebar/constants'; +import { + SEARCH_SCOPE, + GLOBAL_COMMANDS_GROUP_TITLE, + TRACKING_CLICK_COMMAND_PALETTE_ITEM, +} from './constants'; export const commandMapper = ({ name, items }) => { // TODO: we filter out invite_members for now, because it is complicated to add the invite members modal here @@ -12,18 +17,34 @@ export const commandMapper = ({ name, items }) => { }; export const linksReducer = (acc, menuItem) => { + const trackingAttrs = ({ id, title }) => { + return { + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': id || TRACKING_UNKNOWN_ID, + ...(id + ? {} + : { + 'data-track-extra': JSON.stringify({ title }), + }), + }, + }; + }; + acc.push({ text: menuItem.title, keywords: menuItem.title, icon: menuItem.icon, href: menuItem.link, + ...trackingAttrs(menuItem), }); if (menuItem.items?.length) { - const items = menuItem.items.map(({ title, link }) => ({ - keywords: title, - text: [menuItem.title, title].join(' > '), - href: link, + const items = menuItem.items.map((item) => ({ + keywords: item.title, + text: [menuItem.title, item.title].join(' > '), + href: item.link, icon: menuItem.icon, + ...trackingAttrs(item), })); /* eslint-disable-next-line no-param-reassign */ @@ -37,6 +58,10 @@ export const fileMapper = (projectBlobPath, file) => { icon: 'doc-code', text: file, href: joinPaths(projectBlobPath, file), + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'file', + }, }; }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue index 382d844ceee..ddadd6856ca 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue @@ -2,6 +2,8 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; +import { TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; import FrequentItem from './frequent_item.vue'; export default { @@ -65,6 +67,12 @@ export default { // validator, and the href field ensures it renders a link. text: item.name, href: item.webUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': item.id, + 'data-track-property': TRACKING_UNKNOWN_PANEL, + 'data-track-extra': JSON.stringify({ title: item.name }), + }, }, forRenderer: { id: item.id, diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index b64f3ac52b2..4cfc329f8b8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -18,14 +18,12 @@ import { sprintf } from '~/locale'; import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { MIN_SEARCH_TERM, - SEARCH_GITLAB, SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, } from '~/vue_shared/global_search/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { darkModeEnabled } from '~/lib/utils/color_utils'; import { SEARCH_INPUT_DESCRIPTION, @@ -52,10 +50,10 @@ export default { name: 'GlobalSearchModal', SEARCH_MODAL_ID, i18n: { - SEARCH_GITLAB, SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, MIN_SEARCH_TERM, @@ -72,7 +70,6 @@ export default { CommandPaletteItems, FakeSearchInput, }, - mixins: [glFeatureFlagMixin()], data() { return { nextFocusedItemIndex: null, @@ -89,9 +86,6 @@ export default { this.setSearch(value); }, }, - searchPlaceholder() { - return this.glFeatures?.commandPalette ? SEARCH_OR_COMMAND_MODE_PLACEHOLDER : SEARCH_GITLAB; - }, showDefaultItems() { return !this.searchText; }, @@ -146,9 +140,8 @@ export default { }, isCommandMode() { return ( - this.glFeatures?.commandPalette && - (COMMON_HANDLES.includes(this.searchTextFirstChar) || - (this.searchContext.project && this.searchTextFirstChar === PATH_HANDLE)) + COMMON_HANDLES.includes(this.searchTextFirstChar) || + (this.searchContext?.project && this.searchTextFirstChar === PATH_HANDLE) ); }, commandPaletteQuery() { @@ -294,7 +287,7 @@ export default { > <form role="search" - :aria-label="searchPlaceholder" + :aria-label="$options.i18n.SEARCH_OR_COMMAND_MODE_PLACEHOLDER" class="gl-relative gl-rounded-base gl-w-full gl-pb-0" > <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3"> @@ -305,7 +298,7 @@ export default { role="searchbox" data-testid="global-search-input" autocomplete="off" - :placeholder="searchPlaceholder" + :placeholder="$options.i18n.SEARCH_OR_COMMAND_MODE_PLACEHOLDER" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" borderless @input="getAutocompleteOptions" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue index 9a375837102..9167be5c1cc 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue @@ -1,6 +1,8 @@ <script> import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { PLACES } from '~/vue_shared/global_search/constants'; +import { TRACKING_UNKNOWN_ID, TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; export default { name: 'DefaultPlaces', @@ -18,7 +20,23 @@ export default { group() { return { name: this.$options.i18n.PLACES, - items: this.contextSwitcherLinks.map(({ title, link }) => ({ text: title, href: link })), + items: this.contextSwitcherLinks.map(({ title, link }) => ({ + text: title, + href: link, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + // The label and property are hard-coded as unknown for now for + // parity with the existing corresponding context switcher items. + // Once the context switcher is removed, these can be changed. + 'data-track-label': TRACKING_UNKNOWN_ID, + 'data-track-property': TRACKING_UNKNOWN_PANEL, + 'data-track-extra': JSON.stringify({ title }), + + // QA attributes + 'data-testid': 'places-item-link', + 'data-qa-places-item': title, + }, + })), }; }, }, diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js index 6871dabc9a1..79be56f1427 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js @@ -14,6 +14,7 @@ import { SEARCH_RESULTS_ORDER, } from '~/vue_shared/global_search/constants'; import { getFormattedItem } from '../utils'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; import { ICON_GROUP, @@ -172,6 +173,10 @@ export const scopedSearchOptions = (state, getters) => { scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, href: getters.projectUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'scoped_in_project', + }, }); } @@ -182,6 +187,10 @@ export const scopedSearchOptions = (state, getters) => { scopeCategory: GROUPS_CATEGORY, icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, href: getters.groupUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'scoped_in_group', + }, }); } @@ -189,6 +198,10 @@ export const scopedSearchOptions = (state, getters) => { text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, href: getters.allUrl, + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': 'scoped_in_all', + }, }); return items; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js index 11d1fa1ab95..2c369cbdf5f 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/utils.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -1,5 +1,5 @@ import { pickBy } from 'lodash'; -import { truncateNamespace } from '~/lib/utils/text_utility'; +import { slugify, truncateNamespace } from '~/lib/utils/text_utility'; import { GROUPS_CATEGORY, PROJECTS_CATEGORY, @@ -7,6 +7,7 @@ import { ISSUES_CATEGORY, RECENT_EPICS_CATEGORY, } from '~/vue_shared/global_search/constants'; +import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from './command_palette/constants'; import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; const getTruncatedNamespace = (string) => { @@ -61,6 +62,15 @@ export const getFormattedItem = (item, searchContext) => { const avatarSize = getAvatarSize(category); const entityId = getEntityId(item, searchContext); const entityName = getEntityName(item, searchContext); + const trackingLabel = slugify(category ?? ''); + const trackingAttrs = trackingLabel + ? { + extraAttrs: { + 'data-track-action': TRACKING_CLICK_COMMAND_PALETTE_ITEM, + 'data-track-label': slugify(category, '_'), + }, + } + : {}; return pickBy( { @@ -75,6 +85,7 @@ export const getFormattedItem = (item, searchContext) => { namespace, entity_id: entityId, entity_name: entityName, + ...trackingAttrs, }, (val) => val !== undefined, ); diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue deleted file mode 100644 index 48becacebb7..00000000000 --- a/app/assets/javascripts/super_sidebar/components/groups_list.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import { MAX_FREQUENT_GROUPS_COUNT } from '../constants'; -import FrequentItemsList from './frequent_items_list.vue'; -import SearchResults from './search_results.vue'; -import NavItem from './nav_item.vue'; - -export default { - MAX_FREQUENT_GROUPS_COUNT, - components: { - FrequentItemsList, - SearchResults, - NavItem, - }, - props: { - username: { - type: String, - required: true, - }, - viewAllLink: { - type: String, - required: true, - }, - isSearch: { - type: Boolean, - required: false, - default: false, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - computed: { - storageKey() { - return `${this.username}/frequent-groups`; - }, - viewAllProps() { - return { - item: { - link: this.viewAllLink, - title: s__('Navigation|View all your groups'), - icon: 'group', - }, - linkClasses: { 'dashboard-shortcuts-groups': true }, - }; - }, - }, - i18n: { - title: s__('Navigation|Frequently visited groups'), - searchTitle: s__('Navigation|Groups'), - pristineText: s__('Navigation|Groups you visit often will appear here.'), - noResultsText: s__('Navigation|No group matches found'), - }, -}; -</script> - -<template> - <search-results - v-if="isSearch" - :title="$options.i18n.searchTitle" - :no-results-text="$options.i18n.noResultsText" - :search-results="searchResults" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </search-results> - <frequent-items-list - v-else - :title="$options.i18n.title" - :storage-key="storageKey" - :max-items="$options.MAX_FREQUENT_GROUPS_COUNT" - :pristine-text="$options.i18n.pristineText" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </frequent-items-list> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue deleted file mode 100644 index 1bad13f91e8..00000000000 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; -import NavItem from './nav_item.vue'; - -export default { - components: { - ProjectAvatar, - NavItem, - }, - props: { - items: { - type: Array, - required: false, - default: () => [], - }, - }, -}; -</script> - -<template> - <ul class="gl-p-0 gl-list-style-none"> - <nav-item - v-for="item in items" - :key="item.id" - :item="item" - is-subitem - class="show-on-focus-or-hover--context" - > - <template #icon> - <project-avatar - :project-id="item.id" - :project-name="item.title" - :project-avatar-url="item.avatar" - :size="24" - aria-hidden="true" - /> - </template> - <template #actions> - <slot name="actions" :item="item"></slot> - </template> - </nav-item> - <slot name="view-all-items"></slot> - </ul> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index d2d45ca7b6e..6b5002e1aa8 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -79,15 +79,26 @@ export default { isExpanded(newIsExpanded) { this.$emit('collapse-toggle', newIsExpanded); this.keepFlyoutClosed = !this.newIsExpanded; + if (!newIsExpanded) { + this.isMouseOverFlyout = false; + } }, }, methods: { handlePointerover(e) { + if (!this.hasFlyout) return; + this.isMouseOverSection = e.pointerType === 'mouse'; }, handlePointerleave() { - this.isMouseOverSection = false; + if (!this.hasFlyout) return; + this.keepFlyoutClosed = false; + // delay state change. otherwise the flyout menu gets removed before it + // has a chance to emit its mouseover event. + setTimeout(() => { + this.isMouseOverSection = false; + }, 5); }, }, }; @@ -129,8 +140,7 @@ export default { </button> <flyout-menu - v-if="hasFlyout" - v-show="isMouseOver && !isExpanded && !keepFlyoutClosed" + v-if="hasFlyout && isMouseOver && !isExpanded && !keepFlyoutClosed && item.items.length > 0" :target-id="`menu-section-button-${itemId}`" :items="item.items" @mouseover="isMouseOverFlyout = true" diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 36803a885e7..5e0f8fffb0e 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -1,6 +1,6 @@ <script> -import { GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAvatar, GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; import { CLICK_MENU_ITEM_ACTION, CLICK_PINNED_MENU_ITEM_ACTION, @@ -12,11 +12,14 @@ import NavItemRouterLink from './nav_item_router_link.vue'; export default { i18n: { + pin: s__('Navigation|Pin %{title}'), pinItem: s__('Navigation|Pin item'), + unpin: s__('Navigation|Unpin %{title}'), unpinItem: s__('Navigation|Unpin item'), }, name: 'NavItem', components: { + GlAvatar, GlButton, GlIcon, GlBadge, @@ -62,6 +65,12 @@ export default { default: false, }, }, + data() { + return { + isMouseIn: false, + canClickPinButton: false, + }; + }, computed: { pillData() { return this.item.pill_count; @@ -96,12 +105,27 @@ export default { ...extraData, }; }, + /** + * Some QA specs rely on a stable "Project overview"/"Group overview" nav + * item data-qa-submenu-item attribute value. + * + * This computed ensures that those particular nav items use the `id` of + * the item rather than its title for that QA attribute. + * + * In future, probably all nav items should do this, for consistency. + * See https://gitlab.com/gitlab-org/gitlab/-/issues/422925. + */ + qaSubMenuItem() { + const { id } = this.item; + if (id === 'project_overview' || id === 'group_overview') return id.replace(/_/g, '-'); + return this.item.title; + }, linkProps() { return { ...this.$attrs, ...this.trackingProps, item: this.item, - 'data-qa-submenu-item': this.item.title, + 'data-qa-submenu-item': this.qaSubMenuItem, 'data-method': this.item.data_method ?? null, }; }, @@ -118,26 +142,73 @@ export default { navItemLinkComponent() { return this.item.to ? NavItemRouterLink : NavItemLink; }, + hasAvatar() { + return Boolean(this.item.entity_id); + }, + avatarShape() { + return this.item.avatar_shape || 'rect'; + }, + pinAriaLabel() { + return sprintf(this.$options.i18n.pin, { + title: this.item.title, + }); + }, + unpinAriaLabel() { + return sprintf(this.$options.i18n.unpin, { + title: this.item.title, + }); + }, + activeIndicatorStyle() { + const style = { + width: '3px', + borderRadius: '3px', + marginRight: '1px', + }; + + // The active indicator is too close to the avatar for items with one, so shift + // it left by 1px. + // + // The indicator is absolutely positioned using rem units. This tweak for this + // edge case is in pixel units, so that it does not scale with root font size. + if (this.hasAvatar) style.transform = 'translateX(-1px)'; + + return style; + }, + }, + mounted() { + if (this.item.is_active) { + this.$el.scrollIntoView(false); + } + }, + methods: { + togglePointerEvents() { + this.canClickPinButton = this.isMouseIn; + }, }, }; </script> <template> - <li> + <li + class="gl-relative show-on-focus-or-hover--context hide-on-focus-or-hover--context transition-opacity-on-hover--context" + data-testid="nav-item" + @mouseenter="isMouseIn = true" + @mouseleave="isMouseIn = false" + > <component :is="navItemLinkComponent" #default="{ isActive }" v-bind="linkProps" - class="nav-item-link gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--context" + class="gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--control hide-on-focus-or-hover--control" :class="computedLinkClasses" - data-qa-selector="nav_item_link" data-testid="nav-item-link" + data-qa-selector="nav_item_link" > <div :class="[isActive ? 'gl-opacity-10' : 'gl-opacity-0']" class="active-indicator gl-bg-blue-500 gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow" aria-hidden="true" - style="width: 3px; border-radius: 3px; margin-right: 1px" + :style="activeIndicatorStyle" data-testid="active-indicator" ></div> <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> @@ -148,6 +219,14 @@ export default { name="grip" class="gl-m-auto gl-text-gray-400 js-draggable-icon gl-cursor-grab show-on-focus-or-hover--target" /> + <gl-avatar + v-else-if="hasAvatar" + :size="24" + :shape="avatarShape" + :entity-name="item.title" + :entity-id="item.entity_id" + :src="item.avatar" + /> </slot> </div> <div class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end"> @@ -157,36 +236,47 @@ export default { </div> </div> <slot name="actions"></slot> - <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative"> + <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative gl-min-w-8"> <gl-badge v-if="hasPill" size="sm" variant="neutral" - :class="{ 'nav-item-badge gl-absolute gl-right-0 gl-top-2': isPinnable }" + class="gl-bg-t-gray-a-08!" + :class="{ + 'hide-on-focus-or-hover--target transition-opacity-on-hover--target': isPinnable, + }" > {{ pillData }} </gl-badge> - <gl-button - v-if="isPinnable && !isPinned" - v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.pinItem" - size="small" - category="tertiary" - icon="thumbtack" - class="show-on-focus-or-hover--target" - :aria-label="$options.i18n.pinItem" - @click.prevent="$emit('pin-add', item.id)" - /> - <gl-button - v-else-if="isPinnable && isPinned" - v-gl-tooltip.noninteractive.ds500.right.viewport="$options.i18n.unpinItem" - size="small" - category="tertiary" - :aria-label="$options.i18n.unpinItem" - icon="thumbtack-solid" - class="show-on-focus-or-hover--target" - @click.prevent="$emit('pin-remove', item.id)" - /> </span> </component> + <template v-if="isPinnable"> + <gl-button + v-if="isPinned" + v-gl-tooltip.noninteractive.right.viewport="$options.i18n.unpinItem" + :aria-label="unpinAriaLabel" + category="tertiary" + class="show-on-focus-or-hover--target transition-opacity-on-hover--target always-animate gl-absolute gl-right-3 gl-top-2" + :class="{ 'gl-pointer-events-none': !canClickPinButton }" + data-testid="nav-item-unpin" + icon="thumbtack-solid" + size="small" + @click="$emit('pin-remove', item.id)" + @transitionend="togglePointerEvents" + /> + <gl-button + v-else + v-gl-tooltip.noninteractive.right.viewport="$options.i18n.pinItem" + :aria-label="pinAriaLabel" + category="tertiary" + class="show-on-focus-or-hover--target transition-opacity-on-hover--target always-animate gl-absolute gl-right-3 gl-top-2" + :class="{ 'gl-pointer-events-none': !canClickPinButton }" + data-testid="nav-item-pin" + icon="thumbtack" + size="small" + @click="$emit('pin-add', item.id)" + @transitionend="togglePointerEvents" + /> + </template> </li> </template> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index 1e2201fbdff..5da45b52bf4 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -6,6 +6,13 @@ import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../cons import MenuSection from './menu_section.vue'; import NavItem from './nav_item.vue'; +const AMBIGUOUS_SETTINGS = { + ci_cd: s__('Navigation|CI/CD settings'), + merge_request_settings: s__('Navigation|Merge requests settings'), + monitor: s__('Navigation|Monitor settings'), + repository: s__('Navigation|Repository settings'), +}; + export default { i18n: { pinned: s__('Navigation|Pinned'), @@ -23,11 +30,6 @@ export default { required: false, default: () => [], }, - separated: { - type: Boolean, - required: false, - default: false, - }, hasFlyout: { type: Boolean, required: false, @@ -37,7 +39,7 @@ export default { data() { return { expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false', - draggableItems: this.items, + draggableItems: this.renameSettings(this.items), }; }, computed: { @@ -63,7 +65,7 @@ export default { }); }, items(newItems) { - this.draggableItems = newItems; + this.draggableItems = this.renameSettings(newItems); }, }, methods: { @@ -76,6 +78,15 @@ export default { event.oldIndex < event.newIndex, ); }, + renameSettings(items) { + return items.map((i) => { + const title = AMBIGUOUS_SETTINGS[i.id] || i.title; + return { ...i, title }; + }); + }, + onPinRemove(itemId) { + this.$emit('pin-remove', itemId); + }, }, }; </script> @@ -84,10 +95,9 @@ export default { <menu-section :item="sectionItem" :expanded="expanded" - :separated="separated" :has-flyout="hasFlyout" @collapse-toggle="expanded = !expanded" - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-remove="onPinRemove" > <draggable v-if="items.length > 0" @@ -103,7 +113,7 @@ export default { :key="item.id" :item="item" is-in-pinned-section - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-remove="onPinRemove" /> </draggable> <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue deleted file mode 100644 index 8d1a5c825b5..00000000000 --- a/app/assets/javascripts/super_sidebar/components/projects_list.vue +++ /dev/null @@ -1,82 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import { MAX_FREQUENT_PROJECTS_COUNT } from '../constants'; -import FrequentItemsList from './frequent_items_list.vue'; -import SearchResults from './search_results.vue'; -import NavItem from './nav_item.vue'; - -export default { - MAX_FREQUENT_PROJECTS_COUNT, - components: { - FrequentItemsList, - SearchResults, - NavItem, - }, - props: { - username: { - type: String, - required: true, - }, - viewAllLink: { - type: String, - required: true, - }, - isSearch: { - type: Boolean, - required: false, - default: false, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - computed: { - storageKey() { - return `${this.username}/frequent-projects`; - }, - viewAllProps() { - return { - item: { - link: this.viewAllLink, - title: s__('Navigation|View all your projects'), - icon: 'project', - }, - linkClasses: { 'dashboard-shortcuts-projects': true }, - }; - }, - }, - i18n: { - title: s__('Navigation|Frequently visited projects'), - searchTitle: s__('Navigation|Projects'), - pristineText: s__('Navigation|Projects you visit often will appear here.'), - noResultsText: s__('Navigation|No project matches found'), - }, -}; -</script> - -<template> - <search-results - v-if="isSearch" - class="gl-border-t-0" - :title="$options.i18n.searchTitle" - :no-results-text="$options.i18n.noResultsText" - :search-results="searchResults" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </search-results> - <frequent-items-list - v-else - :title="$options.i18n.title" - :storage-key="storageKey" - :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT" - :pristine-text="$options.i18n.pristineText" - > - <template #view-all-items> - <nav-item v-bind="viewAllProps" is-subitem /> - </template> - </frequent-items-list> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue deleted file mode 100644 index ff933f341af..00000000000 --- a/app/assets/javascripts/super_sidebar/components/search_results.vue +++ /dev/null @@ -1,99 +0,0 @@ -<script> -import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; -import uniqueId from 'lodash/uniqueId'; -import ItemsList from './items_list.vue'; - -export default { - components: { - GlCollapse, - GlIcon, - ItemsList, - }, - directives: { - CollapseToggle: GlCollapseToggleDirective, - }, - props: { - title: { - type: String, - required: true, - }, - noResultsText: { - type: String, - required: true, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - data() { - return { - expanded: true, - }; - }, - computed: { - isEmpty() { - return !this.searchResults.length; - }, - collapseIcon() { - return this.expanded ? 'chevron-up' : 'chevron-down'; - }, - }, - created() { - this.collapseId = uniqueId('expandable-section-'); - }, - buttonClasses: [ - // Reset user agent styles - 'gl-appearance-none', - 'gl-border-0', - 'gl-bg-transparent', - // Text styles - 'gl-text-left', - 'gl-text-transform-uppercase', - 'gl-text-secondary', - 'gl-font-weight-semibold', - 'gl-font-xs', - 'gl-line-height-12', - 'gl-letter-spacing-06em', - // Border - 'gl-border-t', - 'gl-border-gray-50', - // Spacing - 'gl-my-3', - 'gl-pt-2', - // Layout - 'gl-display-flex', - 'gl-justify-content-space-between', - 'gl-align-items-center', - ], -}; -</script> - -<template> - <li class="gl-border-t gl-border-gray-50"> - <button - v-collapse-toggle="collapseId" - :class="$options.buttonClasses" - class="gl-mx-3" - data-testid="search-results-toggle" - > - {{ title }} - <gl-icon :name="collapseIcon" :size="16" /> - </button> - <gl-collapse :id="collapseId" v-model="expanded"> - <div - v-if="isEmpty" - data-testid="empty-text" - class="gl-text-gray-500 gl-font-sm gl-mb-3 gl-mx-4" - > - {{ noResultsText }} - </div> - <items-list :aria-label="title" :items="searchResults"> - <template #view-all-items> - <slot name="view-all-items"></slot> - </template> - </items-list> - </gl-collapse> - </li> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue new file mode 100644 index 00000000000..df432a1928a --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue @@ -0,0 +1,126 @@ +<script> +import { getCssClassDimensions } from '~/lib/utils/css_utils'; +import Tracking from '~/tracking'; +import { + JS_TOGGLE_EXPAND_CLASS, + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, +} from '../constants'; + +export default { + name: 'SidebarHoverPeek', + mixins: [Tracking.mixin()], + props: { + isMouseOverSidebar: { + type: Boolean, + required: false, + default: false, + }, + }, + created() { + // Nothing needs to observe these properties, so they are not reactive. + this.state = null; + this.openTimer = null; + this.closeTimer = null; + this.xSidebarEdge = null; + this.isMouseWithinSidebarArea = false; + }, + async mounted() { + await this.$nextTick(); + this.xSidebarEdge = getCssClassDimensions('super-sidebar').width; + document.addEventListener('mousemove', this.onMouseMove); + document.documentElement.addEventListener('mouseleave', this.onDocumentLeave); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .addEventListener('mouseenter', this.onMouseEnter); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .addEventListener('mouseleave', this.onMouseLeave); + this.changeState(STATE_CLOSED); + }, + beforeDestroy() { + document.removeEventListener('mousemove', this.onMouseMove); + document.documentElement.removeEventListener('mouseleave', this.onDocumentLeave); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .removeEventListener('mouseenter', this.onMouseEnter); + document + .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`) + .removeEventListener('mouseleave', this.onMouseLeave); + this.clearTimers(); + }, + methods: { + onMouseMove({ clientX }) { + if (clientX < this.xSidebarEdge) { + this.isMouseWithinSidebarArea = true; + } else { + this.isMouseWithinSidebarArea = false; + if (!this.isMouseOverSidebar && this.state === STATE_OPEN) { + this.willClose(); + } + } + }, + onDocumentLeave() { + this.isMouseWithinSidebarArea = false; + if (this.state === STATE_OPEN) { + this.willClose(); + } else if (this.state === STATE_WILL_OPEN) { + this.close(); + } + }, + onMouseEnter() { + clearTimeout(this.closeTimer); + this.willOpen(); + }, + onMouseLeave() { + clearTimeout(this.openTimer); + if (this.isMouseWithinSidebarArea || this.isMouseOverSidebar) return; + this.willClose(); + }, + willClose() { + this.changeState(STATE_WILL_CLOSE); + this.closeTimer = setTimeout(this.close, SUPER_SIDEBAR_PEEK_CLOSE_DELAY); + }, + willOpen() { + this.changeState(STATE_WILL_OPEN); + this.openTimer = setTimeout(this.open, SUPER_SIDEBAR_PEEK_OPEN_DELAY); + }, + open() { + this.changeState(STATE_OPEN); + this.clearTimers(); + this.track('nav_hover_peek', { + label: 'nav_sidebar_toggle', + property: 'nav_sidebar', + }); + }, + close() { + if (this.isMouseWithinSidebarArea) return; + this.changeState(STATE_CLOSED); + this.clearTimers(); + }, + clearTimers() { + clearTimeout(this.closeTimer); + clearTimeout(this.openTimer); + }, + /** + * Switches to the new state, and emits a change event. + * + * If the given state is the current state, do nothing. + * + * @param {string} state The state to transition to. + */ + changeState(state) { + if (this.state === state) return; + this.state = state; + this.$emit('change', state); + }, + }, + render() { + return null; + }, +}; +</script> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 821b9dbcb7b..02488e99c0e 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/browser'; import { GlBreakpointInstance, breakpoints } from '@gitlab/ui/dist/utils'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PANELS_WITH_PINS } from '../constants'; import NavItem from './nav_item.vue'; @@ -51,10 +50,6 @@ export default { }, }, - i18n: { - mainNavigation: s__('Navigation|Main navigation'), - }, - data() { return { showFlyoutMenus: false, @@ -109,10 +104,8 @@ export default { }, }, mounted() { - if (this.glFeatures.superSidebarFlyoutMenus) { - this.decideFlyoutState(); - window.addEventListener('resize', this.decideFlyoutState); - } + this.decideFlyoutState(); + window.addEventListener('resize', this.decideFlyoutState); }, beforeDestroy() { window.removeEventListener('resize', this.decideFlyoutState); @@ -164,13 +157,12 @@ export default { </script> <template> - <nav :aria-label="$options.i18n.mainNavigation" class="gl-p-2 gl-relative"> + <div class="gl-p-2 gl-relative"> <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0" data-testid="static-items-section"> <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> </ul> <pinned-section v-if="supportsPins" - separated :items="pinnedItems" :has-flyout="showFlyoutMenus" @pin-remove="destroyPin" @@ -203,5 +195,5 @@ export default { /> </template> </ul> - </nav> + </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue index ec728b4af9e..a20e37b945a 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue @@ -1,12 +1,14 @@ <script> import { getCssClassDimensions } from '~/lib/utils/css_utils'; import Tracking from '~/tracking'; -import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants'; - -export const STATE_CLOSED = 'closed'; -export const STATE_WILL_OPEN = 'will-open'; -export const STATE_OPEN = 'open'; -export const STATE_WILL_CLOSE = 'will-close'; +import { + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, +} from '../constants'; export default { name: 'SidebarPeek', diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index 29a3147e949..fe3e4a8199e 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -2,27 +2,31 @@ import { GlButton } from '@gitlab/ui'; import { Mousetrap } from '~/lib/mousetrap'; import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; -import { sidebarState } from '../constants'; +import { + sidebarState, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, +} from '../constants'; import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; +import { trackContextAccess } from '../utils'; import UserBar from './user_bar.vue'; import SidebarPortalTarget from './sidebar_portal_target.vue'; -import ContextHeader from './context_header.vue'; -import ContextSwitcher from './context_switcher.vue'; import HelpCenter from './help_center.vue'; import SidebarMenu from './sidebar_menu.vue'; -import SidebarPeekBehavior, { STATE_CLOSED, STATE_WILL_OPEN } from './sidebar_peek_behavior.vue'; +import SidebarPeekBehavior from './sidebar_peek_behavior.vue'; +import SidebarHoverPeekBehavior from './sidebar_hover_peek_behavior.vue'; export default { components: { GlButton, UserBar, - ContextHeader, - ContextSwitcher, HelpCenter, SidebarMenu, SidebarPeekBehavior, + SidebarHoverPeekBehavior, SidebarPortalTarget, TrialStatusWidget: () => import('ee_component/contextual_sidebar/components/trial_status_widget.vue'), @@ -32,6 +36,7 @@ export default { mixins: [Tracking.mixin()], i18n: { skipToMainContent: __('Skip to main content'), + primary: s__('Navigation|Primary'), }, inject: ['showTrialStatusWidget'], props: { @@ -45,25 +50,34 @@ export default { sidebarState, showPeekHint: false, isMouseover: false, + breakpoint: null, }; }, computed: { + showOverlay() { + return this.sidebarState.isPeek || this.sidebarState.isHoverPeek; + }, menuItems() { return this.sidebarData.current_menu_items || []; }, peekClasses() { return { 'super-sidebar-peek-hint': this.showPeekHint, - 'super-sidebar-peek': this.sidebarState.isPeek, + 'super-sidebar-peek': this.showOverlay, + 'super-sidebar-has-peeked': this.sidebarState.hasPeeked, }; }, }, - watch: { - 'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) { - if (newIsCollapsed && this.$refs['context-switcher']) { - this.$refs['context-switcher'].close(); - } - }, + created() { + const { + is_logged_in: isLoggedIn, + current_context: currentContext, + username, + track_visits_path: trackVisitsPath, + } = this.sidebarData; + if (isLoggedIn && currentContext.namespace) { + trackContextAccess(username, currentContext, trackVisitsPath); + } }, mounted() { Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar); @@ -88,6 +102,7 @@ export default { this.sidebarState.isCollapsed = true; this.showPeekHint = false; } else if (state === STATE_WILL_OPEN) { + this.sidebarState.hasPeeked = true; this.sidebarState.isPeek = false; this.sidebarState.isCollapsed = true; this.showPeekHint = true; @@ -97,8 +112,15 @@ export default { this.showPeekHint = false; } }, - onContextSwitcherToggled(open) { - this.sidebarState.contextSwitcherOpen = open; + onHoverPeekChange(state) { + if (state === STATE_OPEN) { + this.sidebarState.hasPeeked = true; + this.sidebarState.isHoverPeek = true; + this.sidebarState.isCollapsed = false; + } else if (state === STATE_CLOSED) { + this.sidebarState.isHoverPeek = false; + this.sidebarState.isCollapsed = true; + } }, }, }; @@ -114,8 +136,9 @@ export default { > {{ $options.i18n.skipToMainContent }} </gl-button> - <aside + <nav id="super-sidebar" + :aria-label="$options.i18n.primary" class="super-sidebar" :class="peekClasses" data-testid="super-sidebar" @@ -124,32 +147,23 @@ export default { @mouseenter="isMouseover = true" @mouseleave="isMouseover = false" > - <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" /> + <user-bar :has-collapse-button="!showOverlay" :sidebar-data="sidebarData" /> <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> <trial-status-widget - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! nav-item-link gl-py-3" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-py-3" /> <trial-status-popover /> </div> <div class="contextual-nav gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden" > - <div - class="gl-flex-grow-1" - :class="{ 'gl-overflow-auto': !sidebarState.contextSwitcherOpen }" - data-testid="nav-container" - > - <context-switcher - v-if="sidebarData.is_logged_in" - ref="context-switcher" - :username="sidebarData.username" - :projects-path="sidebarData.projects_path" - :groups-path="sidebarData.groups_path" - :current-context="sidebarData.current_context" - :context-header="sidebarData.current_context_header" - @toggle="onContextSwitcherToggled" - /> - <context-header v-else :context="sidebarData.current_context_header" /> + <div class="gl-flex-grow-1 gl-overflow-auto" data-testid="nav-container"> + <h2 + class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-sm super-sidebar-context-header" + > + {{ sidebarData.current_context_header }} + </h2> + <sidebar-menu v-if="menuItems.length" :items="menuItems" @@ -164,7 +178,7 @@ export default { <help-center :sidebar-data="sidebarData" /> </div> </div> - </aside> + </nav> <a v-for="shortcutLink in sidebarData.shortcut_links" :key="shortcutLink.href" @@ -176,13 +190,18 @@ export default { </a> <!-- - Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid + Only mount peek behavior components if the sidebar is peekable, to avoid setting up event listeners unnecessarily. --> <sidebar-peek-behavior - v-if="sidebarState.isPeekable" + v-if="sidebarState.isPeekable && !sidebarState.isHoverPeek" :is-mouse-over-sidebar="isMouseover" @change="onPeekChange" /> + <sidebar-hover-peek-behavior + v-if="sidebarState.isPeekable && !sidebarState.isPeek" + :is-mouse-over-sidebar="isMouseover" + @change="onHoverPeekChange" + /> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue index 7d5e87805d5..30ee18cc369 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -27,19 +27,18 @@ export default { }, i18n: { collapseSidebar: __('Hide sidebar'), - expandSidebar: __('Show sidebar'), - navigationSidebar: __('Navigation sidebar'), + expandSidebar: __('Keep sidebar visible'), + primaryNavigationSidebar: __('Primary navigation sidebar'), }, data() { return sidebarState; }, computed: { + canOpen() { + return this.isCollapsed || this.isPeek || this.isHoverPeek; + }, tooltipTitle() { - if (this.isPeek) return ''; - - return this.isCollapsed - ? this.$options.i18n.expandSidebar - : this.$options.i18n.collapseSidebar; + return this.canOpen ? this.$options.i18n.expandSidebar : this.$options.i18n.collapseSidebar; }, tooltip() { return { @@ -49,21 +48,21 @@ export default { }; }, ariaExpanded() { - return String(!this.isCollapsed); + return String(!this.canOpen); }, }, methods: { toggle() { - this.track(this.isCollapsed ? 'nav_show' : 'nav_hide', { + this.track(this.canOpen ? 'nav_show' : 'nav_hide', { label: 'nav_toggle', property: 'nav_sidebar', }); - toggleSuperSidebarCollapsed(!this.isCollapsed, true); + toggleSuperSidebarCollapsed(!this.canOpen, true); this.focusOtherToggle(); }, focusOtherToggle() { this.$nextTick(() => { - const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; + const classSelector = this.canOpen ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; const otherToggle = document.querySelector(`.${classSelector}`); otherToggle?.focus(); }); @@ -74,13 +73,12 @@ export default { <template> <gl-button - v-gl-tooltip.hover.noninteractive.ds500="tooltip" + v-gl-tooltip.hover="tooltip" aria-controls="super-sidebar" :aria-expanded="ariaExpanded" - :aria-label="$options.i18n.navigationSidebar" + :aria-label="$options.i18n.primaryNavigationSidebar" icon="sidebar" category="tertiary" - :disabled="isPeek" @click="toggle" /> </template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index b76ef91b768..49aee4f3470 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { destroyUserCountsManager, @@ -34,20 +34,19 @@ export default { ), SuperSidebarToggle, BrandLogo, + GlIcon, }, i18n: { - createNew: __('Create new...'), - homepage: __('Homepage'), issues: __('Issues'), mergeRequests: __('Merge requests'), - search: __('Search'), searchKbdHelp: sprintf( - s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + s__('GlobalSearch|Type %{kbdOpen}/%{kbdClose} to search'), { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, false, ), todoList: __('To-Do list'), stopImpersonating: __('Stop impersonating'), + searchBtnText: __('Search or go to…'), }, directives: { GlTooltip: GlTooltipDirective, @@ -103,8 +102,14 @@ export default { </script> <template> - <div class="user-bar"> - <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2"> + <div + class="user-bar gl-display-flex gl-p-3 gl-gap-1" + :class="{ 'gl-flex-direction-column gl-gap-3': sidebarData.is_logged_in }" + > + <div + v-if="hasCollapseButton || sidebarData.is_logged_in" + class="gl-display-flex gl-align-items-center gl-gap-1" + > <template v-if="sidebarData.is_logged_in"> <brand-logo :logo-url="sidebarData.logo_url" /> <gl-badge @@ -112,7 +117,6 @@ export default { variant="success" :href="sidebarData.canary_toggle_com_url" size="sm" - class="gl-ml-2" > {{ $options.NEXT_LABEL }} </gl-badge> @@ -126,24 +130,16 @@ export default { tooltip-container="super-sidebar" data-testid="super-sidebar-collapse-button" /> - <create-menu v-if="sidebarData.is_logged_in" :groups="sidebarData.create_new_menu_groups" /> - - <gl-button - id="super-sidebar-search" - v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip" - v-gl-modal="$options.SEARCH_MODAL_ID" - data-testid="super-sidebar-search-button" - icon="search" - :aria-label="$options.i18n.search" - category="tertiary" + <create-menu + v-if="sidebarData.is_logged_in && sidebarData.create_new_menu_groups.length > 0" + :groups="sidebarData.create_new_menu_groups" /> - <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" /> <user-menu v-if="sidebarData.is_logged_in" :data="sidebarData" /> <gl-button v-if="isImpersonating" - v-gl-tooltip.noninteractive.ds500.bottom + v-gl-tooltip.bottom :href="sidebarData.stop_impersonation_path" :title="$options.i18n.stopImpersonating" :aria-label="$options.i18n.stopImpersonating" @@ -155,10 +151,10 @@ export default { </div> <div v-if="sidebarData.is_logged_in" - class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2" + class="gl-display-flex gl-justify-content-space-between gl-gap-2" > <counter - v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues" + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" :count="userCounts.assigned_issues" @@ -176,9 +172,7 @@ export default { @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom=" - mrMenuShown ? '' : $options.i18n.mergeRequests - " + v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" class="gl-w-full" icon="merge-request-open" :count="mergeRequestTotalCount" @@ -190,7 +184,7 @@ export default { /> </merge-request-menu> <counter - v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.todoList" + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" :count="userCounts.todos" @@ -202,5 +196,16 @@ export default { data-track-property="nav_core_menu" /> </div> + <button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="searchTooltip" + v-gl-modal="$options.SEARCH_MODAL_ID" + class="counter gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-focus--focus gl-w-full" + data-testid="super-sidebar-search-button" + > + <gl-icon name="search" /> + {{ $options.i18n.searchBtnText }} + </button> + <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" /> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 869f07520a2..ed6c41e85c6 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -19,7 +19,6 @@ const DROPDOWN_X_OFFSET_BASE = -211; const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { - feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005', i18n: { newNavigation: { sectionTitle: s__('NorthstarNavigation|Navigation redesign'), @@ -31,7 +30,6 @@ export default { buyPipelineMinutes: s__('CurrentUser|Buy Pipeline minutes'), oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'), gitlabNext: s__('CurrentUser|Switch to GitLab Next'), - provideFeedback: s__('NorthstarNavigation|Provide feedback'), startTrial: s__('CurrentUser|Start an Ultimate trial'), signOut: __('Sign out'), }, @@ -131,17 +129,6 @@ export default { }, }; }, - feedbackItem() { - return { - text: this.$options.i18n.provideFeedback, - href: this.$options.feedbackUrl, - extraAttrs: { - target: '_blank', - ...USER_MENU_TRACKING_DEFAULTS, - 'data-track-label': 'provide_nav_feedback', - }, - }; - }, signOutGroup() { return { items: [ @@ -316,7 +303,6 @@ export default { <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span> </template> <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation /> - <gl-disclosure-dropdown-item :item="feedbackItem" data-testid="feedback-item" /> </gl-disclosure-dropdown-group> <gl-disclosure-dropdown-group diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue index 13f19338610..3c8059387fa 100644 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -71,7 +71,7 @@ export default { v-if="user.status.customized" ref="statusTooltipTarget" data-testid="user-menu-status" - class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm" + class="gl-display-flex gl-align-items-baseline gl-mt-2 gl-font-sm" > <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index 757bf9c7459..77bd8b4a734 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -13,10 +13,12 @@ export const portalState = Vue.observable({ }); export const sidebarState = Vue.observable({ - contextSwitcherOpen: false, isCollapsed: false, + hasPeeked: false, isPeek: false, isPeekable: false, + isHoverPeek: false, + wasHoverPeek: false, }); export const helpCenterState = Vue.observable({ @@ -28,13 +30,17 @@ export const MAX_FREQUENT_GROUPS_COUNT = 3; export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200; export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500; +export const SUPER_SIDEBAR_PEEK_STATE_CLOSED = 'closed'; +export const SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN = 'will-open'; +export const SUPER_SIDEBAR_PEEK_STATE_OPEN = 'open'; +export const SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE = 'will-close'; export const TRACKING_UNKNOWN_ID = 'item_without_id'; export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown'; export const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; export const CLICK_PINNED_MENU_ITEM_ACTION = 'click_pinned_menu_item'; -export const PANELS_WITH_PINS = ['group', 'project']; +export const PANELS_WITH_PINS = ['group', 'project', 'organization']; export const USER_MENU_TRACKING_DEFAULTS = { 'data-track-property': 'nav_user_menu', diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql deleted file mode 100644 index 4b1e65be3fa..00000000000 --- a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql +++ /dev/null @@ -1,24 +0,0 @@ -query searchUserProjectsAndGroups($username: String!, $search: String) { - projects(search: $search, sort: "latest_activity_desc", membership: true, first: 20) { - nodes { - id - name - namespace: nameWithNamespace - webUrl - avatarUrl - } - } - - user(username: $username) { - id - groups(search: $search, first: 20) { - nodes { - id - name - namespace: fullPath - webUrl - avatarUrl - } - } - } -} diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 2b62e7a6ede..de16161efb5 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,6 +1,4 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; import { JS_TOGGLE_EXPAND_CLASS } from './constants'; @@ -12,12 +10,6 @@ import { import SuperSidebar from './components/super_sidebar.vue'; import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - const getTrialStatusWidgetData = (sidebarData) => { if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) { const { @@ -97,7 +89,6 @@ export const initSuperSidebar = () => { return new Vue({ el, name: 'SuperSidebarRoot', - apolloProvider, provide: { rootPath, toggleNewNavEndpoint, diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js index feb7e274b07..9ee78a657b6 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js @@ -26,6 +26,9 @@ export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => { sidebarState.isPeek = false; sidebarState.isPeekable = collapsed; + sidebarState.hasPeeked = false; + sidebarState.isHoverPeek = false; + sidebarState.wasHoverPeek = false; sidebarState.isCollapsed = collapsed; if (saveCookie && isDesktopBreakpoint()) { diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index cbf93155fb6..97830a32d78 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/browser'; import AccessorUtilities from '~/lib/utils/accessor'; import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; +import axios from '~/lib/utils/axios_utils'; /** * This takes an array of project or groups that were stored in the local storage, to be shown in @@ -18,7 +18,8 @@ const sortItemsByFrequencyAndLastAccess = (items) => // and then by lastAccessedOn with recent most first if (itemA.frequency !== itemB.frequency) { return itemB.frequency - itemA.frequency; - } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { + } + if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { return itemB.lastAccessedOn - itemA.lastAccessedOn; } @@ -36,11 +37,43 @@ export const getTopFrequentItems = (items, maxCount) => { return frequentItems.slice(0, maxCount); }; -const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) => { +/** + * This tracks projects' and groups' visits in order to suggest a list of frequently visited + * entities to the user. Currently, this track visits in two ways: + * - The legacy approach uses a simple counting algorithm and stores the data in the local storage. + * - The above approach is being migrated to a backend-based one, where visits will be stored in the + * DB, and suggestions will be made through a smarter algorithm. When we are ready to transition + * to the newer approach, the legacy one will be cleaned up. + * @param {object} item The project/group item being tracked. + * @param {string} namespace A string indicating whether the tracked entity is a project or a group. + * @param {string} trackVisitsPath The API endpoint to track visits server-side. + * @returns {void} + */ +const updateItemAccess = ( + contextItem, + { lastAccessedOn, frequency = 0 } = {}, + namespace, + trackVisitsPath, +) => { const now = Date.now(); const neverAccessed = !lastAccessedOn; const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; + if (shouldUpdate && gon.features?.serverSideFrecentNamespaces) { + try { + axios({ + url: trackVisitsPath, + method: 'POST', + data: { + type: namespace, + id: contextItem.id, + }, + }); + } catch (e) { + Sentry.captureException(e); + } + } + return { ...contextItem, frequency: shouldUpdate ? frequency + 1 : frequency, @@ -48,7 +81,7 @@ const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) = }; }; -export const trackContextAccess = (username, context) => { +export const trackContextAccess = (username, context, trackVisitsPath) => { if (!AccessorUtilities.canUseLocalStorage()) { return false; } @@ -61,9 +94,19 @@ export const trackContextAccess = (username, context) => { ); if (existingItemIndex > -1) { - storedItems[existingItemIndex] = updateItemAccess(context.item, storedItems[existingItemIndex]); + storedItems[existingItemIndex] = updateItemAccess( + context.item, + storedItems[existingItemIndex], + context.namespace, + trackVisitsPath, + ); } else { - const newItem = updateItemAccess(context.item); + const newItem = updateItemAccess( + context.item, + storedItems[existingItemIndex], + context.namespace, + trackVisitsPath, + ); if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) { sortItemsByFrequencyAndLastAccess(storedItems); storedItems.pop(); @@ -74,15 +117,6 @@ export const trackContextAccess = (username, context) => { return localStorage.setItem(storageKey, JSON.stringify(storedItems)); }; -export const formatContextSwitcherItems = (items) => - items.map(({ id, name: title, namespace, avatarUrl: avatar, webUrl: link }) => ({ - id, - title, - subtitle: truncateNamespace(namespace), - avatar, - link, - })); - export const getItemsFromLocalStorage = ({ storageKey, maxItems }) => { if (!AccessorUtilities.canUseLocalStorage()) { return []; diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql index 3ba0ab29530..0de76e25d01 100644 --- a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql +++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql @@ -1,8 +1,8 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" query timeTrackingReport( - $startDate: Time - $endDate: Time + $startTime: Time + $endTime: Time $projectId: ProjectID $groupId: GroupID $username: String @@ -12,8 +12,8 @@ query timeTrackingReport( $after: String ) { timelogs( - startDate: $startDate - endDate: $endDate + startTime: $startTime + endTime: $endTime projectId: $projectId groupId: $groupId username: $username diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue index 2069e4a6722..7bb9b6c52a5 100644 --- a/app/assets/javascripts/time_tracking/components/timelogs_app.vue +++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue @@ -17,11 +17,11 @@ import TimelogsTable from './timelogs_table.vue'; const ENTRIES_PER_PAGE = 20; // Define initial dates to current date and time -const INITIAL_TO_DATE = new Date(); -const INITIAL_FROM_DATE = new Date(); +const INITIAL_TO_DATE_TIME = new Date(new Date().setHours(0, 0, 0, 0)); +const INITIAL_FROM_DATE_TIME = new Date(new Date().setHours(0, 0, 0, 0)); // Set the initial 'from' date to 30 days before the current date -INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30); +INITIAL_FROM_DATE_TIME.setDate(INITIAL_TO_DATE_TIME.getDate() - 30); export default { components: { @@ -45,8 +45,8 @@ export default { projectId: null, groupId: null, username: null, - timeSpentFrom: INITIAL_FROM_DATE, - timeSpentTo: INITIAL_TO_DATE, + timeSpentFrom: INITIAL_FROM_DATE_TIME, + timeSpentTo: INITIAL_TO_DATE_TIME, cursor: { first: ENTRIES_PER_PAGE, after: null, @@ -54,8 +54,8 @@ export default { before: null, }, queryVariables: { - startDate: INITIAL_FROM_DATE, - endDate: INITIAL_TO_DATE, + startTime: INITIAL_FROM_DATE_TIME, + endTime: INITIAL_TO_DATE_TIME, projectId: null, groupId: null, username: null, @@ -108,9 +108,15 @@ export default { before: null, }; + const { timeSpentTo } = this; + + if (timeSpentTo) { + timeSpentTo.setDate(timeSpentTo.getDate() + 1); + } + this.queryVariables = { - startDate: this.nullIfBlank(this.timeSpentFrom), - endDate: this.nullIfBlank(this.timeSpentTo), + startTime: this.nullIfBlank(this.timeSpentFrom), + endTime: this.nullIfBlank(timeSpentTo), projectId: this.nullIfBlank(this.projectId), groupId: this.nullIfBlank(this.groupId), username: this.nullIfBlank(this.username), @@ -141,8 +147,8 @@ export default { }, i18n: { username: s__('TimeTrackingReport|Username'), - from: s__('TimeTrackingReport|From'), - to: s__('TimeTrackingReport|To'), + from: s__('TimeTrackingReport|From the start of'), + to: s__('TimeTrackingReport|To the end of'), runReport: s__('TimeTrackingReport|Run report'), totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '), }, diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue index 7e1e6cc445c..43aa9b94b3a 100644 --- a/app/assets/javascripts/token_access/components/outbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -27,7 +27,7 @@ export default { 'CICD|Limit access %{italicStart}from%{italicEnd} this project (Deprecated)', ), toggleHelpText: s__( - `CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`, + `CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more%{linkEnd}.`, ), cardHeaderTitle: s__('CICD|Add an existing project to the scope'), settingDisabledMessage: s__( @@ -258,12 +258,12 @@ export default { <template #help> <gl-sprintf :message="$options.i18n.toggleHelpText"> <template #link="{ content }"> - <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank"> - {{ content }} - </gl-link> - <strong>{{ $options.i18n.disableToggleWarning }} </strong> + <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">{{ + content + }}</gl-link> </template> </gl-sprintf> + <strong>{{ $options.i18n.disableToggleWarning }} </strong> </template> </gl-toggle> diff --git a/app/assets/javascripts/tracing/components/tracing_details.vue b/app/assets/javascripts/tracing/components/tracing_details.vue deleted file mode 100644 index d8b2cbc9469..00000000000 --- a/app/assets/javascripts/tracing/components/tracing_details.vue +++ /dev/null @@ -1,90 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { createAlert } from '~/alert'; -import { visitUrl, isSafeURL } from '~/lib/utils/url_utility'; - -export default { - components: { - GlLoadingIcon, - }, - i18n: { - error: s__('Tracing|Failed to load trace details.'), - }, - props: { - observabilityClient: { - required: true, - type: Object, - }, - traceId: { - required: true, - type: String, - }, - tracingIndexUrl: { - required: true, - type: String, - validator: (val) => isSafeURL(val), - }, - }, - data() { - return { - trace: null, - loading: false, - }; - }, - created() { - this.validateAndFetch(); - }, - methods: { - async validateAndFetch() { - if (!this.traceId) { - createAlert({ - message: this.$options.i18n.error, - }); - } - this.loading = true; - try { - const enabled = await this.observabilityClient.isTracingEnabled(); - if (enabled) { - await this.fetchTrace(); - } else { - this.goToTracingIndex(); - } - } catch (e) { - createAlert({ - message: this.$options.i18n.error, - }); - } finally { - this.loading = false; - } - }, - async fetchTrace() { - this.loading = true; - try { - this.trace = await this.observabilityClient.fetchTrace(this.traceId); - } catch (e) { - createAlert({ - message: this.$options.i18n.error, - }); - } finally { - this.loading = false; - } - }, - goToTracingIndex() { - visitUrl(this.tracingIndexUrl); - }, - }, -}; -</script> - -<template> - <div v-if="loading" class="gl-py-5"> - <gl-loading-icon size="lg" /> - </div> - - <!-- TODO Replace with actual trace-details component--> - <div v-else-if="trace" data-testid="trace-details"> - <p>{{ tracingIndexUrl }}</p> - <p>{{ trace }}</p> - </div> -</template> diff --git a/app/assets/javascripts/tracing/components/tracing_empty_state.vue b/app/assets/javascripts/tracing/components/tracing_empty_state.vue deleted file mode 100644 index f17060db6bc..00000000000 --- a/app/assets/javascripts/tracing/components/tracing_empty_state.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import EMPTY_TRACING_SVG from '@gitlab/svgs/dist/illustrations/monitoring/tracing.svg?url'; -import { GlEmptyState, GlButton } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - EMPTY_TRACING_SVG, - name: 'TracingEmptyState', - i18n: { - title: s__('Tracing|Get started with Tracing'), - description: s__('Tracing|Monitor your applications with GitLab Distributed Tracing.'), - enableButtonText: s__('Tracing|Enable'), - }, - components: { - GlEmptyState, - GlButton, - }, -}; -</script> - -<template> - <gl-empty-state :title="$options.i18n.title" :svg-path="$options.EMPTY_TRACING_SVG"> - <template #description> - <div> - <span>{{ $options.i18n.description }}</span> - </div> - </template> - - <template #actions> - <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="$emit('enable-tracing')"> - {{ $options.i18n.enableButtonText }} - </gl-button> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/tracing/components/tracing_list.vue b/app/assets/javascripts/tracing/components/tracing_list.vue deleted file mode 100644 index 21d1353a86d..00000000000 --- a/app/assets/javascripts/tracing/components/tracing_list.vue +++ /dev/null @@ -1,125 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { createAlert } from '~/alert'; -import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; -import UrlSync from '~/vue_shared/components/url_sync.vue'; -import { - queryToFilterObj, - filterObjToQuery, - filterObjToFilterToken, - filterTokensToFilterObj, -} from '../filters'; -import TracingEmptyState from './tracing_empty_state.vue'; -import TracingTableList from './tracing_table_list.vue'; -import FilteredSearch from './tracing_list_filtered_search.vue'; - -export default { - components: { - GlLoadingIcon, - TracingTableList, - TracingEmptyState, - FilteredSearch, - UrlSync, - }, - props: { - observabilityClient: { - required: true, - type: Object, - }, - }, - data() { - return { - loading: true, - /** - * tracingEnabled: boolean | null. - * null identifies a state where we don't know if tracing is enabled or not (e.g. when fetching the status from the API fails) - */ - tracingEnabled: null, - traces: [], - filters: queryToFilterObj(window.location.search), - }; - }, - computed: { - query() { - return filterObjToQuery(this.filters); - }, - initialFilterValue() { - return filterObjToFilterToken(this.filters); - }, - }, - async created() { - this.checkEnabled(); - }, - methods: { - async checkEnabled() { - this.loading = true; - try { - this.tracingEnabled = await this.observabilityClient.isTracingEnabled(); - if (this.tracingEnabled) { - await this.fetchTraces(); - } - } catch (e) { - createAlert({ - message: s__('Tracing|Failed to load page.'), - }); - } finally { - this.loading = false; - } - }, - async enableTracing() { - this.loading = true; - try { - await this.observabilityClient.enableTraces(); - this.tracingEnabled = true; - await this.fetchTraces(); - } catch (e) { - createAlert({ - message: s__('Tracing|Failed to enable tracing.'), - }); - } finally { - this.loading = false; - } - }, - async fetchTraces() { - this.loading = true; - try { - const traces = await this.observabilityClient.fetchTraces(this.filters); - this.traces = traces; - } catch (e) { - createAlert({ - message: s__('Tracing|Failed to load traces.'), - }); - } finally { - this.loading = false; - } - }, - selectTrace(trace) { - visitUrl(joinPaths(window.location.pathname, trace.trace_id)); - }, - handleFilters(filterTokens) { - this.filters = filterTokensToFilterObj(filterTokens); - this.fetchTraces(); - }, - }, -}; -</script> - -<template> - <div> - <div v-if="loading" class="gl-py-5"> - <gl-loading-icon size="lg" /> - </div> - - <template v-else-if="tracingEnabled !== null"> - <tracing-empty-state v-if="tracingEnabled === false" @enable-tracing="enableTracing" /> - - <template v-else> - <filtered-search :initial-filters="initialFilterValue" @submit="handleFilters" /> - <url-sync :query="query" /> - - <tracing-table-list :traces="traces" @reload="fetchTraces" @trace-selected="selectTrace" /> - </template> - </template> - </div> -</template> diff --git a/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue b/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue deleted file mode 100644 index d086f2d03ff..00000000000 --- a/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue +++ /dev/null @@ -1,87 +0,0 @@ -<script> -import { GlFilteredSearch, GlFilteredSearchToken } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { - OPERATORS_IS, - OPERATORS_IS_NOT, -} from '~/vue_shared/components/filtered_search_bar/constants'; -import { - PERIOD_FILTER_TOKEN_TYPE, - SERVICE_NAME_FILTER_TOKEN_TYPE, - OPERATION_FILTER_TOKEN_TYPE, - TRACE_ID_FILTER_TOKEN_TYPE, - DURATION_MS_FILTER_TOKEN_TYPE, -} from '../filters'; - -export default { - availableTokens: [ - { - title: s__('Tracing|Period'), - icon: 'clock', - type: PERIOD_FILTER_TOKEN_TYPE, - token: GlFilteredSearchToken, - operators: OPERATORS_IS, - unique: true, - options: [ - { value: '1m', title: s__('Tracing|Last 1 minute') }, - { value: '15m', title: s__('Tracing|Last 15 minutes') }, - { value: '30m', title: s__('Tracing|Last 30 minutes') }, - { value: '1h', title: s__('Tracing|Last 1 hour') }, - { value: '24h', title: s__('Tracing|Last 24 hours') }, - { value: '7d', title: s__('Tracing|Last 7 days') }, - { value: '14d', title: s__('Tracing|Last 14 days') }, - { value: '30d', title: s__('Tracing|Last 30 days') }, - ], - }, - { - title: s__('Tracing|Service'), - type: SERVICE_NAME_FILTER_TOKEN_TYPE, - token: GlFilteredSearchToken, - operators: OPERATORS_IS_NOT, - }, - { - title: s__('Tracing|Operation'), - type: OPERATION_FILTER_TOKEN_TYPE, - token: GlFilteredSearchToken, - operators: OPERATORS_IS_NOT, - }, - { - title: s__('Tracing|Trace ID'), - type: TRACE_ID_FILTER_TOKEN_TYPE, - token: GlFilteredSearchToken, - operators: OPERATORS_IS_NOT, - }, - { - title: s__('Tracing|Duration (ms)'), - type: DURATION_MS_FILTER_TOKEN_TYPE, - token: GlFilteredSearchToken, - operators: [ - { value: '>', description: s__('Tracing|longer than') }, - { value: '<', description: s__('Tracing|shorter than') }, - ], - }, - ], - components: { - GlFilteredSearch, - }, - props: { - initialFilters: { - type: Array, - required: false, - default: () => [], - }, - }, -}; -</script> - -<template> - <div class="vue-filtered-search-bar-container row-content-block gl-border-t-none"> - <gl-filtered-search - :value="initialFilters" - terms-as-tokens - :placeholder="s__('Tracing|Filter Traces')" - :available-tokens="$options.availableTokens" - @submit="$emit('submit', $event)" - /> - </div> -</template> diff --git a/app/assets/javascripts/tracing/components/tracing_table_list.vue b/app/assets/javascripts/tracing/components/tracing_table_list.vue deleted file mode 100644 index abb1f3ae88c..00000000000 --- a/app/assets/javascripts/tracing/components/tracing_table_list.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { GlTable, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export const tableDataClass = 'gl-display-flex gl-md-display-table-cell gl-align-items-center'; -export default { - name: 'TracingTableList', - i18n: { - title: s__('Tracing|Traces'), - emptyText: s__('Tracing|No traces to display.'), - emptyLinkText: s__('Tracing|Check again'), - }, - fields: [ - { - key: 'timestamp', - label: s__('Tracing|Date'), - tdClass: tableDataClass, - sortable: true, - }, - { - key: 'service_name', - label: s__('Tracing|Service'), - tdClass: tableDataClass, - sortable: true, - }, - { - key: 'operation', - label: s__('Tracing|Operation'), - tdClass: tableDataClass, - sortable: true, - }, - { - key: 'duration', - label: s__('Tracing|Duration'), - thClass: 'gl-w-15p', - tdClass: tableDataClass, - sortable: true, - }, - ], - components: { - GlTable, - GlLink, - }, - props: { - traces: { - required: true, - type: Array, - }, - }, - methods: { - onSelect(items) { - if (items[0]) { - this.$emit('trace-selected', items[0]); - } - }, - }, -}; -</script> - -<template> - <div> - <h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ $options.i18n.title }}</h4> - - <gl-table - :items="traces" - :fields="$options.fields" - show-empty - sort-by="timestamp" - :sort-desc="true" - fixed - stacked="md" - tbody-tr-class="table-row" - selectable - select-mode="single" - selected-variant="" - @row-selected="onSelect" - > - <template #cell(timestamp)="data"> - {{ data.item.timestamp }} - </template> - - <template #cell(service_name)="data"> - {{ data.item.service_name }} - </template> - - <template #cell(operation)="data"> - {{ data.item.operation }} - </template> - - <template #cell(duration)="data"> - <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> - {{ `${data.item.duration} ms` }} - </template> - - <template #empty> - {{ $options.i18n.emptyText }} - <gl-link @click="$emit('reload')">{{ $options.i18n.emptyLinkText }}</gl-link> - </template> - </gl-table> - </div> -</template> diff --git a/app/assets/javascripts/tracing/details_index.vue b/app/assets/javascripts/tracing/details_index.vue deleted file mode 100644 index 5702a88766c..00000000000 --- a/app/assets/javascripts/tracing/details_index.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import ObservabilityContainer from '~/observability/components/observability_container.vue'; -import TracingDetails from './components/tracing_details.vue'; - -export default { - components: { - ObservabilityContainer, - TracingDetails, - }, - props: { - traceId: { - type: String, - required: true, - }, - oauthUrl: { - type: String, - required: true, - }, - tracingUrl: { - type: String, - required: true, - }, - provisioningUrl: { - type: String, - required: true, - }, - tracingIndexUrl: { - required: true, - type: String, - }, - }, -}; -</script> - -<template> - <observability-container - :oauth-url="oauthUrl" - :tracing-url="tracingUrl" - :provisioning-url="provisioningUrl" - > - <template #default="{ observabilityClient }"> - <tracing-details - :trace-id="traceId" - :tracing-index-url="tracingIndexUrl" - :observability-client="observabilityClient" - /> - </template> - </observability-container> -</template> diff --git a/app/assets/javascripts/tracing/filters.js b/app/assets/javascripts/tracing/filters.js deleted file mode 100644 index 88a54b2e69f..00000000000 --- a/app/assets/javascripts/tracing/filters.js +++ /dev/null @@ -1,104 +0,0 @@ -import { - filterToQueryObject, - urlQueryToFilter, - prepareTokens, - processFilters, -} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; -import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; - -export const PERIOD_FILTER_TOKEN_TYPE = 'period'; -export const SERVICE_NAME_FILTER_TOKEN_TYPE = 'service-name'; -export const OPERATION_FILTER_TOKEN_TYPE = 'operation'; -export const TRACE_ID_FILTER_TOKEN_TYPE = 'trace-id'; -export const DURATION_MS_FILTER_TOKEN_TYPE = 'duration-ms'; - -export function queryToFilterObj(url) { - const filter = urlQueryToFilter(url, { - filteredSearchTermKey: 'search', - customOperators: [ - { - operator: '>', - prefix: 'gt', - }, - { - operator: '<', - prefix: 'lt', - }, - ], - }); - const { - period = null, - service = null, - operation = null, - trace_id: traceId = null, - durationMs = null, - } = filter; - const search = filter[FILTERED_SEARCH_TERM]; - return { - period, - service, - operation, - traceId, - durationMs, - search, - }; -} - -export function filterObjToQuery(filters) { - return filterToQueryObject( - { - period: filters.period, - service: filters.serviceName, - operation: filters.operation, - trace_id: filters.traceId, - durationMs: filters.durationMs, - [FILTERED_SEARCH_TERM]: filters.search, - }, - { - filteredSearchTermKey: 'search', - customOperators: [ - { - operator: '>', - prefix: 'gt', - applyOnlyToKey: 'durationMs', - }, - { - operator: '<', - prefix: 'lt', - applyOnlyToKey: 'durationMs', - }, - ], - }, - ); -} - -export function filterObjToFilterToken(filters) { - return prepareTokens({ - [PERIOD_FILTER_TOKEN_TYPE]: filters.period, - [SERVICE_NAME_FILTER_TOKEN_TYPE]: filters.serviceName, - [OPERATION_FILTER_TOKEN_TYPE]: filters.operation, - [TRACE_ID_FILTER_TOKEN_TYPE]: filters.traceId, - [DURATION_MS_FILTER_TOKEN_TYPE]: filters.durationMs, - [FILTERED_SEARCH_TERM]: filters.search, - }); -} - -export function filterTokensToFilterObj(tokens) { - const { - [SERVICE_NAME_FILTER_TOKEN_TYPE]: serviceName, - [PERIOD_FILTER_TOKEN_TYPE]: period, - [OPERATION_FILTER_TOKEN_TYPE]: operation, - [TRACE_ID_FILTER_TOKEN_TYPE]: traceId, - [DURATION_MS_FILTER_TOKEN_TYPE]: durationMs, - [FILTERED_SEARCH_TERM]: search, - } = processFilters(tokens); - - return { - serviceName, - period, - operation, - traceId, - durationMs, - search, - }; -} diff --git a/app/assets/javascripts/tracing/list_index.vue b/app/assets/javascripts/tracing/list_index.vue deleted file mode 100644 index 432fbb81506..00000000000 --- a/app/assets/javascripts/tracing/list_index.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import ObservabilityContainer from '~/observability/components/observability_container.vue'; -import TracingList from './components/tracing_list.vue'; - -export default { - components: { - ObservabilityContainer, - TracingList, - }, - props: { - oauthUrl: { - type: String, - required: true, - }, - tracingUrl: { - type: String, - required: true, - }, - provisioningUrl: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <observability-container - :oauth-url="oauthUrl" - :tracing-url="tracingUrl" - :provisioning-url="provisioningUrl" - > - <template #default="{ observabilityClient }"> - <tracing-list :observability-client="observabilityClient" /> - </template> - </observability-container> -</template> diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 114587bb363..88b7f6d3532 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -30,4 +30,10 @@ 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-0'; +export const SERVICE_PING_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-1'; + +export const SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT = + 'users_visiting_security_configuration_threat_management'; + +export const SERVICE_PING_PIPELINE_SECURITY_VISIT = 'users_visiting_pipeline_security'; +export const USER_CONTEXT_SCHEMA = 'iglu:com.gitlab/user_context/jsonschema/1-0-0'; diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js index 89d90cf89be..99e4a6aa3c7 100644 --- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -15,10 +15,14 @@ export function dispatchSnowplowEvent( let { value } = data; const standardContext = getStandardContext({ extra }); - const contexts = [standardContext]; + let contexts = [standardContext]; if (data.context) { - contexts.push(data.context); + if (Array.isArray(data.context)) { + contexts = [...contexts, ...data.context]; + } else { + contexts.push(data.context); + } } if (value !== undefined) { diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index ffbd932c02b..2ee4703aa0b 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -72,4 +72,5 @@ export function initDefaultTrackers() { InternalEvents.bindInternalEventDocument(); InternalEvents.trackInternalLoadEvents(); + InternalEvents.initBrowserSDK(); } diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js index a5fbb55ff63..9bd0200cad1 100644 --- a/app/assets/javascripts/tracking/internal_events.js +++ b/app/assets/javascripts/tracking/internal_events.js @@ -1,10 +1,12 @@ import API from '~/api'; +import getStandardContext from './get_standard_context'; import Tracking from './tracking'; import { GITLAB_INTERNAL_EVENT_CATEGORY, LOAD_INTERNAL_EVENTS_SELECTOR, SERVICE_PING_SCHEMA, + USER_CONTEXT_SCHEMA, } from './constants'; import { Tracker } from './tracker'; import { InternalEventHandler, createInternalEventPayload } from './utils'; @@ -13,17 +15,24 @@ const InternalEvents = { /** * * @param {string} event + * @param {object} data */ - track_event(event) { + track_event(event, data = {}) { + const { context, ...rest } = data; + + const defaultContext = { + schema: SERVICE_PING_SCHEMA, + data: { + event_name: event, + data_source: 'redis_hll', + }, + }; + const mergedContext = context ? [defaultContext, context] : defaultContext; + API.trackInternalEvent(event); Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, { - context: { - schema: SERVICE_PING_SCHEMA, - data: { - event_name: event, - data_source: 'redis_hll', - }, - }, + context: mergedContext, + ...rest, }); }, /** @@ -33,8 +42,8 @@ const InternalEvents = { mixin() { return { methods: { - track_event(event) { - InternalEvents.track_event(event); + track_event(event, data = {}) { + InternalEvents.track_event(event, data); }, }, }; @@ -78,6 +87,25 @@ const InternalEvents = { return loadEvents; }, + /** + * Initialize browser sdk for product analytics + */ + initBrowserSDK() { + const standardContext = getStandardContext(); + + if (window.glClient) { + window.glClient.setDocumentTitle('GitLab'); + window.glClient.page({ + title: 'GitLab', + context: [ + { + schema: USER_CONTEXT_SCHEMA, + data: standardContext?.data || {}, + }, + ], + }); + } + }, }; export default InternalEvents; diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js index b69b1714952..b74078475b0 100644 --- a/app/assets/javascripts/tracking/tracker.js +++ b/app/assets/javascripts/tracking/tracker.js @@ -257,12 +257,20 @@ export const Tracker = { const customUrl = `${pageUrl}${appendHash ? window.location.hash : ''}`; window.snowplow('setCustomUrl', customUrl); + // If Browser SDK is enabled set Custom url and Referrer url + if (window.glClient) { + window.glClient?.setCustomUrl(customUrl); + } if (document.referrer) { const node = referrers.find((links) => links.originalUrl === document.referrer); if (node) { pageLinks.referrer = node.url; window.snowplow('setReferrerUrl', pageLinks.referrer); + + if (window.glClient) { + window.glClient?.setReferrerUrl(pageLinks.referrer); + } } } diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js new file mode 100644 index 00000000000..9bf6d27235c --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js @@ -0,0 +1,64 @@ +import { mockGetProjectStorageStatisticsGraphQLResponse } from 'jest/usage_quotas/storage/mock_data'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getProjectStorageStatisticsQuery from '../queries/project_storage.query.graphql'; +import ProjectStorageApp from './project_storage_app.vue'; + +const meta = { + title: 'usage_quotas/storage/project_storage_app', + component: ProjectStorageApp, +}; + +export default meta; + +const createTemplate = (config = {}) => { + let { provide, apolloProvider } = config; + + if (provide == null) { + provide = {}; + } + + if (apolloProvider == null) { + const requestHandlers = [ + [ + getProjectStorageStatisticsQuery, + () => Promise.resolve(mockGetProjectStorageStatisticsGraphQLResponse), + ], + ]; + apolloProvider = createMockApollo(requestHandlers); + } + + return (args, { argTypes }) => ({ + components: { ProjectStorageApp }, + apolloProvider, + provide: { + projectPath: '/namespace/project', + ...provide, + }, + props: Object.keys(argTypes), + template: '<project-storage-app />', + }); +}; + +export const Default = { + render: createTemplate(), +}; + +export const Loading = { + render(...args) { + const requestHandlers = [[getProjectStorageStatisticsQuery, () => new Promise(() => {})]]; + const apolloProvider = createMockApollo(requestHandlers); + return createTemplate({ + apolloProvider, + })(...args); + }, +}; + +export const LoadingError = { + render(...args) { + const requestHandlers = [[getProjectStorageStatisticsQuery, () => Promise.reject()]]; + const apolloProvider = createMockApollo(requestHandlers); + return createTemplate({ + apolloProvider, + })(...args); + }, +}; diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue index f271b284d78..a5e1cc398e3 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue @@ -3,6 +3,7 @@ import { GlAlert, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { updateRepositorySize } from '~/api/projects_api'; import { numberToHumanSize } from '~/lib/utils/number_utils'; +import SectionedPercentageBar from '~/usage_quotas/components/sectioned_percentage_bar.vue'; import { ERROR_MESSAGE, LEARN_MORE_LABEL, @@ -19,7 +20,6 @@ import { } from '../constants'; import getProjectStorageStatistics from '../queries/project_storage.query.graphql'; import { getStorageTypesFromProjectStatistics, descendingStorageUsageSort } from '../utils'; -import UsageGraph from './usage_graph.vue'; import ProjectStorageDetail from './project_storage_detail.vue'; export default { @@ -29,8 +29,8 @@ export default { GlButton, GlLink, GlLoadingIcon, - UsageGraph, ProjectStorageDetail, + SectionedPercentageBar, }, inject: ['projectPath'], apollo: { @@ -88,6 +88,67 @@ export default { storageTypeHelpPaths, ); }, + + sections() { + if (!this.project?.statistics) { + return null; + } + + const { + buildArtifactsSize, + lfsObjectsSize, + packagesSize, + repositorySize, + storageSize, + wikiSize, + snippetsSize, + } = this.project.statistics; + + if (storageSize === 0) { + return null; + } + + return [ + { + id: 'repository', + value: repositorySize, + }, + { + id: 'lfsObjects', + value: lfsObjectsSize, + }, + { + id: 'packages', + value: packagesSize, + }, + { + id: 'buildArtifacts', + value: buildArtifactsSize, + }, + { + id: 'wiki', + value: wikiSize, + }, + { + id: 'snippets', + value: snippetsSize, + }, + ] + .filter((data) => data.value !== 0) + .sort(descendingStorageUsageSort('value')) + .map((storageType) => { + const storageTypeExtraData = PROJECT_STORAGE_TYPES.find( + (type) => storageType.id === type.id, + ); + const label = storageTypeExtraData?.name; + + return { + label, + formattedValue: numberToHumanSize(storageType.value), + ...storageType, + }; + }); + }, }, methods: { clearError() { @@ -123,11 +184,11 @@ export default { {{ error }} </gl-alert> <div v-else> - <div class="gl-pt-5 gl-px-3"> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div class="gl-pt-5"> + <div class="gl-display-flex gl-justify-content-space-between"> <div> - <h2 class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</h2> - <p class="gl-m-0 gl-text-gray-400"> + <h4 class="gl-font-lg gl-mb-3 gl-mt-0">{{ $options.TOTAL_USAGE_TITLE }}</h4> + <p> {{ $options.TOTAL_USAGE_SUBTITLE }} <gl-link :href="$options.usageQuotasHelpPaths.usageQuotas" @@ -137,13 +198,16 @@ export default { > </p> </div> - <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage"> + <p + class="gl-m-0 gl-font-size-h-display gl-font-weight-bold gl-white-space-nowrap" + data-testid="total-usage" + > {{ totalUsage }} </p> </div> </div> <div v-if="!isStatisticsEmpty" class="gl-w-full"> - <usage-graph :root-storage-statistics="project.statistics" :limit="0" /> + <sectioned-percentage-bar class="gl-mt-5" :sections="sections" /> </div> <div class="gl-w-full gl-my-5"> <gl-button diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue deleted file mode 100644 index 33f202e69db..00000000000 --- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue +++ /dev/null @@ -1,136 +0,0 @@ -<script> -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { PROJECT_STORAGE_TYPES } from '../constants'; -import { descendingStorageUsageSort } from '../utils'; - -export default { - name: 'UsageGraph', - props: { - rootStorageStatistics: { - required: true, - type: Object, - }, - limit: { - required: true, - type: Number, - }, - }, - computed: { - storageTypes() { - const { - buildArtifactsSize, - lfsObjectsSize, - packagesSize, - repositorySize, - storageSize, - wikiSize, - snippetsSize, - } = this.rootStorageStatistics; - - if (storageSize === 0) { - return null; - } - - return [ - { - id: 'repository', - style: this.usageStyle(this.barRatio(repositorySize)), - class: 'gl-bg-data-viz-blue-500', - size: repositorySize, - }, - { - id: 'lfsObjects', - style: this.usageStyle(this.barRatio(lfsObjectsSize)), - class: 'gl-bg-data-viz-orange-600', - size: lfsObjectsSize, - }, - { - id: 'packages', - style: this.usageStyle(this.barRatio(packagesSize)), - class: 'gl-bg-data-viz-aqua-500', - size: packagesSize, - }, - { - id: 'buildArtifacts', - style: this.usageStyle(this.barRatio(buildArtifactsSize)), - class: 'gl-bg-data-viz-green-500', - size: buildArtifactsSize, - }, - { - id: 'wiki', - style: this.usageStyle(this.barRatio(wikiSize)), - class: 'gl-bg-data-viz-magenta-500', - size: wikiSize, - }, - { - id: 'snippets', - style: this.usageStyle(this.barRatio(snippetsSize)), - class: 'gl-bg-data-viz-orange-800', - size: snippetsSize, - }, - ] - .filter((data) => data.size !== 0) - .sort(descendingStorageUsageSort('size')) - .map((storageType) => { - const storageTypeExtraData = PROJECT_STORAGE_TYPES.find( - (type) => storageType.id === type.id, - ); - const name = storageTypeExtraData?.name; - - return { - name, - ...storageType, - }; - }); - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - usageStyle(ratio) { - return { flex: ratio }; - }, - barRatio(size) { - let max = this.rootStorageStatistics.storageSize; - - if (this.limit !== 0 && max <= this.limit) { - max = this.limit; - } - - return size / max; - }, - }, -}; -</script> -<template> - <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100"> - <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="storage-type-usage gl-h-full gl-display-inline-block" - :class="storageType.class" - :style="storageType.style" - data-testid="storage-type-usage" - ></div> - </div> - <div class="row gl-mb-4"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="col-md-auto gl-display-flex gl-align-items-center" - data-testid="storage-type-legend" - data-qa-selector="storage_type_legend" - > - <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div> - <span class="gl-mr-2 gl-font-weight-bold gl-font-sm"> - {{ storageType.name }} - </span> - <span class="gl-text-gray-500 gl-font-sm"> - {{ formatSize(storageType.size) }} - </span> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue index 29b9b68883b..d4601d1f736 100644 --- a/app/assets/javascripts/user_lists/components/user_list.vue +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -39,7 +39,7 @@ export default { ), userIdLabel: s__('UserLists|User IDs'), userIdColumnHeader: s__('UserLists|User ID'), - errorMessage: __('Something went wrong on our end. Please try again!'), + errorMessage: __('Unable to load user list. Reload the page and try again.'), editButtonLabel: s__('UserLists|Edit'), }, classes: { diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index ab707e7e69c..aa68cf5a161 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -32,45 +32,46 @@ function UsersSelect(currentUser, els, options = {}) { const { handleClick, states } = options; - $els.each((i, dropdown) => { - const userSelect = this; - const $dropdown = $(dropdown); - const options = { - states, - projectId: $dropdown.data('projectId'), - groupId: $dropdown.data('groupId'), - showCurrentUser: $dropdown.data('currentUser'), - todoFilter: $dropdown.data('todoFilter'), - todoStateFilter: $dropdown.data('todoStateFilter'), - iid: $dropdown.data('iid'), - issuableType: $dropdown.data('issuableType'), - targetBranch: $dropdown.data('targetBranch'), - authorId: $dropdown.data('authorId'), - showSuggested: $dropdown.data('showSuggested'), - }; - const showNullUser = $dropdown.data('nullUser'); - const defaultNullUser = $dropdown.data('nullUserDefault'); - const showMenuAbove = $dropdown.data('showMenuAbove'); - const showAnyUser = $dropdown.data('anyUser'); - const firstUser = $dropdown.data('firstUser'); - const defaultLabel = $dropdown.data('defaultLabel'); - const issueURL = $dropdown.data('issueUpdate'); - const $selectbox = $dropdown.closest('.selectbox'); - const $assignToMeLink = $selectbox.next('.assign-to-me-link'); - let $block = $selectbox.closest('.block'); - const abilityName = $dropdown.data('abilityName'); - let $value = $block.find('.value'); - const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - const $loading = $block.find('.block-loading').addClass('gl-display-none'); - const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; - let selectedId = $dropdown.data('selected'); - let assignTo; - let assigneeTemplate; - let collapsedAssigneeTemplate; - - const suggestedReviewersHelpPath = $dropdown.data('suggestedReviewersHelpPath'); - const suggestedReviewersHeaderTemplate = template( - `<div class="gl-display-flex gl-align-items-center"> + this.dropdowns = $els + .map((i, dropdown) => { + const userSelect = this; + const $dropdown = $(dropdown); + const options = { + states, + projectId: $dropdown.data('projectId'), + groupId: $dropdown.data('groupId'), + showCurrentUser: $dropdown.data('currentUser'), + todoFilter: $dropdown.data('todoFilter'), + todoStateFilter: $dropdown.data('todoStateFilter'), + iid: $dropdown.data('iid'), + issuableType: $dropdown.data('issuableType'), + targetBranch: $dropdown.data('targetBranch'), + authorId: $dropdown.data('authorId'), + showSuggested: $dropdown.data('showSuggested'), + }; + const showNullUser = $dropdown.data('nullUser'); + const defaultNullUser = $dropdown.data('nullUserDefault'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const showAnyUser = $dropdown.data('anyUser'); + const firstUser = $dropdown.data('firstUser'); + const defaultLabel = $dropdown.data('defaultLabel'); + const issueURL = $dropdown.data('issueUpdate'); + const $selectbox = $dropdown.closest('.selectbox'); + const $assignToMeLink = $selectbox.next('.assign-to-me-link'); + let $block = $selectbox.closest('.block'); + const abilityName = $dropdown.data('abilityName'); + let $value = $block.find('.value'); + const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + const $loading = $block.find('.block-loading').addClass('gl-display-none'); + const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; + let selectedId = $dropdown.data('selected'); + let assignTo; + let assigneeTemplate; + let collapsedAssigneeTemplate; + + const suggestedReviewersHelpPath = $dropdown.data('suggestedReviewersHelpPath'); + const suggestedReviewersHeaderTemplate = template( + `<div class="gl-display-flex gl-align-items-center"> <%- header %> <a title="${s__('SuggestedReviewers|Learn about suggested reviewers')}" @@ -82,562 +83,568 @@ function UsersSelect(currentUser, els, options = {}) { ${spriteIcon('question-o', 'gl-ml-3 gl-icon s16')} </a> </div>`, - ); + ); - if (selectedId === undefined) { - selectedId = selectedIdDefault; - } + if (selectedId === undefined) { + selectedId = selectedIdDefault; + } - const assignYourself = function () { - const unassignedSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + const assignYourself = function () { + const unassignedSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); - if (unassignedSelected) { - unassignedSelected.remove(); - } + if (unassignedSelected) { + unassignedSelected.remove(); + } - // Save current selected user to the DOM - const currentUserInfo = $dropdown.data('currentUserInfo') || {}; - const currentUser = userSelect.currentUser || {}; - const fieldName = $dropdown.data('fieldName'); - const userName = currentUserInfo.name; - const userId = currentUserInfo.id || currentUser.id; + // Save current selected user to the DOM + const currentUserInfo = $dropdown.data('currentUserInfo') || {}; + const currentUser = userSelect.currentUser || {}; + const fieldName = $dropdown.data('fieldName'); + const userName = currentUserInfo.name; + const userId = currentUserInfo.id || currentUser.id; - const inputHtmlString = template(` + const inputHtmlString = template(` <input type="hidden" name="<%- fieldName %>" data-meta="<%- userName %>" value="<%- userId %>" /> `)({ fieldName, userName, userId }); - if ($selectbox) { - $dropdown.parent().before(inputHtmlString); - } else { - $dropdown.after(inputHtmlString); + if ($selectbox) { + $dropdown.parent().before(inputHtmlString); + } else { + $dropdown.after(inputHtmlString); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); } - }; - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); - } + const getSelectedUserInputs = function () { + return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`); + }; - const getSelectedUserInputs = function () { - return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`); - }; - - const getSelected = function () { - return getSelectedUserInputs() - .map((index, input) => parseInt(input.value, 10)) - .get(); - }; - - const checkMaxSelect = function () { - const maxSelect = $dropdown.data('maxSelect'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - - if ($dropdown.hasClass(elsClassName)) { - emitSidebarEvent('sidebar.removeReviewer', { - id: firstSelectedId, - }); - } else { - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); + const getSelected = function () { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function () { + const maxSelect = $dropdown.data('maxSelect'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeReviewer', { + id: firstSelectedId, + }); + } else { + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } } } - } - }; - - const getMultiSelectDropdownTitle = function (selectedUser, isSelected) { - const selectedUsers = getSelected().filter((u) => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter((u) => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return s__('UsersSelect|Unassigned'); - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter((s) => s !== selectedUser.id); + }; + + const getMultiSelectDropdownTitle = function (selectedUser, isSelected) { + const selectedUsers = getSelected().filter((u) => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter((u) => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return s__('UsersSelect|Unassigned'); + } + if (selectedUsers.length === 1) { + return firstUser.name; + } + if (isSelected) { + const otherSelected = selectedUsers.filter((s) => s !== selectedUser.id); + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: selectedUser.name, + length: otherSelected.length, + }); + } return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: selectedUser.name, - length: otherSelected.length, + name: firstUser.name, + length: selectedUsers.length - 1, }); - } - return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: firstUser.name, - length: selectedUsers.length - 1, - }); - }; - - $assignToMeLink.on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); - - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown - .find('.dropdown-toggle-text') - .text(getMultiSelectDropdownTitle(currentUserInfo)) - .removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('fieldName')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown - .find('.dropdown-toggle-text') - .text(gon.current_user_fullname) - .removeClass('is-default'); - } - }); - - $block.on('click', '.js-assign-yourself', (e) => { - e.preventDefault(); - return assignTo(userSelect.currentUser.id); - }); - - assignTo = function (selected) { - const data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('gl-display-none'); - $dropdown.trigger('loading.gl.dropdown'); - - return axios.put(issueURL, data).then(({ data }) => { - let user = {}; - let tooltipTitle; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.addClass('gl-display-none'); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url, - }; - tooltipTitle = escape(user.name); + }; + + $assignToMeLink.on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); + + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown + .find('.dropdown-toggle-text') + .text(getMultiSelectDropdownTitle(currentUserInfo)) + .removeClass('is-default'); } else { - user = { - name: s__('UsersSelect|Unassigned'), - username: '', - avatar: '', - }; - tooltipTitle = s__('UsersSelect|Assignee'); + const $input = $(`input[name="${$dropdown.data('fieldName')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown + .find('.dropdown-toggle-text') + .text(gon.current_user_fullname) + .removeClass('is-default'); } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', tooltipTitle); - fixTitle($collapsedSidebar); + }); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(userSelect.currentUser.id); }); - }; - collapsedAssigneeTemplate = template( - `<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> ${spriteIcon( - 'user', - )} <% } %>`, - ); - assigneeTemplate = template( - `<% if (username) { %> <a class="author-link gl-font-weight-bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + + assignTo = function (selected) { + const data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.removeClass('gl-display-none'); + $dropdown.trigger('loading.gl.dropdown'); + + return axios.put(issueURL, data).then(({ data }) => { + let user = {}; + let tooltipTitle; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.addClass('gl-display-none'); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url, + }; + tooltipTitle = escape(user.name); + } else { + user = { + name: s__('UsersSelect|Unassigned'), + username: '', + avatar: '', + }; + tooltipTitle = s__('UsersSelect|Assignee'); + } + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', tooltipTitle); + fixTitle($collapsedSidebar); + + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = template( + `<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> ${spriteIcon( + 'user', + )} <% } %>`, + ); + assigneeTemplate = template( + `<% if (username) { %> <a class="author-link gl-font-weight-bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', closingTag: '</a>', })}</span> <% } %>`, - ); - return initDeprecatedJQueryDropdown($dropdown, { - showMenuAbove, - data(term, callback) { - return userSelect.users(term, options, (users) => { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const deprecatedJQueryDropdown = this.instance || this.options.instance; - deprecatedJQueryDropdown.options.processData(term, users, callback); - }); - }, - processData(term, dataArg, callback) { - // Sometimes the `dataArg` can contain special dropdown items like - // dividers which we don't want to consider here. - const data = dataArg.filter((x) => !x.type); - - let users = data; - - // Only show assigned user list when there is no search term - if ($dropdown.hasClass('js-multiselect') && term.length === 0) { - const selectedInputs = getSelectedUserInputs(); - - // Potential duplicate entries when dealing with issue board - // because issue board is also managed by vue - const selectedUsers = uniqBy(selectedInputs, (a) => a.value) - .filter((input) => { - const userId = parseInt(input.value, 10); - const inUsersArray = users.find((u) => u.id === userId); - - return !inUsersArray && userId !== 0; - }) - .map((input) => { - const userId = parseInt(input.value, 10); - const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; - return { - avatar_url: avatarUrl || avatar_url || gon.default_avatar_url, - id: userId, - name, - username, - can_merge: parseBoolean(canMerge), - }; - }); + ); + + return initDeprecatedJQueryDropdown($dropdown, { + showMenuAbove, + data(term, callback) { + return userSelect.users(term, options, (users) => { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const deprecatedJQueryDropdown = this.instance || this.options.instance; + deprecatedJQueryDropdown.options.processData(term, users, callback); + }); + }, + processData(term, dataArg, callback) { + // Sometimes the `dataArg` can contain special dropdown items like + // dividers which we don't want to consider here. + const data = dataArg.filter((x) => !x.type); + + let users = data; + + // Only show assigned user list when there is no search term + if ($dropdown.hasClass('js-multiselect') && term.length === 0) { + const selectedInputs = getSelectedUserInputs(); + + // Potential duplicate entries when dealing with issue board + // because issue board is also managed by vue + const selectedUsers = uniqBy(selectedInputs, (a) => a.value) + .filter((input) => { + const userId = parseInt(input.value, 10); + const inUsersArray = users.find((u) => u.id === userId); + + return !inUsersArray && userId !== 0; + }) + .map((input) => { + const userId = parseInt(input.value, 10); + const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; + return { + avatar_url: avatarUrl || avatar_url || gon.default_avatar_url, + id: userId, + name, + username, + can_merge: parseBoolean(canMerge), + }; + }); - users = data.concat(selectedUsers); - } + users = data.concat(selectedUsers); + } - let anyUser; - let index; - let len; - let name; - let obj; - let showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = 0, len = users.length; index < len; index += 1) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; + let anyUser; + let index; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = 0, len = users.length; index < len; index += 1) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; + } } } - } - if (showNullUser) { - showDivider += 1; - users.unshift({ - beforeDivider: true, - name: s__('UsersSelect|Unassigned'), - id: 0, - }); - } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: s__('UsersSelect|Unassigned'), + id: 0, + }); } - anyUser = { - beforeDivider: true, - name, - id: null, - }; - users.unshift(anyUser); - } - - if (showDivider) { - users.splice(showDivider, 0, { type: 'divider' }); - } - - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter((i) => i !== 0); - - if ($dropdown.data('showSuggested')) { - const suggested = this.suggestedUsers(users); - if (suggested.length) { - users = users.filter( - (u) => !u.suggested || (u.suggested && selected.indexOf(u.id) !== -1), - ); - users.splice(showDivider + 1, 0, ...suggested); + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); } + anyUser = { + beforeDivider: true, + name, + id: null, + }; + users.unshift(anyUser); } - if (selected.length > 0) { - if ($dropdown.data('dropdownHeader')) { - showDivider += 1; - users.splice(showDivider, 0, { - type: 'header', - content: $dropdown.data('dropdownHeader'), - }); + if (showDivider) { + users.splice(showDivider, 0, { type: 'divider' }); + } + + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter((i) => i !== 0); + + if ($dropdown.data('showSuggested')) { + const suggested = this.suggestedUsers(users); + if (suggested.length) { + users = users.filter( + (u) => !u.suggested || (u.suggested && selected.indexOf(u.id) !== -1), + ); + users.splice(showDivider + 1, 0, ...suggested); + } } - const selectedUsers = users - .filter((u) => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + if (selected.length > 0) { + if ($dropdown.data('dropdownHeader')) { + showDivider += 1; + users.splice(showDivider, 0, { + type: 'header', + content: $dropdown.data('dropdownHeader'), + }); + } - users = users.filter((u) => selected.indexOf(u.id) === -1); + const selectedUsers = users + .filter((u) => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - selectedUsers.forEach((selectedUser) => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + users = users.filter((u) => selected.indexOf(u.id) === -1); - users.splice(showDivider + 1, 0, { type: 'divider' }); + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); + + users.splice(showDivider + 1, 0, { type: 'divider' }); + } } } - } - callback(users); - if (showMenuAbove) { - $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove(); - } - }, - suggestedUsers(users) { - const selected = getSelected().filter((i) => i !== 0); - const suggestedUsers = users.filter((u) => u.suggested && selected.indexOf(u.id) === -1); - - if (!suggestedUsers.length) return []; - - const items = [ - { - type: 'header', - content: suggestedReviewersHeaderTemplate({ - header: $dropdown.data('suggestedReviewersHeader'), - }), - }, - ...suggestedUsers, - { type: 'header', content: $dropdown.data('allMembersHeader') }, - ]; - return items; - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'], - }, - selectable: true, - fieldName: $dropdown.data('fieldName'), - toggleLabel(selected, el, deprecatedJQueryDropdown) { - const inputValue = deprecatedJQueryDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = deprecatedJQueryDropdown.filteredFullData(); - const callback = deprecatedJQueryDropdown.parseData.bind(deprecatedJQueryDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + callback(users); + if (showMenuAbove) { + $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove(); + } + }, + suggestedUsers(users) { + const selected = getSelected().filter((i) => i !== 0); + const suggestedUsers = users.filter((u) => u.suggested && selected.indexOf(u.id) === -1); + + if (!suggestedUsers.length) return []; + + const items = [ + { + type: 'header', + content: suggestedReviewersHeaderTemplate({ + header: $dropdown.data('suggestedReviewersHeader'), + }), + }, + ...suggestedUsers, + { type: 'header', content: $dropdown.data('allMembersHeader') }, + ]; + return items; + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'], + }, + selectable: true, + fieldName: $dropdown.data('fieldName'), + toggleLabel(selected, el, deprecatedJQueryDropdown) { + const inputValue = deprecatedJQueryDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = deprecatedJQueryDropdown.filteredFullData(); + const callback = deprecatedJQueryDropdown.parseData.bind(deprecatedJQueryDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } + return selected.name; } - return selected.name; - } - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; - }, - defaultLabel, - hidden() { - if ($dropdown.hasClass('js-multiselect')) { - if ($dropdown.hasClass(elsClassName)) { - if (!$dropdown.closest('.merge-request-form').length) { - $dropdown.data('deprecatedJQueryDropdown').clearMenu(); - $dropdown.closest('.selectbox').children('input[type="hidden"]').remove(); + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + }, + defaultLabel, + hidden() { + if ($dropdown.hasClass('js-multiselect')) { + if ($dropdown.hasClass(elsClassName)) { + if (!$dropdown.closest('.merge-request-form').length) { + $dropdown.data('deprecatedJQueryDropdown').clearMenu(); + $dropdown.closest('.selectbox').children('input[type="hidden"]').remove(); + } + emitSidebarEvent('sidebar.saveReviewers'); + } else { + emitSidebarEvent('sidebar.saveAssignees'); } - emitSidebarEvent('sidebar.saveReviewers'); - } else { - emitSidebarEvent('sidebar.saveAssignees'); } - } - if (!$dropdown.data('alwaysShowSelectbox')) { - $selectbox.hide(); + if (!$dropdown.data('alwaysShowSelectbox')) { + $selectbox.hide(); - // Recalculate where .value is because vue might have changed it - $block = $selectbox.closest('.block'); - $value = $block.find('.value'); - // display:block overrides the hide-collapse rule - $value.css('display', ''); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } - $('.dropdown-input-field', $block).val(''); - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - inputMeta: $dropdown.data('inputMeta'), - clicked(options) { - const { $el, e, isMarking } = options; - const user = options.selectedObj; + $('.dropdown-input-field', $block).val(''); + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('inputMeta'), + clicked(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; - dispose($el); + dispose($el); - if ($dropdown.hasClass('js-multiselect')) { - const isActive = $el.hasClass('is-active'); - const previouslySelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); - // Enables support for limiting the number of users selected - // Automatically removes the first on the list if more users are selected - checkMaxSelect(); + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); - if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { - // Unassigned selected - previouslySelected.each((index, element) => { - element.remove(); - }); - if ($dropdown.hasClass(elsClassName)) { - emitSidebarEvent('sidebar.removeAllReviewers'); + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + element.remove(); + }); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeAllReviewers'); + } else { + emitSidebarEvent('sidebar.removeAllAssignees'); + } + } else if (isActive) { + // user selected + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.addReviewer', user); + } else { + emitSidebarEvent('sidebar.addAssignee', user); + } + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } } else { - emitSidebarEvent('sidebar.removeAllAssignees'); + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('fieldName'), 0, {}); + } + + // User unselected + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeReviewer', user); + } else { + emitSidebarEvent('sidebar.removeAssignee', user); + } } - } else if (isActive) { - // user selected - if ($dropdown.hasClass(elsClassName)) { - emitSidebarEvent('sidebar.addReviewer', user); + + if (getSelected().find((u) => u === gon.current_user_id)) { + $assignToMeLink.hide(); } else { - emitSidebarEvent('sidebar.addAssignee', user); + $assignToMeLink.show(); } + } - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === page && page === 'projects:merge_requests:index'; + if ( + $dropdown.hasClass('js-filter-bulk-update') || + $dropdown.hasClass('js-issuable-form-dropdown') + ) { + e.preventDefault(); - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('fieldName'), 0, {}); - } + const isSelecting = user.id !== selectedId; + selectedId = isSelecting ? user.id : selectedIdDefault; - // User unselected - if ($dropdown.hasClass(elsClassName)) { - emitSidebarEvent('sidebar.removeReviewer', user); + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); } else { - emitSidebarEvent('sidebar.removeAssignee', user); + $('.assign-to-me-link').show(); } + return; } - - if (getSelected().find((u) => u === gon.current_user_id)) { - $assignToMeLink.hide(); - } else { - $assignToMeLink.show(); + if (handleClick) { + e.preventDefault(); + handleClick(user, isMarking); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + const selected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}']`) + .val(); + return assignTo(selected); } - } - - const page = $('body').attr('data-page'); - const isIssueIndex = page === 'projects:issues:index'; - const isMRIndex = page === page && page === 'projects:merge_requests:index'; - if ( - $dropdown.hasClass('js-filter-bulk-update') || - $dropdown.hasClass('js-issuable-form-dropdown') - ) { - e.preventDefault(); - const isSelecting = user.id !== selectedId; - selectedId = isSelecting ? user.id : selectedIdDefault; + // Automatically close dropdown after assignee is selected + // since CE has no multiple assignees + // EE does not have a max-select + if ($dropdown.data('maxSelect') && getSelected().length === $dropdown.data('maxSelect')) { + // Close the dropdown + $dropdown.dropdown('toggle'); + } + }, + id(user) { + return user.id; + }, + opened(e) { + const $el = $(e.currentTarget); + const selected = getSelected(); + $el.find('.is-active').removeClass('is-active'); + + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); + if (selected.length > 0) { + getSelected().forEach((selectedId) => highlightSelected(selectedId)); } else { - $('.assign-to-me-link').show(); + highlightSelected(selectedId); } - return; - } - if (handleClick) { - e.preventDefault(); - handleClick(user, isMarking); - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if (!$dropdown.hasClass('js-multiselect')) { - const selected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}']`) - .val(); - return assignTo(selected); - } + }, + updateLabel: $dropdown.data('dropdownTitle'), + renderRow(user) { + const username = user.username ? `@${user.username}` : ''; + const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; - // Automatically close dropdown after assignee is selected - // since CE has no multiple assignees - // EE does not have a max-select - if ($dropdown.data('maxSelect') && getSelected().length === $dropdown.data('maxSelect')) { - // Close the dropdown - $dropdown.dropdown('toggle'); - } - }, - id(user) { - return user.id; - }, - opened(e) { - const $el = $(e.currentTarget); - const selected = getSelected(); - $el.find('.is-active').removeClass('is-active'); - - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } - - if (selected.length > 0) { - getSelected().forEach((selectedId) => highlightSelected(selectedId)); - } else { - highlightSelected(selectedId); - } - }, - updateLabel: $dropdown.data('dropdownTitle'), - renderRow(user) { - const username = user.username ? `@${user.username}` : ''; - const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; - - let selected = false; + let selected = false; - if (this.multiSelect) { - selected = getSelected().find((u) => user.id === u); + if (this.multiSelect) { + selected = getSelected().find((u) => user.id === u); - const { fieldName } = this; - const field = $dropdown - .closest('.selectbox') - .find(`input[name='${fieldName}'][value='${user.id}']`); + const { fieldName } = this; + const field = $dropdown + .closest('.selectbox') + .find(`input[name='${fieldName}'][value='${user.id}']`); - if (field.length) { - selected = true; + if (field.length) { + selected = true; + } + } else { + selected = user.id === selectedId; } - } else { - selected = user.id === selectedId; - } - let img = ''; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${escape( - user.name, - )}</a></li>`; - } else { - // 0 margin, because it's now handled by a wrapper - img = `<img src='${avatar}' class='avatar avatar-inline gl-m-0!' width='32' />`; - } + let img = ''; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${escape( + user.name, + )}</a></li>`; + } else { + // 0 margin, because it's now handled by a wrapper + img = `<img src='${avatar}' class='avatar avatar-inline gl-m-0!' width='32' />`; + } - return userSelect.renderRow( - options.issuableType, - user, - selected, - username, - img, - elsClassName, - ); - }, - }); - }); + return userSelect.renderRow( + options.issuableType, + user, + selected, + username, + img, + elsClassName, + ); + }, + }) + .get() + .map((dropdown) => dropdown.GitLabDropdownInstance); + }) + .get(); } // Return users list. Filtered by query diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js index e30982985b3..0d9ededc550 100644 --- a/app/assets/javascripts/visibility_level/constants.js +++ b/app/assets/javascripts/visibility_level/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private'; export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal'; @@ -45,6 +45,12 @@ export const PROJECT_VISIBILITY_TYPE = { ), }; +export const ORGANIZATION_VISIBILITY_TYPE = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: s__( + 'Organization|Public - The organization can be accessed without any authentication.', + ), +}; + export const VISIBILITY_TYPE_ICON = { [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth', [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index 952ff9b18e9..c49c1316b1b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -4,32 +4,26 @@ import { GlPopover, GlSprintf, GlLink, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlTooltipDirective, } from '@gitlab/ui'; -import { sprintf, __ } from '~/locale'; export default { + name: 'ActionButtons', components: { GlButton, GlPopover, GlSprintf, GlLink, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, directives: { GlTooltip: GlTooltipDirective, }, props: { - widget: { - type: String, - required: false, - default: '', - }, tertiaryButtons: { type: Array, + // fix `spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js` before making this required required: false, default: () => [], }, @@ -41,17 +35,26 @@ export default { }; }, computed: { - dropdownLabel() { - if (!this.widget) return undefined; - - return sprintf(__('%{widget} options'), { widget: this.widget }); - }, hasOneOption() { return this.tertiaryButtons.length === 1; }, hasMultipleOptions() { return this.tertiaryButtons.length > 1; }, + dropdownItems() { + return this.tertiaryButtons.map((item) => { + return { + ...item, + text: item.text, + href: item.href, + extraAttrs: { + dataClipboardText: item.dataClipboardText, + dataMethod: item.dataMethod, + target: item.target, + }, + }; + }); + }, }, methods: { onClickAction(action) { @@ -135,32 +138,18 @@ export default { </span> </template> <template v-if="hasMultipleOptions"> - <gl-dropdown + <gl-disclosure-dropdown v-gl-tooltip + :items="dropdownItems" :title="__('Options')" - :text="dropdownLabel" icon="ellipsis_v" no-caret category="tertiary" - right - lazy text-sr-only size="small" - toggle-class="gl-p-2!" class="gl-display-block gl-md-display-none!" - > - <gl-dropdown-item - v-for="(btn, index) in tertiaryButtons" - :key="index" - :href="btn.href" - :target="btn.target" - :data-clipboard-text="btn.dataClipboardText" - :data-method="btn.dataMethod" - @click="onClickAction(btn)" - > - {{ btn.text }} - </gl-dropdown-item> - </gl-dropdown> + @action="onClickAction" + /> <span v-for="(btn, index) in tertiaryButtons" :key="index"> <gl-button :id="btn.id" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue index 5090081d281..3b62345b969 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -70,7 +70,8 @@ export default { message() { if (this.state === STATUS_CLOSED) { return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.'); - } else if (this.isMerged) { + } + if (this.isMerged) { return s__( 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.', ); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 95fa01c23f1..4ed470440cc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -131,7 +131,8 @@ export default { variant: 'confirm', action: () => this.approve(), }; - } else if (this.showUnapprove) { + } + if (this.showUnapprove) { return { text: s__('mrWidget|Revoke approval'), variant: 'default', diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index e79d2db4b5a..7a3dd4ca35e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -1,9 +1,7 @@ <script> import { createAlert } from '~/alert'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import MRWidgetService from '../../services/mr_widget_service'; import { @@ -25,7 +23,6 @@ export default { DeploymentActionButton, DeploymentViewButton, }, - mixins: [glFeatureFlagsMixin()], props: { computedDeploymentStatus: { type: String, @@ -71,10 +68,7 @@ export default { return this.deployment.details?.playable_build?.play_path; }, redeployPath() { - if (this.redeployMrWidgetFeatureFlagEnabled) { - return this.deployment.retry_url; - } - return this.deployment.details?.playable_build?.retry_path; + return this.deployment.retry_url; }, stopUrl() { return this.deployment.stop_url; @@ -82,13 +76,8 @@ export default { environmentAvailable() { return Boolean(this.deployment.environment_available); }, - redeployMrWidgetFeatureFlagEnabled() { - return this.glFeatures.reviewAppsRedeployMrWidget; - }, showDeploymentActionButton() { - return ( - this.redeployPath && !this.environmentAvailable && this.redeployMrWidgetFeatureFlagEnabled - ); + return this.redeployPath && !this.environmentAvailable; }, }, actionsConfiguration: { @@ -137,16 +126,6 @@ export default { this.actionInProgress = actionName; MRWidgetService.executeInlineAction(endpoint) - .then((resp) => { - if (this.redeployMrWidgetFeatureFlagEnabled) { - return; - } - - const redirectUrl = resp?.data?.redirect_url; - if (redirectUrl) { - visitUrl(redirectUrl); - } - }) .catch(() => { createAlert({ message: errorMessage, @@ -184,17 +163,6 @@ export default { > <span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span> </deployment-action-button> - <deployment-action-button - v-if="canBeManuallyRedeployed && !redeployMrWidgetFeatureFlagEnabled" - :action-in-progress="actionInProgress" - :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]" - :computed-deployment-status="computedDeploymentStatus" - :icon="$options.btnIcons.repeat" - container-classes="js-manual-redeploy-action" - @click="redeploy" - > - <span>{{ $options.actionsConfiguration[constants.REDEPLOYING].buttonText }}</span> - </deployment-action-button> <deployment-view-button v-if="hasExternalUrls && environmentAvailable" :app-button-text="appButtonText" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index c7d34d45f06..efe71ed569a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -58,7 +58,8 @@ export default { return s__( 'mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} increased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB', ); - } else if (memoryTo < memoryFrom) { + } + if (memoryTo < memoryFrom) { return s__( 'mrWidget|%{metricsLinkStart} Memory %{metricsLinkEnd} usage %{emphasisStart} decreased %{emphasisEnd} from %{memoryFrom}MB to %{memoryTo}MB', ); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 31bf62b7e52..3e2f3ab4103 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -305,11 +305,7 @@ export default { </script> <template> - <section - class="media-section" - data-testid="widget-extension" - data-qa-selector="mr_widget_extension" - > + <section class="media-section" data-testid="widget-extension"> <state-container :status="statusIconName" :is-loading="isLoadingSummary" @@ -346,11 +342,7 @@ export default { </template> </template> </div> - <actions - :widget="$options.label || $options.name" - :tertiary-buttons="tertiaryActionsButtons" - @clickedAction="onClickedAction" - /> + <actions :tertiary-buttons="tertiaryActionsButtons" @clickedAction="onClickedAction" /> <div v-if="isCollapsible" class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" @@ -363,7 +355,6 @@ export default { :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" category="tertiary" data-testid="toggle-button" - data-qa-selector="toggle_button" size="small" @click="toggleCollapsed" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index fa369d23b6c..5f0fd973e84 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -108,7 +108,6 @@ export default { </gl-badge> </div> <actions - :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto gl-pl-3" @clickedAction="onClickedAction" @@ -128,7 +127,6 @@ export default { :modal-id="modalId" :level="3" data-testid="child-content" - data-qa-selector="child_content" @clickedAction="onClickedAction" /> </li> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js index 4f8f8d6cb58..b6bcc68e5e0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js @@ -8,8 +8,10 @@ import { function simplifyWidgetName(componentName) { const noWidget = componentName.replace(/^Widget/, ''); + const camelName = noWidget.charAt(0).toLowerCase() + noWidget.slice(1); + const tierlessName = camelName.replace(/(CE|EE)$/, ''); - return noWidget.charAt(0).toLowerCase() + noWidget.slice(1); + return tierlessName; } function baseRedisEventName(extensionName) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js index 757178ee336..83f5c1490e2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js @@ -65,11 +65,8 @@ const createText = (text) => { export const generateText = (text) => { if (typeof text === 'string') { return createText(escapeText(text)); - } else if ( - typeof text === 'object' && - typeof text.text === 'string' && - typeof text.href === 'string' - ) { + } + if (typeof text === 'object' && typeof text.text === 'string' && typeof text.href === 'string') { return createText( `${ text.prependText ? `${escapeText(text.prependText)} ` : '' diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index e94e0fbe6dc..bfcd4610379 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -11,9 +11,9 @@ import { import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { MT_MERGE_STRATEGY } from '../constants'; @@ -183,7 +183,7 @@ export default { v-gl-tooltip :href="ciTroubleshootingDocsPath" target="_blank" - :title="__('About this feature')" + :title="__('Get more information about troubleshooting pipelines')" class="gl-display-flex gl-align-items-center gl-ml-2" > <gl-icon @@ -205,9 +205,7 @@ export default { data-qa-selector="merge_request_pipeline_info_content" class="gl-display-flex gl-flex-wrap gl-align-items-center gl-justify-content-space-between" > - <p - class="mr-pipeline-title gl-m-0! gl-mr-3! gl-font-weight-bold gl-line-height-32 gl-text-gray-900" - > + <p class="mr-pipeline-title gl-m-0! gl-mr-3! gl-font-weight-bold gl-text-gray-900"> {{ pipeline.details.event_type_name }} <gl-link :href="pipeline.path" @@ -253,7 +251,7 @@ export default { v-safe-html="sourceBranchLink" :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate gl-font-weight-normal gl-vertical-align-text-bottom" + class="label-branch label-truncate gl-font-weight-normal" /> </template> <template v-if="finishedAt"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index 400759aa086..4f39bd1d972 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -38,7 +38,7 @@ export default { }, modifyLinkMessage() { if (this.isFastForwardEnabled) return __('Modify commit message'); - else if (this.isSquashEnabled) return __('Modify commit messages'); + if (this.isSquashEnabled) return __('Modify commit messages'); return __('Modify merge commit'); }, ariaLabel() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 61eec503951..bf2c5e52184 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -30,9 +30,11 @@ export default { failedText() { if (this.mr.approvals && !this.mr.isApproved) { return this.$options.i18n.approvalNeeded; - } else if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.BLOCKED_STATUS) { + } + if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.BLOCKED_STATUS) { return this.$options.i18n.blockingMergeRequests; - } else if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.EXTERNAL_STATUS_CHECKS) { + } + if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.EXTERNAL_STATUS_CHECKS) { return this.$options.i18n.externalStatusChecksFailed; } 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 7071759b8bb..0ce8389579d 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 @@ -488,7 +488,7 @@ export default { mergeAndSquashCommitTemplatesHintText: s__( 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more%{linkEnd}.', ), - sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch'), + sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch.'), divergedCommits: (count) => n__('%d commit behind', '%d commits behind', count), }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 9bb39ba22e0..8249dffcc27 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -5,6 +5,7 @@ export default { import( '~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue' ), + MrTestReportWidget: () => import('~/vue_merge_request_widget/extensions/test_report/index.vue'), MrTerraformWidget: () => import('~/vue_merge_request_widget/extensions/terraform/index.vue'), MrCodeQualityWidget: () => import('~/vue_merge_request_widget/extensions/code_quality/index.vue'), @@ -18,6 +19,10 @@ export default { }, computed: { + testReportWidget() { + return this.mr.testResultsPath && 'MrTestReportWidget'; + }, + terraformPlansWidget() { return this.mr.terraformReportsPath && 'MrTerraformWidget'; }, @@ -27,9 +32,12 @@ export default { }, widgets() { - return [this.codeQualityWidget, this.terraformPlansWidget, 'MrSecurityWidget'].filter( - (w) => w, - ); + return [ + this.codeQualityWidget, + this.testReportWidget, + this.terraformPlansWidget, + 'MrSecurityWidget', + ].filter((w) => w); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue index 618d1e71f81..72c041759d9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -92,7 +92,6 @@ export default { </div> <actions v-if="hasActionButtons" - :widget="widgetName" :tertiary-buttons="data.actions" class="gl-ml-auto gl-pl-3" @clickedAction="onClickedAction" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index 2c8bf90064e..d17be3e4037 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -368,7 +368,6 @@ export default { <slot name="action-buttons"> <action-buttons v-if="actionButtons.length > 0" - :widget="widgetName" :tertiary-buttons="actionButtons" @clickedAction="onActionClick" /> 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 e67924d28ab..bb82da7796a 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 @@ -128,11 +128,7 @@ export default { > </template> </help-popover> - <action-buttons - v-if="hasActionButtons" - :widget="widgetName" - :tertiary-buttons="actionButtons" - /> + <action-buttons v-if="hasActionButtons" :tertiary-buttons="actionButtons" /> </div> </div> <div class="gl-display-flex gl-align-items-baseline"> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js index 713c9e610b3..3af984dcf6c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -23,14 +23,17 @@ export default { const { newErrors, resolvedErrors, parsingInProgress } = data; if (parsingInProgress) { return i18n.loading; - } else if (newErrors.length >= 1 && resolvedErrors.length >= 1) { + } + if (newErrors.length >= 1 && resolvedErrors.length >= 1) { return i18n.improvementAndDegradationCopy( i18n.findings(resolvedErrors, codeQualityPrefixes.fixed), i18n.findings(newErrors, codeQualityPrefixes.new), ); - } else if (resolvedErrors.length >= 1) { + } + if (resolvedErrors.length >= 1) { return i18n.singularCopy(i18n.findings(resolvedErrors, codeQualityPrefixes.fixed)); - } else if (newErrors.length >= 1) { + } + if (newErrors.length >= 1) { return i18n.singularCopy(i18n.findings(newErrors, codeQualityPrefixes.new)); } return i18n.noChanges; @@ -38,7 +41,8 @@ export default { statusIcon() { if (this.collapsedData.newErrors.length >= 1) { return EXTENSION_ICONS.warning; - } else if (this.collapsedData.resolvedErrors.length >= 1) { + } + if (this.collapsedData.resolvedErrors.length >= 1) { return EXTENSION_ICONS.success; } return EXTENSION_ICONS.neutral; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue index d30acf24684..cd3a98effa3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue @@ -36,9 +36,11 @@ export default { if (!this.pollingFinished) { return { title: i18n.loading }; - } else if (this.hasError) { + } + if (this.hasError) { return { title: i18n.error }; - } else if ( + } + if ( this.collapsedData?.new_errors?.length >= 1 && this.collapsedData?.resolved_errors?.length >= 1 ) { @@ -48,11 +50,13 @@ export default { i18n.findings(new_errors, codeQualityPrefixes.new), ), }; - } else if (this.collapsedData?.resolved_errors?.length >= 1) { + } + if (this.collapsedData?.resolved_errors?.length >= 1) { return { title: i18n.singularCopy(i18n.findings(resolved_errors, codeQualityPrefixes.fixed)), }; - } else if (this.collapsedData?.new_errors?.length >= 1) { + } + if (this.collapsedData?.new_errors?.length >= 1) { return { title: i18n.singularCopy(i18n.findings(new_errors, codeQualityPrefixes.new)) }; } return { title: i18n.noChanges }; @@ -95,7 +99,8 @@ export default { statusIcon() { if (this.collapsedData?.new_errors?.length >= 1) { return EXTENSION_ICONS.warning; - } else if (this.collapsedData?.resolved_errors?.length >= 1) { + } + if (this.collapsedData?.resolved_errors?.length >= 1) { return EXTENSION_ICONS.success; } return EXTENSION_ICONS.neutral; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js deleted file mode 100644 index 6ac462d4ad5..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js +++ /dev/null @@ -1,189 +0,0 @@ -import { uniqueId } from 'lodash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; -import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; -import { EXTENSION_ICONS } from '../../constants'; -import { - summaryTextBuilder, - reportTextBuilder, - reportSubTextBuilder, - countRecentlyFailedTests, - recentFailuresTextBuilder, - formatFilePath, -} from './utils'; -import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants'; - -export default { - name: 'WidgetTestSummary', - enablePolling: true, - i18n, - props: ['testResultsPath', 'headBlobPath', 'pipeline'], - modalComponent: TestCaseDetails, - computed: { - failedTestNames() { - if (!this.collapsedData?.suites) { - return ''; - } - - const newFailures = this.collapsedData?.suites.flatMap((suite) => [suite.new_failures || []]); - const fileNames = newFailures.flatMap((newFailure) => { - return newFailure.map((failure) => { - return failure.file; - }); - }); - - return fileNames.join(' ').trim(); - }, - summary(data) { - if (data.parsingInProgress) { - return this.$options.i18n.loading; - } - if (data.hasSuiteError) { - return this.$options.i18n.error; - } - return { - subject: summaryTextBuilder(this.$options.i18n.label, data.summary), - meta: recentFailuresTextBuilder(data.summary), - }; - }, - statusIcon(data) { - if (data.status === TESTS_FAILED_STATUS) { - return EXTENSION_ICONS.warning; - } - if (data.hasSuiteError) { - return EXTENSION_ICONS.failed; - } - return EXTENSION_ICONS.success; - }, - tertiaryButtons() { - const actionButtons = []; - - if (this.failedTestNames().length > 0) { - actionButtons.push({ - dataClipboardText: this.failedTestNames(), - id: uniqueId('copy-to-clipboard'), - icon: 'copy-to-clipboard', - testId: 'copy-failed-specs-btn', - text: this.$options.i18n.copyFailedSpecs, - tooltipText: this.$options.i18n.copyFailedSpecsTooltip, - tooltipOnClick: __('Copied'), - }); - } - - actionButtons.push({ - text: this.$options.i18n.fullReport, - href: `${this.pipeline.path}/test_report`, - target: '_blank', - trackFullReportClicked: true, - testId: 'full-report-link', - }); - - return actionButtons; - }, - }, - methods: { - fetchCollapsedData() { - return axios.get(this.testResultsPath).then((response) => { - const { data = {}, status } = response; - const { suites = [], summary = {} } = data; - - return { - ...response, - data: { - hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS), - parsingInProgress: status === HTTP_STATUS_NO_CONTENT, - ...data, - summary: { - recentlyFailed: countRecentlyFailedTests(suites), - ...summary, - }, - }, - }; - }); - }, - fetchFullData() { - return Promise.resolve(this.prepareReports()); - }, - suiteIcon(suite) { - if (suite.status === ERROR_STATUS) { - return EXTENSION_ICONS.error; - } - if (suite.status === TESTS_FAILED_STATUS) { - return EXTENSION_ICONS.failed; - } - return EXTENSION_ICONS.success; - }, - testHeader(test, sectionHeader, index) { - const headers = []; - if (index === 0) { - headers.push(sectionHeader); - } - if (test.recent_failures?.count && test.recent_failures?.base_branch) { - headers.push(i18n.recentFailureCount(test.recent_failures)); - } - return headers; - }, - mapTestAsChild({ iconName, sectionHeader }) { - return (test, index) => { - return { - id: uniqueId('test-'), - header: this.testHeader(test, sectionHeader, index), - modal: { - text: test.name, - onClick: () => { - this.modalData = { - testCase: { - filePath: test.file && `${this.headBlobPath}/${formatFilePath(test.file)}`, - ...test, - }, - }; - }, - }, - icon: { name: iconName }, - }; - }; - }, - prepareReports() { - return this.collapsedData.suites - .map((suite) => { - return { - ...suite, - summary: { - recentlyFailed: countRecentlyFailedTests(suite), - ...suite.summary, - }, - }; - }) - .map((suite) => { - return { - id: uniqueId('suite-'), - text: reportTextBuilder(suite), - subtext: reportSubTextBuilder(suite), - icon: { - name: this.suiteIcon(suite), - }, - children: [ - ...[...suite.new_failures, ...suite.new_errors].map( - this.mapTestAsChild({ - sectionHeader: i18n.newHeader, - iconName: EXTENSION_ICONS.failed, - }), - ), - ...[...suite.existing_failures, ...suite.existing_errors].map( - this.mapTestAsChild({ - iconName: EXTENSION_ICONS.failed, - }), - ), - ...[...suite.resolved_failures, ...suite.resolved_errors].map( - this.mapTestAsChild({ - sectionHeader: i18n.fixedHeader, - iconName: EXTENSION_ICONS.success, - }), - ), - ], - }; - }); - }, - }, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue new file mode 100644 index 00000000000..1b03b9c04e1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue @@ -0,0 +1,313 @@ +<script> +import { uniqueId, uniq } from 'lodash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; +import TestCaseDetails from '~/ci/pipeline_details/test_reports/test_case_details.vue'; +import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; +import MrWidgetRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; +import { EXTENSION_ICONS } from '../../constants'; +import { + summaryTextBuilder, + reportTextBuilder, + reportSubTextBuilder, + countRecentlyFailedTests, + recentFailuresTextBuilder, + formatFilePath, +} from './utils'; +import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants'; + +export default { + name: 'WidgetTestReport', + components: { + MrWidget, + MrWidgetRow, + DynamicScroller, + DynamicScrollerItem, + TestCaseDetails, + }, + i18n, + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + collapsedData: {}, + suites: [], + modalData: null, + }; + }, + computed: { + failedTestNames() { + const { data: { suites = [] } = {} } = this.collapsedData; + + if (!this.hasSuites) { + return ''; + } + + const newFailures = suites.flatMap((suite) => [suite.new_failures || []]); + const fileNames = newFailures.flatMap((newFailure) => { + return newFailure.map((failure) => { + return failure.file; + }); + }); + + return uniq(fileNames).join(' ').trim(); + }, + summary() { + const { + data: { parsingInProgress = false, hasSuiteError = false, summary = {} } = {}, + } = this.collapsedData; + + if (parsingInProgress) { + return { title: this.$options.i18n.loading }; + } + if (hasSuiteError) { + return { title: this.$options.i18n.error }; + } + return { + title: summaryTextBuilder(this.$options.i18n.label, summary), + subtitle: recentFailuresTextBuilder(summary), + }; + }, + statusIcon() { + const { data: { status = null, hasSuiteError = false } = {} } = this.collapsedData; + + if (status === TESTS_FAILED_STATUS) { + return EXTENSION_ICONS.warning; + } + if (hasSuiteError) { + return EXTENSION_ICONS.failed; + } + return EXTENSION_ICONS.success; + }, + tertiaryButtons() { + const actionButtons = []; + + if (this.failedTestNames.length > 0) { + actionButtons.push({ + dataClipboardText: this.failedTestNames, + id: uniqueId('copy-to-clipboard'), + icon: 'copy-to-clipboard', + testId: 'copy-failed-specs-btn', + text: this.$options.i18n.copyFailedSpecs, + tooltipText: this.$options.i18n.copyFailedSpecsTooltip, + tooltipOnClick: __('Copied'), + }); + } + + actionButtons.push({ + text: this.$options.i18n.fullReport, + href: `${this.mr.pipeline.path}/test_report`, + target: '_blank', + trackFullReportClicked: true, + testId: 'full-report-link', + }); + + return actionButtons; + }, + testResultsPath() { + return this.mr.testResultsPath; + }, + hasSuites() { + return this.suites.length > 0; + }, + }, + methods: { + fetchCollapsedData() { + return axios.get(this.testResultsPath).then((response) => { + const { data = {}, status } = response; + const { suites = [], summary = {} } = data; + + this.collapsedData = { + ...response, + data: { + hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS), + parsingInProgress: status === HTTP_STATUS_NO_CONTENT, + ...data, + summary: { + recentlyFailed: countRecentlyFailedTests(suites), + ...summary, + }, + }, + }; + this.suites = this.prepareSuites(this.collapsedData); + + return response; + }); + }, + suiteIcon(suite) { + if (suite.status === ERROR_STATUS) { + return EXTENSION_ICONS.error; + } + if (suite.status === TESTS_FAILED_STATUS) { + return EXTENSION_ICONS.failed; + } + return EXTENSION_ICONS.success; + }, + testHeader(test, sectionHeader, index) { + const headers = []; + if (index === 0) { + headers.push(sectionHeader); + } + if (test.recent_failures?.count && test.recent_failures?.base_branch) { + headers.push(i18n.recentFailureCount(test.recent_failures)); + } + return headers; + }, + mapTestAsChild({ iconName, sectionHeader }) { + return (test, index) => { + return { + id: uniqueId('test-'), + header: this.testHeader(test, sectionHeader, index), + text: test.name, + actions: [ + { + text: __('View details'), + onClick: () => { + this.modalData = { + testCase: { + filePath: test.file && `${this.mr.headBlobPath}/${formatFilePath(test.file)}`, + ...test, + }, + }; + }, + }, + ], + icon: { name: iconName }, + }; + }; + }, + onModalHidden() { + this.modalData = null; + }, + prepareSuites(collapsedData) { + const { + data: { suites = [] }, + } = collapsedData; + + return suites + .map((suite) => { + return { + ...suite, + summary: { + recentlyFailed: countRecentlyFailedTests(suite), + ...suite.summary, + }, + }; + }) + .map((suite) => { + return { + id: uniqueId('suite-'), + text: reportTextBuilder(suite), + subtext: reportSubTextBuilder(suite), + icon: { + name: this.suiteIcon(suite), + }, + children: [ + ...[...suite.new_failures, ...suite.new_errors].map( + this.mapTestAsChild({ + sectionHeader: i18n.newHeader, + iconName: EXTENSION_ICONS.failed, + }), + ), + ...[...suite.existing_failures, ...suite.existing_errors].map( + this.mapTestAsChild({ + iconName: EXTENSION_ICONS.failed, + }), + ), + ...[...suite.resolved_failures, ...suite.resolved_errors].map( + this.mapTestAsChild({ + sectionHeader: i18n.fixedHeader, + iconName: EXTENSION_ICONS.success, + }), + ), + ], + }; + }); + }, + }, +}; +</script> +<template> + <div> + <mr-widget + :error-text="$options.i18n.error" + :status-icon-name="statusIcon" + :loading-text="$options.i18n.loading" + :action-buttons="tertiaryButtons" + :help-popover="$options.helpPopover" + :widget-name="$options.name" + :summary="summary" + :fetch-collapsed-data="fetchCollapsedData" + :is-collapsible="hasSuites" + > + <template #content> + <mr-widget-row + v-for="suite in suites" + :key="suite.id" + :level="2" + :status-icon-name="suite.icon.name" + :widget-name="$options.name" + data-testid="extension-list-item" + > + <template #header> + <div class="gl-flex-direction-column"> + <div>{{ suite.text }}</div> + <div + v-for="(subtext, i) in suite.subtext" + :key="`${suite.id}-subtext-${i}`" + class="gl-font-sm gl-text-gray-700" + > + {{ subtext }} + </div> + </div> + </template> + <template #body> + <div v-if="suite.children.length > 0" class="gl-mt-2 gl-w-full"> + <dynamic-scroller + :items="suite.children" + :min-item-size="32" + :style="{ maxHeight: '170px' }" + key-field="id" + class="gl-pr-5" + > + <template #default="{ item, active }"> + <dynamic-scroller-item :item="item" :active="active"> + <strong + v-for="(headerText, i) in item.header" + :key="`${item.id}-headerText-${i}`" + class="gl-display-block gl-mt-2" + > + {{ headerText }} + </strong> + <mr-widget-row + :key="item.id" + :level="3" + :widget-name="$options.name" + :status-icon-name="item.icon.name" + :action-buttons="item.actions" + class="gl-mt-2" + > + <template #header>{{ item.text }}</template> + </mr-widget-row> + </dynamic-scroller-item> + </template> + </dynamic-scroller> + </div> + </template> + </mr-widget-row> + </template> + </mr-widget> + <test-case-details + :modal-id="`modal${$options.name}`" + :visible="modalData !== null" + v-bind="modalData" + @hidden="onModalHidden" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js index 37f9964d23a..24f6b3e69ff 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js @@ -62,7 +62,7 @@ export const reportSubTextBuilder = ({ suite_errors: suiteErrors, summary }) => } return errors; } - return recentFailuresTextBuilder(summary); + return [recentFailuresTextBuilder(summary)]; }; export const countRecentlyFailedTests = (subject) => { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index acdcbf7afd7..175a0b0563f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -56,7 +56,6 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab import getStateQuery from './queries/get_state.query.graphql'; import getStateSubscription from './queries/get_state.subscription.graphql'; import accessibilityExtension from './extensions/accessibility'; -import testReportExtension from './extensions/test_report'; import ReportWidgetContainer from './components/report_widget_container.vue'; import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; @@ -225,9 +224,6 @@ export default { this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId, ); }, - shouldRenderTestReport() { - return Boolean(this.mr?.testResultsPath); - }, mergeError() { let { mergeError } = this.mr; @@ -281,11 +277,6 @@ export default { this.registerAccessibilityExtension(); } }, - shouldRenderTestReport(newVal) { - if (newVal) { - this.registerTestReportExtension(); - } - }, }, mounted() { MRWidgetService.fetchInitialData() @@ -525,11 +516,6 @@ export default { registerExtension(accessibilityExtension); } }, - registerTestReportExtension() { - if (this.shouldRenderTestReport) { - registerExtension(testReportExtension); - } - }, }, }; </script> @@ -569,7 +555,7 @@ export default { v-if="hasMergeError" type="danger" dismissible - data-testid="merge_error" + data-testid="merge-error" > <span v-safe-html="mergeError"></span> </mr-widget-alert-message> @@ -577,6 +563,7 @@ export default { v-if="showMergePipelineForkWarning" type="warning" :help-path="mr.mergeRequestPipelinesHelpPath" + data-testid="merge-pipeline-fork-warning" > {{ s__( diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index f90056a8e1a..d6bab074f3f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -4,31 +4,44 @@ import { stateKey } from './state_maps'; export default function deviseState() { if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.PREPARING) { return stateKey.preparing; - } else if (!this.commitsCount) { + } + if (!this.commitsCount) { return stateKey.nothingToMerge; - } else if (this.projectArchived) { + } + if (this.projectArchived) { return stateKey.archived; - } else if (this.branchMissing) { + } + if (this.branchMissing) { return stateKey.missingBranch; - } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CHECKING) { + } + if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CHECKING) { return stateKey.checking; - } else if (this.hasConflicts) { + } + if (this.hasConflicts) { return stateKey.conflicts; - } else if (this.shouldBeRebased) { + } + if (this.shouldBeRebased) { return stateKey.rebase; - } else if (this.hasMergeChecksFailed && !this.autoMergeEnabled) { + } + if (this.hasMergeChecksFailed && !this.autoMergeEnabled) { return stateKey.mergeChecksFailed; - } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CI_MUST_PASS) { + } + if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CI_MUST_PASS) { return stateKey.pipelineFailed; - } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DRAFT_STATUS) { + } + if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DRAFT_STATUS) { return stateKey.draft; - } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DISCUSSIONS_NOT_RESOLVED) { + } + if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DISCUSSIONS_NOT_RESOLVED) { return stateKey.unresolvedDiscussions; - } else if (this.canMerge && this.isSHAMismatch) { + } + if (this.canMerge && this.isSHAMismatch) { return stateKey.shaMismatch; - } else if (this.autoMergeEnabled && !this.mergeError) { + } + if (this.autoMergeEnabled && !this.mergeError) { return stateKey.autoMergeEnabled; - } else if ( + } + if ( this.detailedMergeStatus === DETAILED_MERGE_STATUS.MERGEABLE || this.detailedMergeStatus === DETAILED_MERGE_STATUS.CI_STILL_RUNNING ) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index b1c069d9b1e..bb74f82145f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,8 +1,8 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; -import { badgeState } from '~/issuable/components/status_box.vue'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants'; import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility'; import { machine } from '~/lib/utils/finite_state_machine'; +import { badgeState } from '~/merge_requests/components/merge_request_status_badge.vue'; import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, @@ -351,11 +351,14 @@ export default class MergeRequestStore { if (availableAutoMergeStrategies.includes(MTWPS_MERGE_STRATEGY)) { return MTWPS_MERGE_STRATEGY; - } else if (availableAutoMergeStrategies.includes(MT_MERGE_STRATEGY)) { + } + if (availableAutoMergeStrategies.includes(MT_MERGE_STRATEGY)) { return MT_MERGE_STRATEGY; - } else if (availableAutoMergeStrategies.includes(MWCP_MERGE_STRATEGY)) { + } + if (availableAutoMergeStrategies.includes(MWCP_MERGE_STRATEGY)) { return MWCP_MERGE_STRATEGY; - } else if (availableAutoMergeStrategies.includes(MWPS_MERGE_STRATEGY)) { + } + if (availableAutoMergeStrategies.includes(MWPS_MERGE_STRATEGY)) { return MWPS_MERGE_STRATEGY; } diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js index 106dd7a3b97..957e642fcb8 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js @@ -1 +1,5 @@ export const HIGHLIGHT_CLASS_NAME = 'hll'; +export const MARKUP_FILE_TYPE = 'markup'; +export const MARKUP_CONTENT_SELECTOR = '.js-markup-content'; +export const ELEMENTS_PER_CHUNK = 20; +export const CONTENT_LOADED_EVENT = 'richContentLoaded'; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index 11ce6afbb1d..27bdcc69120 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -3,7 +3,14 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { handleBlobRichViewer } from '~/blob/viewer'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import { handleLocationHash } from '~/lib/utils/common_utils'; +import { sanitize } from '~/lib/dompurify'; import ViewerMixin from './mixins'; +import { + MARKUP_FILE_TYPE, + MARKUP_CONTENT_SELECTOR, + ELEMENTS_PER_CHUNK, + CONTENT_LOADED_EVENT, +} from './constants'; export default { components: { @@ -16,21 +23,77 @@ export default { data() { return { isLoading: true, + initialContent: null, + remainingContent: [], }; }, + computed: { + rawContent() { + return this.initialContent || this.richViewer || this.content; + }, + isMarkup() { + return this.type === MARKUP_FILE_TYPE; + }, + }, + created() { + this.optimizeMarkupRendering(); + }, mounted() { - window.requestIdleCallback(async () => { + this.renderRemainingMarkup(); + handleBlobRichViewer(this.$refs.content, this.type); + handleLocationHash(); + }, + methods: { + optimizeMarkupRendering() { + /** + * If content is markup we optimize rendering by splitting it into two parts: + * - initialContent (top section of the file - is rendered right away) + * - remainingContent (remaining content - is rendered over a longer time period) + * + * This is done so that the browser doesn't render the whole file at once (improves TBT) + */ + + if (!this.isMarkup) return; + + const tmpWrapper = document.createElement('div'); + tmpWrapper.innerHTML = sanitize(this.rawContent, this.$options.safeHtmlConfig); + + const fileContent = tmpWrapper.querySelector(MARKUP_CONTENT_SELECTOR); + if (!fileContent) return; + + const initialContent = [...fileContent.childNodes].slice(0, ELEMENTS_PER_CHUNK); + this.remainingContent = [...fileContent.childNodes].slice(ELEMENTS_PER_CHUNK); + + fileContent.innerHTML = ''; + fileContent.append(...initialContent); + this.initialContent = tmpWrapper.outerHTML; + }, + renderRemainingMarkup() { /** - * Rendering Markdown usually takes long due to the amount of HTML being parsed. - * This ensures that content is loaded only when the browser goes into idle. + * Rendering large Markdown files can block the main thread due to the amount of HTML being parsed. + * The optimization below ensures that content is rendered over a longer time period instead of all at once. * More details here: https://gitlab.com/gitlab-org/gitlab/-/issues/331448 * */ - this.isLoading = false; - await this.$nextTick(); - handleBlobRichViewer(this.$refs.content, this.type); - handleLocationHash(); - this.$emit('richContentLoaded'); - }); + + if (!this.isMarkup || !this.remainingContent.length) { + this.$emit(CONTENT_LOADED_EVENT); + this.isLoading = false; + return; + } + + const fileContent = this.$refs.content.$el.querySelector(MARKUP_CONTENT_SELECTOR); + + for (let i = 0; i < this.remainingContent.length; i += ELEMENTS_PER_CHUNK) { + const nextChunkEnd = i + ELEMENTS_PER_CHUNK; + const content = this.remainingContent.slice(i, nextChunkEnd); + setTimeout(() => { + fileContent.append(...content); + if (nextChunkEnd < this.remainingContent.length) return; + this.$emit(CONTENT_LOADED_EVENT); + this.isLoading = false; + }, i); + } + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'], @@ -39,8 +102,8 @@ export default { </script> <template> <markdown-field-view - v-if="!isLoading" ref="content" - v-safe-html:[$options.safeHtmlConfig]="richViewer || content" + v-safe-html:[$options.safeHtmlConfig]="rawContent" + :is-loading="isLoading" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 14e99977a85..2a47e96b2e2 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -50,11 +50,14 @@ export default { tooltipTitle() { if (!this.showTooltip) { return undefined; - } else if (this.file.deleted) { + } + if (this.file.deleted) { return __('Deleted'); - } else if (this.file.tempFile) { + } + if (this.file.tempFile) { return __('Added'); - } else if (this.file.changed) { + } + if (this.file.changed) { return __('Modified'); } diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 9aa7a7d6c49..1f45b4c5c9d 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from './ci_icon.vue'; + /** * Renders CI Badge link with CI icon and status text based on * API response shared between all places where it is used. @@ -48,7 +49,7 @@ export default { required: false, default: true, }, - badgeSize: { + size: { type: String, required: false, default: badgeSizeOptions.md, @@ -59,7 +60,7 @@ export default { }, computed: { isSmallBadgeSize() { - return this.badgeSize === badgeSizeOptions.sm; + return this.size === badgeSizeOptions.sm; }, title() { return !this.showText ? this.status?.text : ''; @@ -120,13 +121,12 @@ export default { <template> <gl-badge v-gl-tooltip - :class="{ 'gl-pl-0!': isSmallBadgeSize }" + :class="{ 'gl-pl-2': isSmallBadgeSize }" :title="title" :href="detailsPath" - :size="badgeSize" + :size="size" :variant="badgeStyles.variant" - :data-testid="`ci-badge-${status.text}`" - data-qa-selector="status_badge_link" + data-testid="ci-badge-link" @click="$emit('ciStatusBadgeClick')" > <ci-icon :status="status" /> diff --git a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue index 31c98d1e3a7..025e38a55ad 100644 --- a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue +++ b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue @@ -1,10 +1,11 @@ <script> -import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { confidentialityInfoText } from '../constants'; export default { components: { GlBadge, + GlIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -18,22 +19,31 @@ export default { type: String, required: true, }, + hideTextInSmallScreens: { + type: Boolean, + required: false, + default: false, + }, }, computed: { confidentialTooltip() { return confidentialityInfoText(this.workspaceType, this.issuableType); }, + confidentialTextClass() { + return { + 'gl-display-none gl-sm-display-block': this.hideTextInSmallScreens, + 'gl-ml-2': true, + }; + }, }, }; </script> <template> - <gl-badge - v-gl-tooltip.bottom - :title="confidentialTooltip" - icon="eye-slash" - variant="warning" - class="gl-display-inline gl-mr-3" - >{{ __('Confidential') }}</gl-badge - > + <gl-badge v-gl-tooltip :title="confidentialTooltip" variant="warning"> + <gl-icon name="eye-slash" :size="16" /> + <span data-testid="confidential-badge-text" :class="confidentialTextClass">{{ + __('Confidential') + }}</span> + </gl-badge> </template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 65a601ed927..a1ef1f30ebb 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -38,7 +38,16 @@ export default { default: CONFIRM_DANGER_MODAL_CANCEL, }, }, + model: { + prop: 'visible', + event: 'change', + }, props: { + visible: { + type: Boolean, + required: false, + default: null, + }, modalId: { type: String, required: true, @@ -89,12 +98,15 @@ export default { <template> <gl-modal ref="modal" + :visible="visible" :modal-id="modalId" :data-testid="modalId" :title="$options.i18n.CONFIRM_DANGER_MODAL_TITLE" :action-primary="actionPrimary" :action-cancel="actionCancel" + size="sm" @primary="$emit('confirm')" + @change="$emit('change', $event)" > <gl-alert v-if="confirmDangerMessage" diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue deleted file mode 100644 index d8a2789a419..00000000000 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ /dev/null @@ -1,283 +0,0 @@ -<script> -import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; -import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; -import { __, sprintf } from '~/locale'; - -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import DateTimePickerInput from './date_time_picker_input.vue'; -import { - defaultTimeRanges, - defaultTimeRange, - isValidInputString, - inputStringToIsoDate, - isoDateToInputString, -} from './date_time_picker_lib'; - -const events = { - input: 'input', - invalid: 'invalid', -}; - -export default { - components: { - GlIcon, - GlButton, - GlDropdown, - GlDropdownItem, - GlFormGroup, - TooltipOnTruncate, - DateTimePickerInput, - }, - props: { - value: { - type: Object, - required: false, - default: () => defaultTimeRange, - }, - options: { - type: Array, - required: false, - default: () => defaultTimeRanges, - }, - customEnabled: { - type: Boolean, - required: false, - default: true, - }, - utc: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - timeRange: this.value, - - /** - * Valid start iso date string, null if not valid value - */ - startDate: null, - /** - * Invalid start date string as input by the user - */ - startFallbackVal: '', - - /** - * Valid end iso date string, null if not valid value - */ - endDate: null, - /** - * Invalid end date string as input by the user - */ - endFallbackVal: '', - }; - }, - computed: { - startInputValid() { - return isValidInputString(this.startDate); - }, - endInputValid() { - return isValidInputString(this.endDate); - }, - isValid() { - return this.startInputValid && this.endInputValid; - }, - - startInput: { - get() { - return this.dateToInput(this.startDate) || this.startFallbackVal; - }, - set(val) { - try { - this.startDate = this.inputToDate(val); - this.startFallbackVal = null; - } catch (e) { - this.startDate = null; - this.startFallbackVal = val; - } - this.timeRange = null; - }, - }, - endInput: { - get() { - return this.dateToInput(this.endDate) || this.endFallbackVal; - }, - set(val) { - try { - this.endDate = this.inputToDate(val); - this.endFallbackVal = null; - } catch (e) { - this.endDate = null; - this.endFallbackVal = val; - } - this.timeRange = null; - }, - }, - - timeWindowText() { - try { - const timeRange = findTimeRange(this.value, this.options); - if (timeRange) { - return timeRange.label; - } - - const { start, end } = convertToFixedRange(this.value); - if (isValidInputString(start) && isValidInputString(end)) { - return sprintf(__('%{start} to %{end}'), { - start: this.stripZerosInDateTime(this.dateToInput(start)), - end: this.stripZerosInDateTime(this.dateToInput(end)), - }); - } - } catch { - return __('Invalid date range'); - } - return ''; - }, - - customLabel() { - if (this.utc) { - return __('Custom range (UTC)'); - } - return __('Custom range'); - }, - }, - watch: { - value(newValue) { - const { start, end } = convertToFixedRange(newValue); - this.timeRange = this.value; - this.startDate = start; - this.endDate = end; - }, - }, - mounted() { - try { - const { start, end } = convertToFixedRange(this.timeRange); - this.startDate = start; - this.endDate = end; - } catch { - // when dates cannot be parsed, emit error. - this.$emit(events.invalid); - } - - // Validate on mounted, and trigger an update if needed - if (!this.isValid) { - this.$emit(events.invalid); - } - }, - methods: { - dateToInput(date) { - if (date === null) { - return null; - } - return isoDateToInputString(date, this.utc); - }, - inputToDate(value) { - return inputStringToIsoDate(value, this.utc); - }, - stripZerosInDateTime(str = '') { - return str.replace(' 00:00:00', ''); - }, - closeDropdown() { - this.$refs.dropdown.hide(); - }, - isOptionActive(option) { - return isEqualTimeRanges(option, this.timeRange); - }, - setQuickRange(option) { - this.timeRange = option; - this.$emit(events.input, this.timeRange); - }, - setFixedRange() { - this.timeRange = convertToFixedRange({ - start: this.startDate, - end: this.endDate, - }); - this.$emit(events.input, this.timeRange); - }, - }, -}; -</script> -<template> - <tooltip-on-truncate - :title="timeWindowText" - :truncate-target="(elem) => elem.querySelector('.gl-dropdown-toggle-text')" - placement="top" - class="d-inline-block" - > - <gl-dropdown - ref="dropdown" - :text="timeWindowText" - v-bind="$attrs" - class="date-time-picker w-100" - menu-class="date-time-picker-menu" - toggle-class="date-time-picker-toggle text-truncate" - > - <template #button-content> - <span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span> - <span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{ - __('UTC') - }}</span> - <gl-icon class="gl-dropdown-caret" name="chevron-down" /> - </template> - - <div class="d-flex justify-content-between gl-p-2"> - <gl-form-group - v-if="customEnabled" - :label="customLabel" - label-for="custom-from-time" - label-class="gl-pb-2" - class="custom-time-range-form-group col-md-7 gl-pl-2 gl-pr-0 m-0" - > - <div class="gl-pt-3"> - <date-time-picker-input - id="custom-time-from" - v-model="startInput" - :label="__('From')" - :state="startInputValid" - /> - <date-time-picker-input - id="custom-time-to" - v-model="endInput" - :label="__('To')" - :state="endInputValid" - /> - </div> - <gl-form-group> - <gl-button data-testid="cancelButton" @click="closeDropdown">{{ - __('Cancel') - }}</gl-button> - <gl-button - variant="confirm" - category="primary" - :disabled="!isValid" - @click="setFixedRange()" - > - {{ __('Apply') }} - </gl-button> - </gl-form-group> - </gl-form-group> - <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-px-2 m-0"> - <template #label> - <span class="gl-pl-7">{{ __('Quick range') }}</span> - </template> - - <gl-dropdown-item - v-for="(option, index) in options" - :key="index" - :active="isOptionActive(option)" - active-class="active" - @click="setQuickRange(option)" - > - <gl-icon - name="mobile-issue-close" - class="align-bottom" - :class="{ invisible: !isOptionActive(option) }" - /> - {{ option.label }} - </gl-dropdown-item> - </gl-form-group> - </div> - </gl-dropdown> - </tooltip-on-truncate> -</template> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue deleted file mode 100644 index 190d4e1f104..00000000000 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue +++ /dev/null @@ -1,77 +0,0 @@ -<script> -import { GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { __, sprintf } from '~/locale'; -import { dateFormats } from './date_time_picker_lib'; - -const inputGroupText = { - invalidFeedback: sprintf(__('Format: %{dateFormat}'), { - dateFormat: dateFormats.inputFormat, - }), - placeholder: dateFormats.inputFormat, -}; - -export default { - components: { - GlFormGroup, - GlFormInput, - }, - props: { - state: { - default: null, - required: true, - validator: (prop) => typeof prop === 'boolean' || prop === null, - }, - value: { - default: null, - required: false, - validator: (prop) => typeof prop === 'string' || prop === null, - }, - label: { - type: String, - default: '', - required: true, - }, - id: { - type: String, - required: false, - default: () => uniqueId('dateTimePicker_'), - }, - }, - data() { - return { - inputGroupText, - }; - }, - computed: { - invalidFeedback() { - return this.state ? '' : this.inputGroupText.invalidFeedback; - }, - inputState() { - // When the state is valid we want to show no - // green outline. Hence passing null and not true. - if (this.state === true) { - return null; - } - return this.state; - }, - }, - methods: { - onInputBlur(e) { - this.$emit('input', e.target.value.trim() || null); - }, - }, -}; -</script> - -<template> - <gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback"> - <gl-form-input - :id="id" - :value="value" - :state="inputState" - :placeholder="inputGroupText.placeholder" - @blur="onInputBlur" - /> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js deleted file mode 100644 index 38b1a587b34..00000000000 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ /dev/null @@ -1,91 +0,0 @@ -import dateformat from '~/lib/dateformat'; -import { __ } from '~/locale'; - -/** - * Default time ranges for the date picker. - * @see app/assets/javascripts/lib/utils/datetime_range.js - */ -export const defaultTimeRanges = [ - { - duration: { seconds: 60 * 30 }, - label: __('30 minutes'), - }, - { - duration: { seconds: 60 * 60 * 3 }, - label: __('3 hours'), - }, - { - duration: { seconds: 60 * 60 * 8 }, - label: __('8 hours'), - default: true, - }, - { - duration: { seconds: 60 * 60 * 24 * 1 }, - label: __('1 day'), - }, -]; - -export const defaultTimeRange = defaultTimeRanges.find((tr) => tr.default); - -export const dateFormats = { - /** - * Format used by users to input dates - * - * Note: Should be a format that can be parsed by Date.parse. - */ - inputFormat: 'yyyy-mm-dd HH:MM:ss', - /** - * Format used to strip timezone from inputs - */ - stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'", -}; - -/** - * Returns true if the date can be parsed succesfully after - * being typed by a user. - * - * It allows some ambiguity so validation is not strict. - * - * @param {string} value - Value as typed by the user - * @returns true if the value can be parsed as a valid date, false otherwise - */ -export const isValidInputString = (value) => { - try { - // dateformat throws error that can be caught. - // This is better than using `new Date()` - if (value && value.trim()) { - dateformat(value, 'isoDateTime'); - return true; - } - return false; - } catch (e) { - return false; - } -}; - -/** - * Convert the input in time picker component to an ISO date. - * - * @param {string} value - * @param {Boolean} utc - If true, it forces the date to by - * formatted using UTC format, ignoring the local time. - * @returns {Date} - */ -export const inputStringToIsoDate = (value, utc = false) => { - let date = new Date(value); - if (utc) { - // Forces date to be interpreted as UTC by stripping the timezone - // by formatting to a string with 'Z' and skipping timezone - date = dateformat(date, dateFormats.stripTimezoneFormat); - } - return dateformat(date, 'isoUtcDateTime'); -}; - -/** - * Converts a iso date string to a formatted string for the Time picker component. - * - * @param {String} ISO Formatted date - * @returns {string} - */ -export const isoDateToInputString = (date, utc = false) => - dateformat(date, dateFormats.inputFormat, utc); diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index 7080e046b30..535f1c5f645 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -65,7 +65,8 @@ export default { viewer() { if (this.diffViewerMode === diffViewerModes.renamed) { return RenamedFile; - } else if (this.diffMode === diffModes.mode_changed) { + } + if (this.diffMode === diffModes.mode_changed) { return ModeChanged; } diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue deleted file mode 100644 index 53210cbcc93..00000000000 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue +++ /dev/null @@ -1,3 +0,0 @@ -<template> - <div class="nothing-here-block">{{ __('Empty file') }}</div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index 3bb168e9051..b34a6b11092 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -64,6 +64,11 @@ export default { required: false, default: undefined, }, + noOptionsText: { + type: String, + required: false, + default: __('No options found'), + }, }, computed: { isSearchEmpty() { @@ -72,6 +77,9 @@ export default { noOptionsFound() { return !this.isSearchEmpty && this.options.length === 0; }, + noOptions() { + return this.isSearchEmpty && this.options.length === 0; + }, }, methods: { selectOption(option) { @@ -177,6 +185,9 @@ export default { <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> {{ $options.i18n.noMatchingResults }} </gl-dropdown-item> + <gl-dropdown-item v-if="noOptions"> + {{ noOptionsText }} + </gl-dropdown-item> </template> </gl-dropdown-form> </slot> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue index 71e3bf4ff63..eb7b20fa4c1 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -19,6 +19,11 @@ export default { EntitySelect, }, props: { + apiParams: { + type: Object, + required: false, + default: () => ({}), + }, label: { type: String, required: false, @@ -48,7 +53,7 @@ export default { default: null, }, groupsFilter: { - type: String, + type: String, // Two supported values: `descendant_groups` and `subgroups` See app/assets/javascripts/vue_shared/components/entity_select/utils.js. required: false, default: null, }, @@ -62,17 +67,15 @@ export default { async fetchGroups(searchString = '', page = 1) { let groups = []; let totalPages = 0; + const params = { + search: searchString, + per_page: DEFAULT_PER_PAGE, + page, + ...this.apiParams, + }; try { - const { data = [], headers } = await axios.get( - Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), - { - params: { - search: searchString, - per_page: DEFAULT_PER_PAGE, - page, - }, - }, - ); + const url = groupsPath(this.groupsFilter, this.parentGroupID); + const { data = [], headers } = await axios.get(url, { params }); groups = data.map((group) => ({ ...group, text: group.full_name, diff --git a/app/assets/javascripts/vue_shared/components/entity_select/utils.js b/app/assets/javascripts/vue_shared/components/entity_select/utils.js index 0a4622269f4..857a3ab4c74 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/utils.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/utils.js @@ -1,15 +1,26 @@ import Api from '~/api'; +/** + * @param {'descendant_groups'|'subgroups'|null} [groupsFilter] - type of group filtering + * @param {string|null} [parentGroupID] - parent group is needed for 'descendant_groups' and 'subgroups' filtering. + */ export const groupsPath = (groupsFilter, parentGroupID) => { - if (groupsFilter !== undefined && parentGroupID === undefined) { + if (groupsFilter && !parentGroupID) { throw new Error('Cannot use groupsFilter without a parentGroupID'); } + + let url = ''; switch (groupsFilter) { case 'descendant_groups': - return Api.descendantGroupsPath.replace(':id', parentGroupID); + url = Api.descendantGroupsPath.replace(':id', parentGroupID); + break; case 'subgroups': - return Api.subgroupsPath.replace(':id', parentGroupID); + url = Api.subgroupsPath.replace(':id', parentGroupID); + break; default: - return Api.groupsPath; + url = Api.groupsPath; + break; } + + return Api.buildUrl(url); }; diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index db0b0ea185b..226f44a1541 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -145,7 +145,8 @@ export default { el.classList.contains('inputarea') ) { return true; - } else if (combo === 'mod+p') { + } + if (combo === 'mod+p') { return false; } diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 721f87ff4d6..cecd1be82e9 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -141,7 +141,6 @@ export default { ref="textOutput" class="file-row-name" :title="file.name" - data-qa-selector="file_name_content" :data-qa-file-name="file.name" data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" 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 2b3d1b2c1f5..c698b94749d 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 @@ -73,6 +73,7 @@ export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_REVIEWER = s__('SearchToken|Reviewer'); export const TOKEN_TITLE_SOURCE_BRANCH = __('Source Branch'); export const TOKEN_TITLE_STATUS = __('Status'); +export const TOKEN_TITLE_JOBS_RUNNER_TYPE = s__('Job|Runner type'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); @@ -100,6 +101,7 @@ export const TOKEN_TYPE_RELEASE = 'release'; export const TOKEN_TYPE_REVIEWER = 'reviewer'; export const TOKEN_TYPE_SOURCE_BRANCH = 'source-branch'; export const TOKEN_TYPE_STATUS = 'status'; +export const TOKEN_TYPE_JOBS_RUNNER_TYPE = 'jobs-runner-type'; export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index f31d4d53a23..346384e3023 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -73,7 +73,8 @@ export default { }, searchInputPlaceholder: { type: String, - required: true, + required: false, + default: __('Search or filter results…'), }, suggestionsListClass: { type: String, diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 8322fe92de4..77108ad3628 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -2,6 +2,8 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; +import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; +import searchMilestonesQuery from '~/issues/list/queries/search_milestones.query.graphql'; import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { stripQuotes } from '~/lib/utils/text_utility'; @@ -36,6 +38,14 @@ export default { defaultMilestones() { return this.config.defaultMilestones || DEFAULT_MILESTONES; }, + namespace() { + return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP; + }, + fetchMilestonesQuery() { + return this.config.fetchMilestones + ? this.config.fetchMilestones + : this.fetchMilestonesBySearchTerm; + }, }, methods: { getActiveMilestone(milestones, data) { @@ -51,10 +61,17 @@ export default { ) || this.defaultMilestones.find(({ value }) => value === data) ); }, + fetchMilestonesBySearchTerm(search) { + return this.$apollo + .query({ + query: searchMilestonesQuery, + variables: { fullPath: this.config.fullPath, search, isProject: this.config.isProject }, + }) + .then(({ data }) => data[this.namespace]?.milestones.nodes); + }, fetchMilestones(searchTerm) { this.loading = true; - this.config - .fetchMilestones(searchTerm) + this.fetchMilestonesQuery(searchTerm) .then((response) => { const data = Array.isArray(response) ? response : response.data; 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 7da45169fee..a375a167c68 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 @@ -24,6 +24,7 @@ export default { :key="group.id" :group="group" :show-group-icon="showGroupIcon" + @delete="$emit('delete', $event)" /> </ul> </template> 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 8a301cd0dd0..ca1e7400f2d 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,5 +1,6 @@ <script> import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } 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'; @@ -7,6 +8,9 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { __ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; +import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; +import DangerConfirmModal from '~/vue_shared/components/confirm_danger/confirm_danger_modal.vue'; export default { i18n: { @@ -25,6 +29,8 @@ export default { GlIcon, UserAccessRoleBadge, GlTruncateText, + ListActions, + DangerConfirmModal, }, directives: { GlTooltip: GlTooltipDirective, @@ -41,6 +47,12 @@ export default { default: false, }, }, + data() { + return { + isDeleteModalVisible: false, + modalId: uniqueId('groups-list-item-modal-id-'), + }; + }, computed: { visibility() { return this.group.visibility; @@ -75,94 +87,131 @@ export default { groupMembersCount() { return numberToMetricPrefix(this.group.groupMembersCount); }, + actions() { + return { + [ACTION_EDIT]: { + href: this.group.editPath, + }, + [ACTION_DELETE]: { + action: this.onActionDelete, + }, + }; + }, + hasActions() { + return this.group.availableActions?.length; + }, + hasActionDelete() { + return this.group.availableActions?.includes(ACTION_DELETE); + }, + }, + methods: { + onActionDelete() { + this.isDeleteModalVisible = true; + }, }, }; </script> <template> - <li class="groups-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b"> - <div class="gl-display-flex gl-flex-grow-1"> - <gl-icon - v-if="showGroupIcon" - class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" - :name="groupIconName" - /> - <gl-avatar-labeled - :entity-id="group.id" - :entity-name="group.fullName" - :label="group.fullName" - :label-link="group.webUrl" - shape="rect" - :size="$options.avatarSize" - > - <template #meta> - <div class="gl-px-2"> - <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap"> - <div class="gl-px-2"> - <gl-icon - v-if="visibility" - v-gl-tooltip="visibilityTooltip" - :name="visibilityIcon" - class="gl-text-secondary" - /> - </div> - <div class="gl-px-2"> - <user-access-role-badge v-if="shouldShowAccessLevel">{{ - accessLevelLabel - }}</user-access-role-badge> + <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"> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-icon + v-if="showGroupIcon" + class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" + :name="groupIconName" + /> + <gl-avatar-labeled + :entity-id="group.id" + :entity-name="group.fullName" + :label="group.fullName" + :label-link="group.webUrl" + shape="rect" + :size="$options.avatarSize" + > + <template #meta> + <div class="gl-px-2"> + <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap"> + <div class="gl-px-2"> + <gl-icon + v-if="visibility" + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary" + /> + </div> + <div class="gl-px-2"> + <user-access-role-badge v-if="shouldShowAccessLevel">{{ + accessLevelLabel + }}</user-access-role-badge> + </div> </div> </div> + </template> + <gl-truncate-text + v-if="group.descriptionHtml" + :lines="2" + :mobile-lines="2" + :show-more-text="$options.i18n.showMore" + :show-less-text="$options.i18n.showLess" + class="gl-mt-2" + > + <div + v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml" + class="gl-font-sm 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="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> - </template> - <gl-truncate-text - v-if="group.descriptionHtml" - :lines="2" - :mobile-lines="2" - :show-more-text="$options.i18n.showMore" - :show-less-text="$options.i18n.showLess" - class="gl-mt-2" - > <div - v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml" - class="gl-font-sm 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="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> + 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> </div> + <list-actions + v-if="hasActions" + class="gl-ml-3 gl-md-align-self-center" + :actions="actions" + :available-actions="group.availableActions" + /> + + <danger-confirm-modal + v-if="hasActionDelete" + v-model="isDeleteModalVisible" + :modal-id="modalId" + :phrase="group.fullName" + @confirm="$emit('delete', group)" + /> </li> </template> diff --git a/app/assets/javascripts/vue_shared/components/incidents/utils.js b/app/assets/javascripts/vue_shared/components/incidents/utils.js deleted file mode 100644 index bcb578a6ba6..00000000000 --- a/app/assets/javascripts/vue_shared/components/incidents/utils.js +++ /dev/null @@ -1,3 +0,0 @@ -import { noop } from 'lodash'; - -export const isValidSlaDueAt = noop; diff --git a/app/assets/javascripts/vue_shared/components/list_actions/constants.js b/app/assets/javascripts/vue_shared/components/list_actions/constants.js new file mode 100644 index 00000000000..b1506ae1e93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_actions/constants.js @@ -0,0 +1,16 @@ +import { __ } from '~/locale'; + +export const ACTION_EDIT = 'edit'; +export const ACTION_DELETE = 'delete'; + +export const BASE_ACTIONS = { + [ACTION_EDIT]: { + text: __('Edit'), + }, + [ACTION_DELETE]: { + text: __('Delete'), + extraAttrs: { + class: 'gl-text-red-500!', + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/list_actions/list_actions.stories.js b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.stories.js new file mode 100644 index 00000000000..d34729c2373 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.stories.js @@ -0,0 +1,44 @@ +import { makeContainer } from 'storybook_addons/make_container'; +import ListActions from './list_actions.vue'; +import { ACTION_DELETE, ACTION_EDIT } from './constants'; + +export default { + component: ListActions, + title: 'vue_shared/list_actions', + decorators: [makeContainer({ height: '115px' })], + parameters: { + docs: { + description: { + component: ` +This component renders actions used by lists of resources such as groups and projects. +Currently it is used by \`ProjectsListItem\`. There are base actions defined in \`~/vue_shared/components/list_actions\` +that help reduce the amount of boilerplate needed for common actions such as edit and delete. This component accepts an +\`actions\` prop that can extend the base actions and/or add custom actions. These actions should follow the format of +a [disclosure dropdown item](https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/base-new-dropdowns-disclosure--docs#setting-disclosure-dropdown-items). +The \`availableActions\` prop defines what actions to render and in what order. This prop will generally be set by checking +permissions of the current user. +`, + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + components: { ListActions }, + props: Object.keys(argTypes), + template: '<list-actions v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + actions: { + [ACTION_EDIT]: { + href: '/?path=/story/vue-shared-list-actions--default', + }, + [ACTION_DELETE]: { + // eslint-disable-next-line no-console + action: () => console.log('Deleted'), + }, + }, + availableActions: [ACTION_EDIT, ACTION_DELETE], +}; diff --git a/app/assets/javascripts/vue_shared/components/list_actions/list_actions.vue b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.vue new file mode 100644 index 00000000000..7b78cc1da8f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.vue @@ -0,0 +1,52 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { BASE_ACTIONS } from './constants'; + +export default { + name: 'ListActions', + i18n: { + actions: __('Actions'), + }, + components: { + GlDisclosureDropdown, + }, + props: { + // Can extend `BASE_ACTIONS` and/or add new actions. + // Expected format: https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/base-new-dropdowns-disclosure--docs#setting-disclosure-dropdown-items + actions: { + type: Object, + required: true, + }, + availableActions: { + type: Array, + required: true, + }, + }, + computed: { + items() { + return this.availableActions.reduce((accumulator, action) => { + return [ + ...accumulator, + { + ...BASE_ACTIONS[action], + ...this.actions[action], + }, + ]; + }, []); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + :items="items" + icon="ellipsis_v" + no-caret + :toggle-text="$options.i18n.actions" + text-sr-only + placement="right" + category="tertiary" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index a570abae9d3..05ce007e615 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -1,9 +1,9 @@ <script> -import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; import { __, n__ } from '~/locale'; export default { - components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert }, + components: { GlDisclosureDropdown, GlForm, GlFormTextarea, GlButton, GlAlert }, props: { disabled: { type: Boolean, @@ -39,43 +39,58 @@ export default { return n__('Apply %d suggestion', 'Apply %d suggestions', this.batchSuggestionsCount); }, + helperText() { + if (this.batchSuggestionsCount <= 1) { + return __('This also resolves this thread'); + } + + return __('This also resolves all related threads'); + }, }, methods: { onApply() { this.$emit('apply', this.message); }, + focusCommitMessageInput() { + this.$refs.commitMessage.$el.focus(); + }, }, }; </script> <template> - <gl-dropdown - :text="dropdownText" - :disabled="disabled" - size="small" - boundary="window" - right - lazy - menu-class="gl-w-full!" + <gl-disclosure-dropdown data-qa-selector="apply_suggestion_dropdown" - @shown="$refs.commitMessage.$el.focus()" + fluid-width + placement="right" + size="small" + :disabled="disabled" + :toggle-text="dropdownText" + @shown="focusCommitMessageInput" > - <gl-dropdown-form class="gl-px-4! gl-m-0!"> + <gl-form class="gl-display-flex gl-flex-direction-column gl-px-4! gl-mx-0! gl-my-2!"> <label for="commit-message">{{ __('Commit message') }}</label> <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-4"> {{ errorMessage }} </gl-alert> + <gl-form-textarea id="commit-message" ref="commitMessage" v-model="message" + class="apply-suggestions-input-min-width" :placeholder="defaultCommitMessage" submit-on-enter data-qa-selector="commit_message_field" @submit="onApply" /> + + <span class="gl-mt-2 gl-text-secondary"> + {{ helperText }} + </span> + <gl-button - class="gl-w-auto! gl-mt-3 gl-text-center! gl-transition-medium! float-right" + class="gl-w-auto! gl-mt-3 gl-align-self-end" category="primary" variant="confirm" data-qa-selector="commit_with_custom_message_button" @@ -83,6 +98,6 @@ export default { > {{ __('Apply') }} </gl-button> - </gl-dropdown-form> - </gl-dropdown> + </gl-form> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index b1c6f5e6056..f7f5ccdbf31 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -4,7 +4,11 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; import { InternalEvents } from '~/tracking'; import savedRepliesQuery from './saved_replies.query.graphql'; -import { TRACKING_SAVED_REPLIES_USE, TRACKING_SAVED_REPLIES_USE_IN_MR } from './constants'; +import { + TRACKING_SAVED_REPLIES_USE, + TRACKING_SAVED_REPLIES_USE_IN_MR, + TRACKING_SAVED_REPLIES_USE_IN_OTHER, +} from './constants'; export default { apollo: { @@ -61,9 +65,9 @@ export default { if (savedReply) { this.$emit('select', savedReply.content); this.track_event(TRACKING_SAVED_REPLIES_USE); - if (isInMr) { - this.track_event(TRACKING_SAVED_REPLIES_USE_IN_MR); - } + this.track_event( + isInMr ? TRACKING_SAVED_REPLIES_USE_IN_MR : TRACKING_SAVED_REPLIES_USE_IN_OTHER, + ); } }, }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/constants.js b/app/assets/javascripts/vue_shared/components/markdown/constants.js index 47ef7cccbc2..7b31c4a59e3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/constants.js +++ b/app/assets/javascripts/vue_shared/components/markdown/constants.js @@ -1,2 +1,3 @@ export const TRACKING_SAVED_REPLIES_USE = 'i_code_review_saved_replies_use'; export const TRACKING_SAVED_REPLIES_USE_IN_MR = 'i_code_review_saved_replies_use_in_mr'; +export const TRACKING_SAVED_REPLIES_USE_IN_OTHER = 'i_code_review_saved_replies_use_in_other'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue index 84d40db07bb..c70197c6715 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue @@ -2,8 +2,26 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { + props: { + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + watch: { + isLoading() { + this.handleGFM(); + }, + }, mounted() { - renderGFM(this.$el); + this.handleGFM(); + }, + methods: { + handleGFM() { + if (this.isLoading) return; + renderGFM(this.$el); + }, }, }; </script> 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 493b329f1b1..fc7e0a7c732 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -119,9 +119,11 @@ export default { }, }, data() { + const editingMode = + localStorage.getItem(this.$options.EDITING_MODE_KEY) || EDITING_MODE_MARKDOWN_FIELD; return { markdown: this.value || (this.autosaveKey ? getDraft(this.autosaveKey) : '') || '', - editingMode: EDITING_MODE_MARKDOWN_FIELD, + editingMode, autofocused: false, }; }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js index 0b0867ae84c..6c2f084591e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -72,7 +72,7 @@ export function mountMarkdownEditor(options = {}) { quickActionsDocsPath, formFieldPlaceholder, formFieldClasses, - qaSelector, + testid, newIssuePath, } = el.dataset; @@ -115,7 +115,7 @@ export function mountMarkdownEditor(options = {}) { id: formFieldId, name: formFieldName, class: formFieldClasses, - 'data-qa-selector': qaSelector, + 'data-testid': testid, }, autosaveKey, enableAutocomplete, diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 855c7a449c4..8a0ca8ebac1 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -77,9 +77,7 @@ export default { return this.inapplicableReason; } - return this.batchSuggestionsCount > 1 - ? __('This also resolves all related threads') - : __('This also resolves this thread'); + return false; }, isDisableButton() { return this.isApplying || !this.canApply; diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index 9179331cdec..0ec8b6e2a0a 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -6,6 +6,9 @@ const noteableTypeText = { Issue: __('issue'), Epic: __('epic'), MergeRequest: __('merge request'), + Task: __('task'), + KeyResult: __('key result'), + Objective: __('objective'), }; export default { diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js index df1188d365b..77fd197978f 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js @@ -1,5 +1,3 @@ -import { __ } from '~/locale'; - export const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; export const thClass = 'gl-hover-bg-blue-50'; @@ -15,7 +13,3 @@ export const initialPaginationState = { firstPageSize: defaultPageSize, lastPageSize: null, }; - -export const defaultI18n = { - searchPlaceholder: __('Search or filter results…'), -}; diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index ab9e6e092d9..0c3d175684c 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -14,11 +14,10 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; -import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; +import { initialPaginationState, defaultPageSize } from './constants'; import { isAny } from './utils'; export default { - defaultI18n, components: { GlAlert, GlBadge, @@ -300,7 +299,6 @@ export default { <div class="filtered-search-wrapper"> <filtered-search-bar :namespace="projectPath" - :search-input-placeholder="$options.defaultI18n.searchPlaceholder" :tokens="filteredSearchTokens" :initial-filter-value="filteredSearchValue" initial-sortby="created_desc" diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index e1f042f78ab..76bedc0feeb 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -64,7 +64,7 @@ export default { <template> <gl-pagination v-if="showPagination" - class="gl-mt-3" + class="gl-mt-5" v-bind="$attrs" align="center" :value="pageInfo.page" diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue index c1246b2bf44..4f580d4a848 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlIcon, + GlSprintf, +} from '@gitlab/ui'; import { __ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -9,8 +15,9 @@ const DEFAULT_PAGE_SIZES = [20, 50, 100]; export default { components: { PaginationLinks, - GlDropdown, - GlDropdownItem, + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlIcon, GlSprintf, LocalStorageSync, @@ -80,25 +87,31 @@ export default { @input="setPageSize" /> <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> - <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> - <template #button-content> - <span class="gl-font-weight-bold"> + <gl-disclosure-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> + <template #toggle> + <gl-button class="gl-font-weight-bold" category="tertiary"> <gl-sprintf :message="__('%{count} items per page')"> <template #count> {{ pageInfo.perPage }} </template> </gl-sprintf> - </span> - <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + </gl-button> </template> - <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)"> - <gl-sprintf :message="__('%{count} items per page')"> - <template #count> - {{ size }} - </template> - </gl-sprintf> - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-item + v-for="size in pageSizes" + :key="size" + @action="setPageSize(size)" + > + <template #list-item> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ size }} + </template> + </gl-sprintf> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> <div class="gl-ml-2" data-testid="information"> <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> <template #start> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/constants.js b/app/assets/javascripts/vue_shared/components/projects_list/constants.js deleted file mode 100644 index aa0b1418a06..00000000000 --- a/app/assets/javascripts/vue_shared/components/projects_list/constants.js +++ /dev/null @@ -1,2 +0,0 @@ -export const ACTION_EDIT = 'edit'; -export const ACTION_DELETE = 'delete'; 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 9fc4571b0dc..ce75e305473 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,7 +7,6 @@ import { GlTooltipDirective, GlPopover, GlSprintf, - GlDisclosureDropdown, } from '@gitlab/ui'; import uniqueId from 'lodash/uniqueId'; @@ -20,8 +19,9 @@ import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import { truncate } from '~/lib/utils/text_utility'; import SafeHtml from '~/vue_shared/directives/safe_html'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; import DeleteModal from '~/projects/components/shared/delete_modal.vue'; -import { ACTION_EDIT, ACTION_DELETE } from './constants'; const MAX_TOPICS_TO_SHOW = 3; const MAX_TOPIC_TITLE_LENGTH = 15; @@ -51,8 +51,8 @@ export default { GlPopover, GlSprintf, TimeAgoTooltip, - GlDisclosureDropdown, DeleteModal, + ListActions, }, directives: { GlTooltip: GlTooltipDirective, @@ -163,30 +163,21 @@ export default { return numberToMetricPrefix(this.project.openIssuesCount); }, - actionsDropdownItems() { - return [ - { - id: ACTION_EDIT, - text: __('Edit'), + actions() { + return { + [ACTION_EDIT]: { href: this.project.editPath, }, - { - id: ACTION_DELETE, - text: __('Delete'), - extraAttrs: { - class: 'gl-text-red-500!', - }, - action: () => { - this.isDeleteModalVisible = true; - }, + [ACTION_DELETE]: { + action: this.onActionDelete, }, - ].filter(({ id }) => this.project.actions?.includes(id)); + }; }, hasActions() { - return this.actionsDropdownItems.length; + return this.project.availableActions?.length; }, - hasDeleteAction() { - return this.actionsDropdownItems.find((action) => action.id === ACTION_DELETE); + hasActionDelete() { + return this.project.availableActions?.includes(ACTION_DELETE); }, }, methods: { @@ -204,6 +195,9 @@ export default { return null; }, + onActionDelete() { + this.isDeleteModalVisible = true; + }, }, }; </script> @@ -336,20 +330,15 @@ export default { </div> </div> </div> - <gl-disclosure-dropdown + <list-actions v-if="hasActions" class="gl-ml-3 gl-md-align-self-center" - :items="actionsDropdownItems" - icon="ellipsis_v" - no-caret - :toggle-text="$options.i18n.actions" - text-sr-only - placement="right" - category="tertiary" + :actions="actions" + :available-actions="project.availableActions" /> <delete-modal - v-if="hasDeleteAction" + v-if="hasActionDelete" v-model="isDeleteModalVisible" :confirm-phrase="project.name" :is-fork="project.isForked" diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index 7b7d3d48d9e..53c16fccba1 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -104,7 +104,7 @@ export default { :id="`source-editor-${fileGlobalId}`" ref="editor" data-editor-loading - data-qa-selector="source_editor_container" + data-testid="source-editor-container" @[$options.readyEvent]="$emit($options.readyEvent, $event)" > <pre class="editor-loading-content">{{ value }}</pre> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue index b89fa3f8292..8dac6327a99 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -82,6 +82,7 @@ export default { methods: { handleChunkAppear() { this.hasAppeared = true; + this.$emit('appear'); }, calculateLineNumber(index) { return this.startingFrom + index + 1; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index a4d50466f8f..797a38d8171 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -33,6 +33,7 @@ export default { components: { GlLoadingIcon, Chunk, + CodeownersValidation: () => import('ee_component/blob/components/codeowners_validation.vue'), }, mixins: [Tracking.mixin()], props: { @@ -40,6 +41,14 @@ export default { type: Object, required: true, }, + projectPath: { + type: String, + required: true, + }, + currentRef: { + type: String, + required: true, + }, }, data() { return { @@ -49,7 +58,6 @@ export default { firstChunk: null, chunks: {}, isLoading: true, - isLineSelected: false, lineHighlighter: null, }; }, @@ -66,7 +74,8 @@ export default { if (this.blob.name && this.blob.name.endsWith(`.${SVELTE_LANGUAGE}`)) { // override for svelte files until https://github.com/rouge-ruby/rouge/issues/1717 is resolved return SVELTE_LANGUAGE; - } else if (this.blob.name === this.$options.codeownersFileName) { + } + if (this.isCodeownersFile) { // override for codeowners files return this.$options.codeownersLanguage; } @@ -87,6 +96,9 @@ export default { totalChunks() { return Object.keys(this.chunks).length; }, + isCodeownersFile() { + return this.blob.name === CODEOWNERS_FILE_NAME; + }, }, async created() { if (this.isLfsBlob) { @@ -121,7 +133,7 @@ export default { this.generateRemainingChunks(); this.isLoading = false; await this.$nextTick(); - this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + this.selectLine(); }); }, methods: { @@ -227,18 +239,16 @@ export default { return languageDefinition; }, async selectLine() { - if (this.isLineSelected || !this.lineHighlighter) { - return; + if (!this.lineHighlighter) { + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); } - - this.isLineSelected = true; await this.$nextTick(); - this.lineHighlighter.highlightHash(this.$route.hash); + const scrollEnabled = false; + this.lineHighlighter.highlightHash(this.$route.hash, scrollEnabled); }, }, userColorScheme: window.gon.user_color_scheme, currentlySelectedLine: null, - codeownersFileName: CODEOWNERS_FILE_NAME, codeownersLanguage: CODEOWNERS_LANGUAGE, }; </script> @@ -250,6 +260,13 @@ export default { :data-path="blob.path" data-qa-selector="blob_viewer_file_content" > + <codeowners-validation + v-if="isCodeownersFile" + class="gl-text-black-normal" + :current-ref="currentRef" + :project-path="projectPath" + :file-path="blob.path" + /> <chunk v-if="firstChunk" :lines="firstChunk.lines" @@ -263,20 +280,21 @@ export default { /> <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> - <chunk - v-for="(chunk, key, index) in chunks" - v-else - :key="key" - :lines="chunk.lines" - :content="chunk.content" - :total-lines="chunk.totalLines" - :starting-from="chunk.startingFrom" - :is-highlighted="chunk.isHighlighted" - :chunk-index="index" - :language="chunk.language" - :blame-path="blob.blamePath" - :total-chunks="totalChunks" - @appear="highlightChunk" - /> + <template v-else> + <chunk + v-for="(chunk, key, index) in chunks" + :key="key" + :lines="chunk.lines" + :content="chunk.content" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" + :blame-path="blob.blamePath" + :total-chunks="totalChunks" + @appear="highlightChunk" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue index 0fb6e577f32..c7353ed6785 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 @@ -41,8 +41,14 @@ export default { addBlobLinksTracking(); }, mounted() { - const { hash } = this.$route; - this.lineHighlighter.highlightHash(hash); + this.selectLine(); + }, + methods: { + async selectLine() { + await this.$nextTick(); + const scrollEnabled = false; + this.lineHighlighter.highlightHash(this.$route.hash, scrollEnabled); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -66,6 +72,7 @@ export default { :total-lines="chunk.totalLines" :starting-from="chunk.startingFrom" :blame-path="blob.blamePath" + @appear="selectLine" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue deleted file mode 100644 index c0aef42b0f2..00000000000 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; -import { isString } from 'lodash'; - -const isValidItem = (item) => - isString(item.eventName) && isString(item.title) && isString(item.description); - -export default { - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - }, - - props: { - actionItems: { - type: Array, - required: true, - validator(value) { - return value.length > 1 && value.every(isValidItem); - }, - }, - menuClass: { - type: String, - required: false, - default: '', - }, - variant: { - type: String, - required: false, - default: 'default', - }, - }, - - data() { - return { - selectedItem: this.actionItems[0], - }; - }, - - computed: { - dropdownToggleText() { - return this.selectedItem.title; - }, - }, - - methods: { - triggerEvent() { - this.$emit(this.selectedItem.eventName); - }, - changeSelectedItem(item) { - this.selectedItem = item; - this.$emit('change', item); - }, - }, -}; -</script> - -<template> - <gl-dropdown - :menu-class="menuClass" - split - :text="dropdownToggleText" - :variant="variant" - v-bind="$attrs" - @click="triggerEvent" - > - <template v-for="(item, itemIndex) in actionItems"> - <gl-dropdown-item - :key="item.eventName" - is-check-item - :is-checked="selectedItem === item" - @click="changeSelectedItem(item)" - > - <strong>{{ item.title }}</strong> - <div>{{ item.description }}</div> - </gl-dropdown-item> - - <gl-dropdown-divider - v-if="itemIndex < actionItems.length - 1" - :key="`${item.eventName}-divider`" - /> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue index bda88a48e48..9ba5e8724f9 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue @@ -70,7 +70,8 @@ export default { selectTarget() { if (isFunction(this.truncateTarget)) { return this.truncateTarget(this.$el); - } else if (this.truncateTarget === 'child') { + } + if (this.truncateTarget === 'child') { return this.$el.childNodes[0]; } return this.$el; diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 446c8c97df0..30f616dd8e1 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -83,9 +83,11 @@ export default { if (this.user.status.emoji && this.user.status.message_html) { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; - } else if (this.user.status.message_html) { + } + if (this.user.status.message_html) { return this.user.status.message_html; - } else if (this.user.status.emoji) { + } + if (this.user.status.emoji) { return glEmojiTag(this.user.status.emoji); } 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 4879baced0d..863c43b0e55 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 @@ -13,7 +13,7 @@ import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { participantsQueries, userSearchQueries } from '~/sidebar/queries/constants'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -130,11 +130,11 @@ export default { }, update(data) { return ( - data.workspace?.users?.nodes - .filter((x) => x?.user) - .map((node) => ({ - ...node.user, - canMerge: node.mergeRequestInteraction?.canMerge || false, + data.workspace?.users + .filter((user) => user) + .map((user) => ({ + ...user, + canMerge: user.mergeRequestInteraction?.canMerge || false, })) || [] ); }, 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 79d14b5f2d0..beb8321a271 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -195,9 +195,11 @@ export default { webIdeActionText() { if (this.webIdeText) { return this.webIdeText; - } else if (this.isBlob) { + } + if (this.isBlob) { return __('Open in Web IDE'); - } else if (this.isFork) { + } + if (this.isFork) { return __('Edit fork in Web IDE'); } diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index d9bc2c82688..9c001fa2e9a 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -86,7 +86,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) => ), { workspaceType: workspaceType === WORKSPACE_PROJECT ? __('project') : __('group'), - issuableType: issuableType.toLowerCase(), + issuableType: issuableType.toLowerCase().replaceAll('_', ' '), permissions: issuableType === TYPE_ISSUE ? __('at least the Reporter role, the author, and assignees') diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index 699b41f3bf3..1cfa3f6d3d7 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -36,7 +36,7 @@ export default { ariaLabel: __('Description'), class: 'rspec-issuable-form-description', placeholder: __('Write a comment or drag your files here…'), - dataQaSelector: 'issuable_form_description_field', + dataTestid: 'issuable-form-description-field', id: 'issuable-description', name: 'issuable-description', }, 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 31dd49ca415..690d9523a63 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 @@ -8,6 +8,7 @@ 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 SafeHtml from '~/vue_shared/directives/safe_html'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { STATE_CLOSED } from '~/work_items/constants'; import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils'; @@ -24,6 +25,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml, }, mixins: [timeagoMixin], props: { @@ -80,14 +82,20 @@ export default { author() { return this.issuable.author || {}; }, + externalAuthor() { + return this.issuable.externalAuthor; + }, webUrl() { return this.issuable.gitlabWebUrl || this.issuable.webUrl; }, authorId() { return getIdFromGraphQLId(this.author.id); }, + isIssueTrackerExternal() { + return Boolean(this.issuable.externalTracker); + }, isIssuableUrlExternal() { - return isExternal(this.webUrl); + return isExternal(this.webUrl ?? ''); }, reference() { return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; @@ -130,7 +138,8 @@ export default { return sprintf(__('closed %{timeago}'), { timeago: this.timeFormatted(this.issuable.closedAt), }); - } else if (this.issuable.updatedAt !== this.issuable.createdAt) { + } + if (this.issuable.updatedAt !== this.issuable.createdAt) { return sprintf(__('updated %{timeAgo}'), { timeAgo: this.timeFormatted(this.issuable.updatedAt), }); @@ -242,6 +251,7 @@ export default { <div data-testid="issuable-title" class="issue-title title"> <work-item-type-icon v-if="showWorkItemTypeIcon" + class="gl-mr-2" :work-item-type="type" show-tooltip-on-hover /> @@ -259,18 +269,33 @@ export default { :title="__('This issue is hidden because its author has been banned')" :aria-label="__('Hidden')" /> - <gl-link - class="issue-title-text" - dir="auto" - :href="webUrl" - data-qa-selector="issuable_title_link" - data-testid="issuable-title-link" - v-bind="issuableTitleProps" - @click="handleIssuableItemClick" - > - {{ issuable.title }} + <template v-if="isIssueTrackerExternal"> + <gl-link + class="issue-title-text" + dir="auto" + :href="webUrl" + data-qa-selector="issuable_title_link" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click="handleIssuableItemClick" + > + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> + </template> + <template v-else> + <gl-link + v-safe-html="issuable.titleHtml || issuable.title" + class="issue-title-text" + dir="auto" + :href="webUrl" + data-qa-selector="issuable_title_link" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click="handleIssuableItemClick" + /> <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> - </gl-link> + </template> <span v-if="taskStatus" class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm" @@ -298,6 +323,9 @@ export default { </span> </template> <template #author> + <span v-if="externalAuthor" data-testid="external-author" + >{{ externalAuthor }} {{ __('via') }}</span + > <slot v-if="hasSlotContents('author')" name="author"></slot> <gl-link v-else @@ -344,7 +372,7 @@ export default { </div> <div class="issuable-meta"> <ul v-if="showIssuableMeta" class="controls"> - <li v-if="hasSlotContents('status')" class="issuable-status"> + <li v-if="hasSlotContents('status')"> <slot name="status"></slot> </li> <li v-if="assignees.length"> 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 7a9404e06c7..0db7417cebc 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 @@ -5,6 +5,7 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -51,7 +52,8 @@ export default { }, searchInputPlaceholder: { type: String, - required: true, + required: false, + default: __('Search or filter results…'), }, searchTokens: { type: Array, @@ -344,7 +346,7 @@ export default { :show-friendly-text="showFilteredSearchFriendlyText" terms-as-tokens class="gl-flex-grow-1 gl-border-t-none row-content-block" - data-qa-selector="issuable_search_container" + data-testid="issuable-search-container" @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" @@ -377,7 +379,7 @@ export default { v-for="issuable in issuables" :key="issuableId(issuable)" :class="{ 'gl-cursor-grab': isManualOrdering }" - data-qa-selector="issuable_container" + data-testid="issuable-container" :data-qa-issuable-title="issuable.title" :has-scoped-labels-feature="hasScopedLabelsFeature" :issuable-symbol="issuableSymbol" diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue index 0691bc02b5c..ab71842ae13 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -56,7 +56,7 @@ export default { @click="$emit('click', tab.name)" > <template #title> - <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`"> + <span :title="tab.titleTooltip" :data-testid="`${tab.name}-issuables-tab`"> {{ tab.title }} </span> <gl-badge diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index ce1851ab873..01389cd90a9 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -34,7 +34,7 @@ export default { <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }" - data-qa-selector="description_content" + data-testid="description-content" > <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> <textarea diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index 29aef89a991..c4b92454ac0 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -162,8 +162,8 @@ export default { <template> <div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row"> - <div class="detail-page-header-body gl-flex-wrap"> - <gl-badge class="gl-mr-2" :variant="badgeVariant"> + <div class="detail-page-header-body gl-flex-wrap gl-gap-2"> + <gl-badge :variant="badgeVariant" data-testid="issue-state-badge"> <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> <span class="gl-display-none gl-sm-display-block" :class="{ 'gl-ml-2': statusIcon }"> <slot name="status-badge">{{ badgeText }}</slot> @@ -193,18 +193,18 @@ export default { <work-item-type-icon v-if="shouldShowWorkItemTypeIcon" show-text - :work-item-type="issuableType.toUpperCase()" + :work-item-type="issuableType" /> <gl-sprintf :message="createdMessage"> <template #timeAgo> - <time-ago-tooltip class="gl-mx-2" :time="createdAt" /> + <time-ago-tooltip :time="createdAt" /> </template> <template #email> {{ serviceDeskReplyTo }} </template> <template #author> <gl-link - class="gl-font-weight-bold gl-mx-2 js-user-link" + class="gl-font-weight-bold js-user-link" :href="author.webUrl" :data-user-id="authorId" > @@ -225,7 +225,6 @@ export default { <gl-icon v-if="isFirstContribution" v-gl-tooltip - class="gl-mr-2" name="first-contribution" :title="__('1st contribution!')" :aria-label="__('1st contribution!')" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 2bc57ecba55..3878c16c8d0 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -92,6 +92,11 @@ export default { required: false, default: false, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, methods: { handleKeydownTitle(e, issuableMeta) { @@ -105,7 +110,7 @@ export default { </script> <template> - <div class="issuable-show-container" data-qa-selector="issuable_show_container"> + <div class="issuable-show-container" data-testid="issuable-show-container"> <issuable-header :issuable-state="issuable.state" :status-icon="statusIcon" @@ -116,6 +121,7 @@ export default { :author="issuable.author" :task-completion-status="taskCompletionStatus" :issuable-type="issuable.type" + :workspace-type="workspaceType" :show-work-item-type-icon="showWorkItemTypeIcon" > <template #status-badge> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 841d92fd63d..da71adc8abd 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -60,8 +60,7 @@ export default { v-safe-html="issuable.titleHtml || issuable.title" class="title gl-font-size-h-display" dir="auto" - data-qa-selector="title_content" - data-testid="title" + data-testid="issuable-title" ></h1> <gl-button v-if="enableEdit" diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 4503ba6e561..f54c4c52743 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -88,6 +88,10 @@ export default { showSuperSidebarToggle() { return gon.use_new_navigation && sidebarState.isCollapsed; }, + + topBarClasses() { + return gon.use_new_navigation ? 'top-bar-fixed container-fluid' : ''; + }, }, created() { @@ -120,15 +124,17 @@ export default { <template> <div> - <div - class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" - > - <super-sidebar-toggle - v-if="showSuperSidebarToggle" - class="gl-mr-2" - :class="$options.JS_TOGGLE_EXPAND_CLASS" - /> - <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> + <div :class="topBarClasses" data-testid="top-bar"> + <div + class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" + > + <super-sidebar-toggle + v-if="showSuperSidebarToggle" + class="gl-mr-2" + :class="$options.JS_TOGGLE_EXPAND_CLASS" + /> + <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> + </div> </div> <template v-if="activePanel"> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue index 28618cb96a3..61bca18b050 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -1,15 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { name: 'SecurityReportDownloadDropdown', components: { - GlDropdown, - GlDropdownItem, - }, - directives: { - GlTooltip, + GlDisclosureDropdown, }, props: { artifacts: { @@ -26,19 +22,23 @@ export default { required: false, default: '', }, - title: { - type: String, - required: false, - default: '', - }, }, computed: { showDropdown() { return this.loading || this.artifacts.length > 0; }, + items() { + return this.artifacts.map(({ name, path }) => ({ + text: this.artifactText(name), + href: path, + extraAttrs: { + download: '', + }, + })); + }, }, methods: { - artifactText({ name }) { + artifactText(name) { return sprintf(s__('SecurityReports|Download %{artifactName}'), { artifactName: name, }); @@ -48,23 +48,13 @@ export default { </script> <template> - <gl-dropdown + <gl-disclosure-dropdown v-if="showDropdown" - v-gl-tooltip - :text="text" - :title="title" + :items="items" + :toggle-text="text" :loading="loading" icon="download" size="small" - right - > - <gl-dropdown-item - v-for="artifact in artifacts" - :key="artifact.path" - :href="artifact.path" - download - > - {{ artifactText(artifact) }} - </gl-dropdown-item> - </gl-dropdown> + placement="right" + /> </template> diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js index bc3741a3880..07b16a13e68 100644 --- a/app/assets/javascripts/vuex_shared/bindings.js +++ b/app/assets/javascripts/vuex_shared/bindings.js @@ -20,7 +20,8 @@ export const mapComputed = (list, defaultUpdateFn, root) => { get() { if (getter) { return this.$store.getters[getter]; - } else if (root) { + } + if (root) { if (typeof root === 'function') { return root(this.$store.state)[key]; } diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js index 1c6e632135d..ef82142289d 100644 --- a/app/assets/javascripts/webpack.js +++ b/app/assets/javascripts/webpack.js @@ -7,6 +7,7 @@ * e.g. the `window` scope, because it needs to be executed in the scope of webpack. */ -if (gon && gon.webpack_public_path) { +// eslint-disable-next-line camelcase +if (gon && gon.webpack_public_path && typeof __webpack_public_path__ !== 'undefined') { __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line camelcase } diff --git a/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue index 1ead16c944b..425679c1400 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue @@ -1,13 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Tracking from '~/tracking'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, LocalStorageSync, }, mixins: [Tracking.mixin()], @@ -25,7 +24,7 @@ export default { type: String, required: true, }, - filterOptions: { + items: { type: Array, required: true, }, @@ -63,8 +62,7 @@ export default { }, selectedSortOption() { return ( - this.filterOptions.find(({ key }) => this.sortFilterProp === key) || - this.defaultSortFilterProp + this.items.find(({ key }) => this.sortFilterProp === key) || this.defaultSortFilterProp ); }, }, @@ -94,23 +92,14 @@ export default { as-string @input="setDiscussionFilterOption" /> - <gl-dropdown - class="gl-xs-w-full" - size="small" - :text="getDropdownSelectedText" + <gl-collapsible-listbox + :toggle-text="getDropdownSelectedText" :disabled="loading" - right - > - <gl-dropdown-item - v-for="{ text, key, testid } in filterOptions" - :key="text" - :data-testid="testid" - is-check-item - :is-checked="isSortDropdownItemActive(key)" - @click="fetchFilteredDiscussions(key)" - > - {{ text }} - </gl-dropdown-item> - </gl-dropdown> + :items="items" + :selected="sortFilterProp" + placement="right" + size="small" + @select="fetchFilteredDiscussions" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index 66ad3d50287..57faed61280 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -74,6 +74,11 @@ export default { required: false, default: false, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -263,6 +268,7 @@ export default { :work-item-id="workItemId" :autofocus="autofocus" :comment-button-text="commentButtonText" + :is-work-item-confidential="isWorkItemConfidential" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" @error="$emit('error', $event)" 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 b143c529014..a79169bde1e 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 @@ -3,11 +3,13 @@ import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import { STATE_OPEN, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { STATE_OPEN, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME } from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; +import CommentFieldLayout from '~/notes/components/comment_field_layout.vue'; export default { i18n: { @@ -22,6 +24,7 @@ export default { markdownDocsPath: helpPagePath('user/markdown'), }, components: { + CommentFieldLayout, GlButton, MarkdownEditor, GlFormCheckbox, @@ -89,6 +92,11 @@ export default { required: false, default: false, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -119,6 +127,23 @@ export default { commentButtonTextComputed() { return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; }, + workItemDocPath() { + return this.workItemType === TASK_TYPE_NAME ? 'user/tasks.html' : 'user/okrs.html'; + }, + workItemDocAnchor() { + return this.workItemType === TASK_TYPE_NAME ? 'confidential-tasks' : 'confidential-okrs'; + }, + getWorkItemData() { + return { + confidential: this.isWorkItemConfidential, + confidential_issues_docs_path: helpPagePath(this.workItemDocPath, { + anchor: this.workItemDocAnchor, + }), + }; + }, + workItemTypeKey() { + return capitalizeFirstCharacter(this.workItemType).replace(' ', ''); + }, }, methods: { setCommentText(newText) { @@ -158,66 +183,73 @@ export default { <template> <div class="timeline-discussion-body gl-overflow-visible!"> <div class="note-body gl-p-0! gl-overflow-visible!"> - <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> - <markdown-editor - :value="commentText" - :render-markdown-path="markdownPreviewPath" - :markdown-docs-path="$options.constantOptions.markdownDocsPath" - :autocomplete-data-sources="autocompleteDataSources" - :form-field-props="formFieldProps" - :add-spacing-classes="false" - data-testid="work-item-add-comment" - class="gl-mb-5" - use-bottom-toolbar - supports-quick-actions - :autofocus="autofocus" - @input="setCommentText" - @keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })" - @keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })" - @keydown.esc.stop="cancelEditing" - /> - <gl-form-checkbox - v-if="isNewDiscussion" - v-model="isNoteInternal" - class="gl-mb-2" - data-testid="internal-note-checkbox" + <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1 new-note"> + <comment-field-layout + :with-alert-container="isWorkItemConfidential" + :noteable-data="getWorkItemData" + :noteable-type="workItemTypeKey" > - {{ $options.i18n.internal }} - <gl-icon - v-gl-tooltip:tooltipcontainer.bottom - name="question-o" - :size="16" - :title="$options.i18n.internalVisibility" - class="gl-text-blue-500" + <markdown-editor + :value="commentText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :autocomplete-data-sources="autocompleteDataSources" + :form-field-props="formFieldProps" + :add-spacing-classes="false" + data-testid="work-item-add-comment" + use-bottom-toolbar + supports-quick-actions + :autofocus="autofocus" + @input="setCommentText" + @keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })" + @keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })" + @keydown.esc.stop="cancelEditing" + /> + </comment-field-layout> + <div class="note-form-actions"> + <gl-form-checkbox + v-if="isNewDiscussion" + v-model="isNoteInternal" + class="gl-mb-2" + data-testid="internal-note-checkbox" + > + {{ $options.i18n.internal }} + <gl-icon + v-gl-tooltip:tooltipcontainer.bottom + name="question-o" + :size="16" + :title="$options.i18n.internalVisibility" + class="gl-text-blue-500" + /> + </gl-form-checkbox> + <gl-button + category="primary" + variant="confirm" + data-testid="confirm-button" + :disabled="!commentText.length" + :loading="isSubmitting" + @click="$emit('submitForm', { commentText, isNoteInternal })" + >{{ commentButtonTextComputed }} + </gl-button> + <work-item-state-toggle-button + v-if="isNewDiscussion" + class="gl-ml-3" + :work-item-id="workItemId" + :work-item-state="workItemState" + :work-item-type="workItemType" + can-update + @error="$emit('error', $event)" /> - </gl-form-checkbox> - <gl-button - category="primary" - variant="confirm" - data-testid="confirm-button" - :disabled="!commentText.length" - :loading="isSubmitting" - @click="$emit('submitForm', { commentText, isNoteInternal })" - >{{ commentButtonTextComputed }} - </gl-button> - <work-item-state-toggle-button - v-if="isNewDiscussion" - class="gl-ml-3" - :work-item-id="workItemId" - :work-item-state="workItemState" - :work-item-type="workItemType" - can-update - @error="$emit('error', $event)" - /> - <gl-button - v-else - data-testid="cancel-button" - category="primary" - class="gl-ml-3" - :loading="updateInProgress" - @click="cancelEditing" - >{{ $options.i18n.cancelButtonText }} - </gl-button> + <gl-button + v-else + data-testid="cancel-button" + category="primary" + class="gl-ml-3" + :loading="updateInProgress" + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} + </gl-button> + </div> </form> </div> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index f030363664f..fd8842aa01a 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -65,6 +65,11 @@ export default { required: false, default: false, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -235,6 +240,7 @@ export default { :autocomplete-data-sources="autocompleteDataSources" :markdown-preview-path="markdownPreviewPath" :is-internal-thread="note.internal" + :is-work-item-confidential="isWorkItemConfidential" @startReplying="showReplyForm" @cancelEditing="hideReplyForm" @replied="onReplied" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 92560f2da9e..b5e3ea68725 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -163,6 +163,9 @@ export default { projectName() { return this.workItem?.project?.name; }, + isWorkItemConfidential() { + return this.workItem?.confidential; + }, }, apollo: { workItem: { @@ -314,6 +317,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :work-item-id="workItemId" :autofocus="isEditing" + :is-work-item-confidential="isWorkItemConfidential" class="gl-pl-3 gl-mt-3" @cancelEditing="isEditing = false" @submitForm="updateNote" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue index 0c1419e983f..1578c78ac4f 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue @@ -64,7 +64,7 @@ export default { :work-item-type="workItemType" :loading="disableActivityFilterSort" :sort-filter-prop="discussionFilter" - :filter-options="$options.WORK_ITEM_ACTIVITY_FILTER_OPTIONS" + :items="$options.WORK_ITEM_ACTIVITY_FILTER_OPTIONS" :storage-key="$options.WORK_ITEM_NOTES_FILTER_KEY" :default-sort-filter-prop="$options.WORK_ITEM_NOTES_FILTER_ALL_NOTES" tracking-action="work_item_notes_filter_changed" @@ -77,7 +77,7 @@ export default { :work-item-type="workItemType" :loading="disableActivityFilterSort" :sort-filter-prop="sortOrder" - :filter-options="$options.WORK_ITEM_ACTIVITY_SORT_OPTIONS" + :items="$options.WORK_ITEM_ACTIVITY_SORT_OPTIONS" :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY" :default-sort-filter-prop="$options.ASC" tracking-action="work_item_notes_sort_order_changed" 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 0a38dcb77f6..f50cfac90f7 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 @@ -43,15 +43,6 @@ export default { type: Boolean, required: true, }, - parentWorkItemId: { - type: String, - required: true, - }, - workItemType: { - type: String, - required: false, - default: '', - }, childPath: { type: String, required: true, @@ -158,7 +149,7 @@ export default { </span> <gl-link :href="childPath" - class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + class="gl-text-truncate gl-font-weight-semibold" data-testid="item-title" @click="$emit('click', $event)" @mouseover="$emit('mouseover')" diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue index ddeac2b92ae..38d8d239a7e 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue @@ -42,7 +42,8 @@ export default { assigneesContainerClass() { if (this.assignees.length === 2) { return 'fixed-width-avatars-2'; - } else if (this.assignees.length > 2) { + } + if (this.assignees.length > 2) { return 'fixed-width-avatars-3'; } return ''; diff --git a/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue index 53e8eedf060..12b7bade31d 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue @@ -1,29 +1,28 @@ <script> -import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; export default { components: { - GlDropdownItem, - GlDropdown, - GlIcon, + GlDisclosureDropdownItem, + GlDisclosureDropdown, }, }; </script> <template> <div class="gl-ml-5"> - <gl-dropdown + <gl-disclosure-dropdown category="tertiary" toggle-class="btn-icon btn-sm" - :right="true" + icon="ellipsis_v" data-testid="work_items_links_menu" + :aria-label="__(`More actions`)" + text-sr-only + no-caret > - <template #button-content> - <gl-icon name="ellipsis_v" :size="14" /> - </template> - <gl-dropdown-item @click="$emit('removeChild')"> - {{ s__('WorkItem|Remove') }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-item @action="$emit('removeChild')"> + <template #list-item>{{ s__('WorkItem|Remove') }}</template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </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 new file mode 100644 index 00000000000..7b38e838033 --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -0,0 +1,145 @@ +<script> +import { GlTokenSelector } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import { + WORK_ITEMS_TYPE_MAP, + WORK_ITEM_TYPE_ENUM_TASK, + I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + sprintfWorkItem, +} from '../../constants'; + +export default { + components: { + GlTokenSelector, + }, + props: { + value: { + type: Array, + required: false, + default: () => [], + }, + fullPath: { + type: String, + required: true, + }, + childrenType: { + type: String, + required: false, + default: WORK_ITEM_TYPE_ENUM_TASK, + }, + childrenIds: { + type: Array, + required: false, + default: () => [], + }, + parentWorkItemId: { + type: String, + required: true, + }, + areWorkItemsToAddValid: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + availableWorkItems: { + query: projectWorkItemsQuery, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.search?.title || this.search, + types: [this.childrenType], + in: this.search ? 'TITLE' : undefined, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace.workItems.nodes.filter( + (wi) => !this.childrenIds.includes(wi.id) && this.parentWorkItemId !== wi.id, + ); + }, + }, + }, + data() { + return { + availableWorkItems: [], + search: '', + searchStarted: false, + }; + }, + computed: { + workItemsToAdd: { + get() { + return this.value; + }, + set(workItemsToAdd) { + this.$emit('input', workItemsToAdd); + }, + }, + isLoading() { + return this.$apollo.queries.availableWorkItems.loading; + }, + addInputPlaceholder() { + return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); + }, + childrenTypeName() { + return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; + }, + tokenSelectorContainerClass() { + return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + getIdFromGraphQLId, + setSearchKey(value) { + this.search = value; + }, + handleFocus() { + this.searchStarted = true; + }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + }, +}; +</script> +<template> + <gl-token-selector + v-model="workItemsToAdd" + :dropdown-items="availableWorkItems" + :loading="isLoading" + :placeholder="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="debouncedSearchKeyUpdate" + @focus="handleFocus" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #token-content="{ token }"> + {{ token.title }} + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <div class="gl-display-flex"> + <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> + <div class="gl-text-truncate">{{ dropdownItem.title }}</div> + </div> + </template> + </gl-token-selector> +</template> diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue index f343f787358..27de858fe4e 100644 --- a/app/assets/javascripts/work_items/components/widget_wrapper.vue +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -14,6 +14,11 @@ export default { required: false, default: '', }, + widgetName: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -30,6 +35,12 @@ export default { isOpenString() { return this.isOpen ? 'true' : 'false'; }, + anchorLink() { + return `#${this.widgetName}`; + }, + anchorLinkId() { + return `user-content-${this.widgetName}-links`; + }, }, methods: { hide() { @@ -46,14 +57,14 @@ export default { </script> <template> - <div id="tasks" class="gl-new-card" :aria-expanded="isOpenString"> + <div :id="widgetName" class="gl-new-card" :aria-expanded="isOpenString"> <div class="gl-new-card-header"> <div class="gl-new-card-title-wrapper"> <h3 class="gl-new-card-title"> <gl-link - id="user-content-tasks-links" - class="anchor position-absolute gl-text-decoration-none" - href="#tasks" + :id="anchorLinkId" + class="gl-text-decoration-none" + :href="anchorLink" aria-hidden="true" /> <slot name="header"></slot> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index e8fe64c932b..18aa4d55086 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -1,8 +1,7 @@ <script> import { - GlDropdown, - GlDropdownItem, - GlDropdownForm, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlDropdownDivider, GlModal, GlModalDirective, @@ -53,9 +52,8 @@ export default { emailAddressCopied: __('Email address copied'), }, components: { - GlDropdown, - GlDropdownItem, - GlDropdownForm, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlDropdownDivider, GlModal, GlToggle, @@ -180,13 +178,16 @@ export default { navigator.clipboard.writeText(text); } toast(message); + this.closeDropdown(); }, handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); this.$emit('toggleWorkItemConfidentiality', !this.isConfidential); + this.closeDropdown(); }, handleDelete() { this.$refs.modal.show(); + this.closeDropdown(); }, handleDeleteWorkItem() { this.track('click_delete_work_item'); @@ -275,6 +276,9 @@ export default { throwConvertError() { this.$emit('error', this.i18n.convertError); }, + closeDropdown() { + this.$refs.workItemsMoreActions.close(); + }, async promoteToObjective() { try { const { @@ -300,6 +304,8 @@ export default { } catch (error) { this.throwConvertError(); Sentry.captureException(error); + } finally { + this.closeDropdown(); } }, }, @@ -308,77 +314,87 @@ export default { <template> <div> - <gl-dropdown + <gl-disclosure-dropdown + ref="workItemsMoreActions" icon="ellipsis_v" data-testid="work-item-actions-dropdown" text-sr-only :text="__('More actions')" category="tertiary" + :auto-close="false" no-caret right > <template v-if="$options.isLoggedIn"> - <gl-dropdown-form - class="work-item-notifications-form" + <gl-disclosure-dropdown-item + class="gl-display-flex gl-justify-content-end gl-w-full" :data-testid="$options.notificationsToggleFormTestId" > - <div class="gl-px-5 gl-pb-2 gl-pt-1"> + <template #list-item> <gl-toggle :value="subscribedToNotifications" :label="$options.i18n.notifications" :data-testid="$options.notificationsToggleTestId" + class="work-item-notification-toggle" label-position="left" label-id="notifications-toggle" @change="toggleNotifications($event)" /> - </div> - </gl-dropdown-form> + </template> + </gl-disclosure-dropdown-item> <gl-dropdown-divider /> </template> - <gl-dropdown-item + + <gl-disclosure-dropdown-item v-if="canPromoteToObjective" :data-testid="$options.promoteActionTestId" - @click="promoteToObjective" + @action="promoteToObjective" > - {{ __('Promote to objective') }} - </gl-dropdown-item> + <template #list-item>{{ __('Promote to objective') }}</template> + </gl-disclosure-dropdown-item> <template v-if="canUpdate && !isParentConfidential"> - <gl-dropdown-item + <gl-disclosure-dropdown-item :data-testid="$options.confidentialityTestId" - @click="handleToggleWorkItemConfidentiality" - >{{ + @action="handleToggleWorkItemConfidentiality" + ><template #list-item>{{ isConfidential ? $options.i18n.disableTaskConfidentiality : $options.i18n.enableTaskConfidentiality - }}</gl-dropdown-item + }}</template></gl-disclosure-dropdown-item > </template> - <gl-dropdown-item + <gl-disclosure-dropdown-item ref="workItemReference" :data-testid="$options.copyReferenceTestId" :data-clipboard-text="workItemReference" - @click="copyToClipboard(workItemReference, $options.i18n.referenceCopied)" - >{{ $options.i18n.copyReference }}</gl-dropdown-item + @action="copyToClipboard(workItemReference, $options.i18n.referenceCopied)" + ><template #list-item>{{ + $options.i18n.copyReference + }}</template></gl-disclosure-dropdown-item > <template v-if="$options.isLoggedIn && workItemCreateNoteEmail"> - <gl-dropdown-item + <gl-disclosure-dropdown-item ref="workItemCreateNoteEmail" :data-testid="$options.copyCreateNoteEmailTestId" :data-clipboard-text="workItemCreateNoteEmail" - @click="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" - >{{ i18n.copyCreateNoteEmail }}</gl-dropdown-item + @action="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" + ><template #list-item>{{ + i18n.copyCreateNoteEmail + }}</template></gl-disclosure-dropdown-item > - <gl-dropdown-divider v-if="canDelete" /> </template> - <gl-dropdown-item + <gl-dropdown-divider v-if="canDelete" /> + <gl-disclosure-dropdown-item v-if="canDelete" :data-testid="$options.deleteActionTestId" variant="danger" - @click="handleDelete" + @action="handleDelete" > - {{ i18n.deleteWorkItem }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item + ><span class="text-danger">{{ i18n.deleteWorkItem }}</span></template + > + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> <gl-modal ref="modal" modal-id="work-item-confirm-delete" diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4b4aa7f96ca..f9527884adc 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -388,7 +388,7 @@ export default { :display-text="__('Invite members')" trigger-element="side-nav" icon="plus" - trigger-source="work-item-assignees-dropdown" + trigger-source="work_item_assignees_dropdown" classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" /> </gl-dropdown-item> diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue index f93ea4a0753..14e55134048 100644 --- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -84,13 +84,13 @@ export default { <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> <confidentiality-badge v-if="isWorkItemConfidential" - class="gl-vertical-align-middle gl-display-inline-flex!" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" + class="gl-vertical-align-middle gl-display-inline-flex! gl-mr-2" :issuable-type="workItemType" + :workspace-type="$options.WORKSPACE_PROJECT" + hide-text-in-small-screens /> <work-item-type-icon - class="gl-vertical-align-middle gl-mr-0!" + class="gl-vertical-align-middle" :work-item-icon-name="workItemIconName" :work-item-type="workItemType" show-text 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 d826ef9cbe7..edecd7addcc 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -31,6 +31,7 @@ import { WORK_ITEM_TYPE_VALUE_ISSUE, WORK_ITEM_TYPE_VALUE_OBJECTIVE, WIDGET_TYPE_NOTES, + WIDGET_TYPE_LINKED_ITEMS, } from '../constants'; import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql'; @@ -50,6 +51,7 @@ import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; import WorkItemStateToggleButton from './work_item_state_toggle_button.vue'; +import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; export default { i18n, @@ -79,6 +81,7 @@ export default { AbuseCategorySelector, GlIntersectionObserver, ConfidentialityBadge, + WorkItemRelationships, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'reportAbusePath'], @@ -259,6 +262,15 @@ export default { showIntersectionObserver() { return !this.isModal && this.workItemsMvc2Enabled; }, + hasLinkedWorkItems() { + return this.glFeatures.linkedWorkItems; + }, + workItemLinkedItems() { + return this.isWidgetPresent(WIDGET_TYPE_LINKED_ITEMS); + }, + showWorkItemLinkedItems() { + return this.hasLinkedWorkItems && this.workItemLinkedItems; + }, }, mounted() { if (this.modalWorkItemIid) { @@ -515,9 +527,9 @@ export default { <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" /> <confidentiality-badge v-if="workItem.confidential" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" + class="gl-mr-3" :issuable-type="workItemType" + :workspace-type="$options.WORKSPACE_PROJECT" /> <work-item-todos v-if="showWorkItemCurrentUserTodos" @@ -591,6 +603,12 @@ export default { @show-modal="openInModal" @addChild="$emit('addChild')" /> + <work-item-relationships + v-if="showWorkItemLinkedItems" + :work-item-iid="workItemIid" + :work-item-full-path="workItem.project.fullPath" + @showModal="openInModal" + /> <work-item-notes v-if="workItemNotes" :work-item-id="workItem.id" @@ -600,6 +618,7 @@ export default { :assignees="workItemAssignees && workItemAssignees.assignees.nodes" :can-set-work-item-metadata="canAssignUnassignUser" :report-abuse-path="reportAbusePath" + :is-work-item-confidential="workItem.confidential" class="gl-pt-5" @error="updateError = $event" @has-notes="updateHasNotes" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index bf427feaa35..9d9414b5399 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -56,14 +56,14 @@ export default { return isLoggedIn() && this.canUpdate; }, treeRootWrapper() { - return this.canReorder ? Draggable : 'div'; + return this.canReorder ? Draggable : 'ul'; }, treeRootOptions() { const options = { ...defaultSortableOptions, fallbackOnBody: false, group: 'sortable-container', - tag: 'div', + tag: 'ul', 'ghost-class': 'tree-item-drag-active', 'data-parent-id': this.workItemId, value: this.children, @@ -248,6 +248,7 @@ export default { <component :is="treeRootWrapper" v-bind="treeRootOptions" + class="content-list" :class="{ 'gl-cursor-grab sortable-container': canReorder }" @end="handleDragOnEnd" > diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index a9b0c2b98bf..679287338c8 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -13,6 +13,7 @@ import { WIDGET_TYPE_HIERARCHY, WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; +import { workItemPath } from '../../utils'; import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; import WorkItemTreeChildren from './work_item_tree_children.vue'; @@ -90,7 +91,7 @@ export default { return this.isItemOpen ? __('Created') : __('Closed'); }, childPath() { - return `${gon?.relative_url_root || ''}/${this.fullPath}/-/work_items/${this.childItem.iid}`; + return workItemPath(this.fullPath, this.childItem.iid); }, chevronType() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; @@ -212,7 +213,7 @@ export default { </script> <template> - <div class="tree-item"> + <li class="tree-item"> <div class="gl-display-flex gl-align-items-flex-start" :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" @@ -249,5 +250,5 @@ export default { @removeChild="removeChild" @click="$emit('click', $event)" /> - </div> + </li> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index a0ff693e156..eb836007e75 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -103,6 +103,7 @@ export default { isReportDrawerOpen: false, reportedUserId: 0, reportedUrl: '', + widgetName: 'tasks', }; }, computed: { @@ -166,7 +167,6 @@ export default { this.updateWorkItemIdUrlQuery(child); }, async closeModal() { - this.activeChild = {}; this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(child) { @@ -206,6 +206,7 @@ export default { <widget-wrapper ref="wrapper" :error="error" + :widget-name="widgetName" data-testid="work-item-links" @dismissAlert="error = undefined" > 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 4960189fb48..55440e1603c 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 @@ -3,19 +3,15 @@ import { GlAlert, GlFormGroup, GlForm, - GlTokenSelector, GlButton, GlFormInput, GlFormCheckbox, GlTooltip, } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; +import WorkItemTokenInput from '../shared/work_item_token_input.vue'; import { addHierarchyChild } from '../../graphql/cache_utils'; import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql'; -import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; import { @@ -23,7 +19,6 @@ import { WORK_ITEMS_TYPE_MAP, WORK_ITEM_TYPE_ENUM_TASK, I18N_WORK_ITEM_CREATE_BUTTON_LABEL, - I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, I18N_WORK_ITEM_ADD_BUTTON_LABEL, I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, @@ -35,12 +30,12 @@ export default { components: { GlAlert, GlForm, - GlTokenSelector, GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlTooltip, + WorkItemTokenInput, }, inject: ['fullPath', 'hasIterationsFeature'], props: { @@ -101,35 +96,14 @@ export default { return data.workspace?.workItemTypes?.nodes; }, }, - availableWorkItems: { - query: projectWorkItemsQuery, - variables() { - return { - fullPath: this.fullPath, - searchTerm: this.search?.title || this.search, - types: [this.childrenType], - in: this.search ? 'TITLE' : undefined, - }; - }, - skip() { - return !this.searchStarted; - }, - update(data) { - return data.workspace.workItems.nodes.filter( - (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id, - ); - }, - }, }, data() { return { workItemTypes: [], - availableWorkItems: [], - search: '', - searchStarted: false, + workItemsToAdd: [], error: null, + search: '', childToCreateTitle: null, - workItemsToAdd: [], confidential: this.parentConfidential, }; }, @@ -177,7 +151,8 @@ export default { addOrCreateButtonLabel() { if (this.isCreateForm) { return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName); - } else if (this.workItemsToAdd.length > 1) { + } + if (this.workItemsToAdd.length > 1) { return sprintfWorkItem(I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, this.childrenTypeName); } return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); @@ -216,15 +191,6 @@ export default { } return this.workItemsToAdd.length === 0 || !this.areWorkItemsToAddValid; }, - isLoading() { - return this.$apollo.queries.availableWorkItems.loading; - }, - addInputPlaceholder() { - return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); - }, - tokenSelectorContainerClass() { - return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; - }, invalidWorkItemsToAdd() { return this.parentConfidential ? this.workItemsToAdd.filter((workItem) => !workItem.confidential) @@ -249,11 +215,7 @@ export default { ); }, }, - created() { - this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, methods: { - getIdFromGraphQLId, getConfidentialityTooltipTarget() { // We want tooltip to be anchored to `input` within checkbox component // but `$el.querySelector('input')` doesn't work. 🤷♂️ @@ -317,20 +279,6 @@ export default { this.childToCreateTitle = null; }); }, - setSearchKey(value) { - this.search = value; - }, - handleFocus() { - this.searchStarted = true; - }, - handleMouseOver() { - this.timeout = setTimeout(() => { - this.searchStarted = true; - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, - handleMouseOut() { - clearTimeout(this.timeout); - }, }, i18n: { inputLabel: __('Title'), @@ -385,30 +333,16 @@ export default { >{{ confidentialityCheckboxTooltip }}</gl-tooltip > <div class="gl-mb-4"> - <gl-token-selector + <work-item-token-input v-if="!isCreateForm" v-model="workItemsToAdd" - :dropdown-items="availableWorkItems" - :loading="isLoading" - :placeholder="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="debouncedSearchKeyUpdate" - @focus="handleFocus" - @mouseover.native="handleMouseOver" - @mouseout.native="handleMouseOut" - > - <template #token-content="{ token }"> - {{ token.title }} - </template> - <template #dropdown-item-content="{ dropdownItem }"> - <div class="gl-display-flex"> - <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> - <div class="gl-text-truncate">{{ dropdownItem.title }}</div> - </div> - </template> - </gl-token-selector> + :is-create-form="isCreateForm" + :parent-work-item-id="issuableGid" + :children-type="childrenType" + :children-ids="childrenIds" + :are-work-items-to-add-valid="areWorkItemsToAddValid" + :full-path="fullPath" + /> <div v-if="showWorkItemsToAddInvalidMessage" class="gl-text-red-500" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 246eac82c78..bc3f5201fb8 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -64,6 +64,7 @@ export default { isShownAddForm: false, formType: null, childType: null, + widgetName: 'tasks', }; }, computed: { @@ -101,6 +102,7 @@ export default { <template> <widget-wrapper ref="wrapper" + :widget-name="widgetName" :error="error" data-testid="work-item-tree" @dismissAlert="error = undefined" diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 8fc460294e6..256f8ed53d1 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -79,6 +79,11 @@ export default { type: String, required: true, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -124,6 +129,7 @@ export default { isNewDiscussion: true, markdownPreviewPath: this.markdownPreviewPath, autocompleteDataSources: this.autocompleteDataSources, + isWorkItemConfidential: this.isWorkItemConfidential, }; }, notesArray() { @@ -366,6 +372,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :assignees="assignees" :can-set-work-item-metadata="canSetWorkItemMetadata" + :is-work-item-confidential="isWorkItemConfidential" @deleteNote="showDeleteNoteModal($event, discussion)" @reportAbuse="reportAbuse(true, $event)" @error="$emit('error', $event)" diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue new file mode 100644 index 00000000000..cbe830f9565 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue @@ -0,0 +1,61 @@ +<script> +import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; +import { workItemPath } from '../../utils'; + +export default { + components: { + WorkItemLinkChildContents, + }, + props: { + linkedItems: { + type: Array, + required: false, + default: () => [], + }, + heading: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + workItemFullPath: { + type: String, + required: true, + }, + }, + methods: { + linkedItemPath(fullPath, id) { + return workItemPath(fullPath, id); + }, + }, +}; +</script> +<template> + <div> + <h4 + v-if="heading" + data-testid="work-items-list-heading" + class="gl-font-sm gl-font-weight-semibold gl-text-gray-700 gl-mx-2 gl-mt-3 gl-mb-2" + > + {{ heading }} + </h4> + <div class="work-items-list-body"> + <ul ref="list" class="work-items-list content-list"> + <li + v-for="linkedItem in linkedItems" + :key="linkedItem.workItem.id" + class="gl-pt-0! gl-pb-0! gl-border-b-0!" + > + <work-item-link-child-contents + :child-item="linkedItem.workItem" + :can-update="canUpdate" + :child-path="linkedItemPath(workItemFullPath, linkedItem.workItem.iid)" + @click="$emit('showModal', { event: $event, child: linkedItem.workItem })" + /> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue new file mode 100644 index 00000000000..4f6879e9605 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue @@ -0,0 +1,185 @@ +<script> +import { GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants'; + +import WidgetWrapper from '../widget_wrapper.vue'; +import WorkItemRelationshipList from './work_item_relationship_list.vue'; + +export default { + components: { + GlLoadingIcon, + GlIcon, + GlButton, + WidgetWrapper, + WorkItemRelationshipList, + }, + props: { + workItemIid: { + type: String, + required: true, + }, + workItemFullPath: { + type: String, + required: true, + }, + }, + apollo: { + workItem: { + query: workItemByIidQuery, + variables() { + return { + fullPath: this.workItemFullPath, + iid: this.workItemIid, + }; + }, + update(data) { + return data.workspace.workItems.nodes[0] ?? {}; + }, + context: { + isSingleRequest: true, + }, + skip() { + return !this.workItemIid; + }, + error(e) { + this.error = e.message || this.$options.i18n.fetchError; + }, + async result() { + // When work items are switched in a modal, the data props are not getting reset. + // Thus, duplicating the work items in the list. + // Here, the existing list are cleared before the new items are pushed. + this.linksRelatesTo = []; + this.linksIsBlockedBy = []; + this.linksBlocks = []; + + this.linkedWorkItems.forEach((item) => { + if (item.linkType === LINKED_CATEGORIES_MAP.RELATES_TO) { + this.linksRelatesTo.push(item); + } else if (item.linkType === LINKED_CATEGORIES_MAP.IS_BLOCKED_BY) { + this.linksIsBlockedBy.push(item); + } else if (item.linkType === LINKED_CATEGORIES_MAP.BLOCKS) { + this.linksBlocks.push(item); + } + }); + }, + }, + }, + data() { + return { + error: '', + linksRelatesTo: [], + linksIsBlockedBy: [], + linksBlocks: [], + widgetName: 'linkeditems', + }; + }, + computed: { + canUpdate() { + // This will be false untill we implement remove item mutation + return false; + }, + isLoading() { + return this.$apollo.queries.workItem.loading; + }, + linkedWorkItemsWidget() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS); + }, + linkedWorkItems() { + return this.linkedWorkItemsWidget?.linkedItems?.nodes || []; + }, + linkedWorkItemsCount() { + return this.linkedWorkItems.length; + }, + isEmptyRelatedWorkItems() { + return !this.error && this.linkedWorkItems.length === 0; + }, + }, + i18n: { + title: s__('WorkItem|Linked Items'), + fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'), + emptyStateMessage: s__( + "WorkItem|Link work items together to show that they're related or that one is blocking others.", + ), + addChildButtonLabel: s__('WorkItem|Add'), + relatedToTitle: s__('WorkItem|Related to'), + blockingTitle: s__('WorkItem|Blocking'), + blockedByTitle: s__('WorkItem|Blocked by'), + addLinkedWorkItemButtonLabel: s__('WorkItem|Add'), + }, +}; +</script> +<template> + <widget-wrapper + :error="error" + class="work-item-relationships" + :widget-name="widgetName" + @dismissAlert="error = undefined" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title"> + {{ $options.i18n.title }} + </h3> + <div v-if="linkedWorkItemsCount" class="gl-new-card-count"> + <gl-icon name="link" class="gl-mr-2" /> + <span data-testid="linked-items-count">{{ linkedWorkItemsCount }}</span> + </div> + </div> + </template> + <template #header-right> + <gl-button size="small" class="gl-ml-3"> + <slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot> + </gl-button> + </template> + <template #body> + <div class="gl-new-card-content"> + <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" /> + <template v-else> + <div v-if="isEmptyRelatedWorkItems" data-testid="links-empty"> + <p class="gl-new-card-empty"> + {{ $options.i18n.emptyStateMessage }} + </p> + </div> + <template v-else> + <work-item-relationship-list + v-if="linksBlocks.length" + :class="{ + 'gl-pb-3 gl-mb-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': + linksIsBlockedBy.length, + }" + :linked-items="linksBlocks" + :heading="$options.i18n.blockingTitle" + :work-item-full-path="workItemFullPath" + :can-update="canUpdate" + @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + /> + <work-item-relationship-list + v-if="linksIsBlockedBy.length" + :class="{ + 'gl-pb-3 gl-mb-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': + linksRelatesTo.length, + }" + :linked-items="linksIsBlockedBy" + :heading="$options.i18n.blockedByTitle" + :work-item-full-path="workItemFullPath" + :can-update="canUpdate" + @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + /> + <work-item-relationship-list + v-if="linksRelatesTo.length" + :linked-items="linksRelatesTo" + :heading="$options.i18n.relatedToTitle" + :work-item-full-path="workItemFullPath" + :can-update="canUpdate" + @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + /> + </template> + </template> + </div> + </template> + </widget-wrapper> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state_badge.vue b/app/assets/javascripts/work_items/components/work_item_state_badge.vue index 1d1bc7352b1..5c5b41b38e6 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_badge.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue @@ -1,11 +1,12 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { STATE_OPEN } from '../constants'; export default { components: { GlBadge, + GlIcon, }, props: { workItemState: { @@ -31,11 +32,8 @@ export default { </script> <template> - <gl-badge - :icon="workItemStateIcon" - :variant="workItemStateVariant" - class="gl-mr-2 gl-vertical-align-middle" - > - {{ stateText }} + <gl-badge :variant="workItemStateVariant" class="gl-mr-2 gl-vertical-align-middle"> + <gl-icon :name="workItemStateIcon" :size="16" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ stateText }}</span> </gl-badge> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index f27ae5f4e6d..5426f3965b3 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -53,7 +53,7 @@ export default { </script> <template> - <span class="gl-mr-2"> + <span> <gl-icon v-gl-tooltip.hover="showTooltipOnHover" :name="iconName" diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 57206550328..2b118247426 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -26,6 +26,7 @@ export const WIDGET_TYPE_MILESTONE = 'MILESTONE'; export const WIDGET_TYPE_ITERATION = 'ITERATION'; export const WIDGET_TYPE_NOTES = 'NOTES'; export const WIDGET_TYPE_HEALTH_STATUS = 'HEALTH_STATUS'; +export const WIDGET_TYPE_LINKED_ITEMS = 'LINKED_ITEMS'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; @@ -35,6 +36,7 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; +export const WORK_ITEM_TYPE_VALUE_EPIC = 'Epic'; export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident'; export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue'; export const WORK_ITEM_TYPE_VALUE_TASK = 'Task'; @@ -57,6 +59,9 @@ export const i18n = { export const I18N_WORK_ITEM_ERROR_FETCHING_LABELS = s__( 'WorkItem|Something went wrong when fetching labels. Please try again.', ); +export const I18N_WORK_ITEM_ERROR_FETCHING_TYPES = s__( + 'WorkItem|Something went wrong when fetching work item types. Please try again', +); export const I18N_WORK_ITEM_ERROR_CREATING = s__( 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.', ); @@ -208,24 +213,22 @@ export const WORK_ITEM_NOTES_FILTER_KEY = 'filter_key_work_item'; export const WORK_ITEM_ACTIVITY_FILTER_OPTIONS = [ { - key: WORK_ITEM_NOTES_FILTER_ALL_NOTES, + value: WORK_ITEM_NOTES_FILTER_ALL_NOTES, text: s__('WorkItem|All activity'), }, { - key: WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS, + value: WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS, text: s__('WorkItem|Comments only'), - testid: 'comments-activity', }, { - key: WORK_ITEM_NOTES_FILTER_ONLY_HISTORY, + value: WORK_ITEM_NOTES_FILTER_ONLY_HISTORY, text: s__('WorkItem|History only'), - testid: 'history-activity', }, ]; export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [ - { key: DESC, text: __('Newest first'), testid: 'newest-first' }, - { key: ASC, text: __('Oldest first') }, + { value: DESC, text: __('Newest first') }, + { value: ASC, text: __('Oldest first') }, ]; export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action'; @@ -241,10 +244,6 @@ export const TODO_DONE_ICON = 'todo-done'; export const TODO_DONE_STATE = 'done'; export const TODO_PENDING_STATE = 'pending'; -export const CURRENT_USER_TODOS_TYPENAME = 'WorkItemWidgetCurrentUserTodos'; - -export const EMOJI_ACTION_ADD = 'ADD'; -export const EMOJI_ACTION_REMOVE = 'REMOVE'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; @@ -257,3 +256,9 @@ export const WORK_ITEM_TO_ISSUE_MAP = { [WIDGET_TYPE_HEALTH_STATUS]: 'healthStatus', [WIDGET_TYPE_AWARD_EMOJI]: 'awardEmoji', }; + +export const LINKED_CATEGORIES_MAP = { + RELATES_TO: 'relates_to', + IS_BLOCKED_BY: 'is_blocked_by', + BLOCKS: 'blocks', +}; diff --git a/app/assets/javascripts/work_items/graphql/group_work_item_types.query.graphql b/app/assets/javascripts/work_items/graphql/group_work_item_types.query.graphql new file mode 100644 index 00000000000..30757f57234 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/group_work_item_types.query.graphql @@ -0,0 +1,11 @@ +query groupWorkItemTypes($fullPath: ID!) { + workspace: group(fullPath: $fullPath) { + id + workItemTypes { + nodes { + id + name + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 383d003e78c..ffc9fe2f7f7 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -100,4 +100,31 @@ fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetAwardEmoji { type } + + ... on WorkItemWidgetLinkedItems { + type + linkedItems { + nodes { + linkId + linkType + workItem { + id + iid + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ...WorkItemMetadataWidgets + } + } + } + } + } } diff --git a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue index fe7cb719bbb..a853018a931 100644 --- a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -1,5 +1,7 @@ <script> import * as Sentry from '@sentry/browser'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import { STATUS_OPEN } from '~/issues/constants'; import { __, s__ } from '~/locale'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; @@ -8,12 +10,11 @@ import { STATE_CLOSED } from '../../constants'; import getWorkItemsQuery from '../queries/get_work_items.query.graphql'; export default { - i18n: { - searchPlaceholder: __('Search or filter results...'), - }, issuableListTabs, components: { IssuableList, + IssueCardStatistics, + IssueCardTimeInfo, }, inject: ['fullPath'], data() { @@ -57,17 +58,33 @@ export default { :current-tab="state" :error="error" :issuables="workItems" + :issuables-loading="$apollo.queries.workItems.loading" namespace="work-items" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" show-work-item-type-icon :sort-options="sortOptions" :tabs="$options.issuableListTabs" @dismiss-alert="error = undefined" > + <template #nav-actions> + <slot name="nav-actions"></slot> + </template> + + <template #timeframe="{ issuable = {} }"> + <issue-card-time-info :issue="issuable" /> + </template> + <template #status="{ issuable }"> {{ getStatus(issuable) }} </template> + + <template #statistics="{ issuable = {} }"> + <issue-card-statistics :issue="issuable" /> + </template> + + <template #list-body> + <slot name="list-body"></slot> + </template> </issuable-list> </template> diff --git a/app/assets/javascripts/work_items/list/index.js b/app/assets/javascripts/work_items/list/index.js index 5cd38600779..885cea2c1d6 100644 --- a/app/assets/javascripts/work_items/list/index.js +++ b/app/assets/javascripts/work_items/list/index.js @@ -1,7 +1,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import WorkItemsListApp from './components/work_items_list_app.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import WorkItemsListApp from 'ee_else_ce/work_items/list/components/work_items_list_app.vue'; export const mountWorkItemsListApp = () => { const el = document.querySelector('.js-work-items-list-root'); @@ -12,6 +13,13 @@ export const mountWorkItemsListApp = () => { Vue.use(VueApollo); + const { + fullPath, + hasEpicsFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, + } = el.dataset; + return new Vue({ el, name: 'WorkItemsListRoot', @@ -19,7 +27,10 @@ export const mountWorkItemsListApp = () => { defaultClient: createDefaultClient(), }), provide: { - fullPath: el.dataset.fullPath, + fullPath, + hasEpicsFeature: parseBoolean(hasEpicsFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), }, render: (createComponent) => createComponent(WorkItemsListApp), }); diff --git a/app/assets/javascripts/work_items/list/queries/base_work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/list/queries/base_work_item_widgets.fragment.graphql new file mode 100644 index 00000000000..1198973d184 --- /dev/null +++ b/app/assets/javascripts/work_items/list/queries/base_work_item_widgets.fragment.graphql @@ -0,0 +1,38 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment BaseWorkItemWidgets on WorkItemWidget { + ... on WorkItemWidgetAssignees { + type + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetLabels { + type + allowsScopedLabels + labels { + nodes { + id + color + description + title + } + } + } + ... on WorkItemWidgetMilestone { + type + milestone { + id + dueDate + startDate + title + webPath + } + } + ... on WorkItemWidgetStartAndDueDate { + type + dueDate + } +} diff --git a/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql index 7ada2cf12dd..623527302f1 100644 --- a/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql +++ b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql @@ -1,3 +1,5 @@ +#import "ee_else_ce/work_items/list/queries/work_item_widgets.fragment.graphql" + query getWorkItems($fullPath: ID!) { group(fullPath: $fullPath) { id @@ -21,30 +23,7 @@ query getWorkItems($fullPath: ID!) { updatedAt webUrl widgets { - ... on WorkItemWidgetAssignees { - assignees { - nodes { - id - avatarUrl - name - username - webUrl - } - } - type - } - ... on WorkItemWidgetLabels { - allowsScopedLabels - labels { - nodes { - id - color - description - title - } - } - type - } + ...WorkItemWidgets } workItemType { id diff --git a/app/assets/javascripts/work_items/list/queries/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/list/queries/work_item_widgets.fragment.graphql new file mode 100644 index 00000000000..6862df5d330 --- /dev/null +++ b/app/assets/javascripts/work_items/list/queries/work_item_widgets.fragment.graphql @@ -0,0 +1,5 @@ +#import "./base_work_item_widgets.fragment.graphql" + +fragment WorkItemWidgets on WorkItemWidget { + ...BaseWorkItemWidgets +} diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 49ec12db4e1..b5705b21b5a 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -3,7 +3,11 @@ import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui'; import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { getPreferredLocales, s__ } from '~/locale'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants'; +import { + I18N_WORK_ITEM_ERROR_CREATING, + I18N_WORK_ITEM_ERROR_FETCHING_TYPES, + sprintfWorkItem, +} from '../constants'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; @@ -11,9 +15,6 @@ import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import ItemTitle from '../components/item_title.vue'; export default { - fetchTypesErrorText: s__( - 'WorkItem|Something went wrong when fetching work item types. Please try again', - ), components: { GlButton, GlAlert, @@ -53,7 +54,7 @@ export default { })); }, error() { - this.error = this.$options.fetchTypesErrorText; + this.error = I18N_WORK_ITEM_ERROR_FETCHING_TYPES; }, }, }, diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 5a882977bc2..1443e4b509d 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,9 +1,26 @@ -import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_LABELS } from './constants'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_HEALTH_STATUS, + WIDGET_TYPE_HIERARCHY, + WIDGET_TYPE_LABELS, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_START_AND_DUE_DATE, + WIDGET_TYPE_WEIGHT, +} from './constants'; export const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES; +export const isHealthStatusWidget = (widget) => widget.type === WIDGET_TYPE_HEALTH_STATUS; + export const isLabelsWidget = (widget) => widget.type === WIDGET_TYPE_LABELS; +export const isMilestoneWidget = (widget) => widget.type === WIDGET_TYPE_MILESTONE; + +export const isStartAndDueDateWidget = (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE; + +export const isWeightWidget = (widget) => widget.type === WIDGET_TYPE_WEIGHT; + export const findHierarchyWidgets = (widgets) => widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); @@ -26,3 +43,7 @@ export const markdownPreviewPath = (fullPath, iid) => `${ gon.relative_url_root || '' }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`; + +export const workItemPath = (fullPath, workItemIid) => { + return joinPaths(gon?.relative_url_root || '/', fullPath, '-', 'work_items', workItemIid); +}; diff --git a/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js index 61d93acdb91..57f4783955c 100644 --- a/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js +++ b/app/assets/javascripts/work_items_hierarchy/hierarchy_util.js @@ -3,7 +3,8 @@ import { LICENSE_PLAN } from './constants'; export function inferLicensePlan({ hasSubEpics, hasEpics }) { if (hasSubEpics) { return LICENSE_PLAN.ULTIMATE; - } else if (hasEpics) { + } + if (hasEpics) { return LICENSE_PLAN.PREMIUM; } return LICENSE_PLAN.FREE; diff --git a/app/assets/stylesheets/disable_animations.scss b/app/assets/stylesheets/disable_animations.scss index 1e63cdcfa39..7472340896f 100644 --- a/app/assets/stylesheets/disable_animations.scss +++ b/app/assets/stylesheets/disable_animations.scss @@ -1,4 +1,7 @@ -* { +*:not( + /* Keep transition enabled where it would otherwise break specs */ + .always-animate +) { /* stylelint-disable property-no-vendor-prefix */ -o-transition: none !important; -moz-transition: none !important; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 7b8d9281148..514247d2913 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -2,6 +2,7 @@ :root { --performance-bar-height: 0px; --system-header-height: 0px; + --header-height: 0px; --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; @@ -22,6 +23,10 @@ --system-header-height: #{$system-header-height}; } +.with-header { + --header-height: #{$header-height}; +} + .with-top-bar { --top-bar-height: #{$top-bar-height}; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 884cb70cb9f..a467d9e8c8a 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -287,6 +287,23 @@ } } + .non-blocking-loader & { + &.is-loading{ + .dropdown-content { + display: block; + height: 2rem; + + ul{ + display: none; + } + } + } + + .dropdown-loading{ + position: relative; + } + } + ul { margin: 0; padding: 0; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index b78b07f953b..67e96f08cb0 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -369,22 +369,6 @@ .board-labels-toggle-wrapper { margin-bottom: $gl-input-padding; } - - .board-swimlanes-toggle-wrapper { - @include gl-h-auto; - margin-bottom: $gl-input-padding; - - > span, - > .dropdown, - .gl-dropdown-toggle { - @include gl-w-full; - @include gl-m-0; - } - - > .dropdown { - @include gl-mt-2; - } - } } } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index b9fbcfb642c..32735679ded 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -5,7 +5,7 @@ $search-input-field-x-min-width: 200px; padding: 0 16px; z-index: $header-zindex; margin-bottom: 0; - min-height: $header-height; + min-height: var(--header-height); border: 0; position: fixed; top: $calc-system-headers-height; @@ -22,7 +22,7 @@ $search-input-field-x-min-width: 200px; display: flex; justify-content: space-between; position: relative; - min-height: $header-height; + min-height: var(--header-height); padding-left: 0; .title { @@ -505,7 +505,7 @@ $search-input-field-x-min-width: 200px; .navbar-empty { justify-content: center; - height: $header-height; + height: var(--header-height); background: $white; border-bottom: 1px solid $gray-100; @@ -642,7 +642,7 @@ header.navbar-gitlab.super-sidebar-logged-out { &:focus, &:active { - box-shadow: inset 0 0 0 $gl-border-size-1 $white; + @include gl-focus; } &:active { diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss index f77f64f1d76..e409facd081 100644 --- a/app/assets/stylesheets/framework/job_log.scss +++ b/app/assets/stylesheets/framework/job_log.scss @@ -6,7 +6,7 @@ word-break: break-all; word-wrap: break-word; color: color-yiq($builds-log-bg); - border-radius: $border-radius-small; + border-radius: 0 0 $border-radius-default $border-radius-default; min-height: 42px; background-color: $builds-log-bg; } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 086a16edda2..171f070d776 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -4,6 +4,12 @@ html { &.touch .tooltip { display: none !important; } + + @include media-breakpoint-up(sm) { + &.logged-out-marketing-header { + --header-height: 72px; + } + } } body { @@ -197,9 +203,3 @@ body { padding-right: 0; } } - -@include media-breakpoint-up(sm) { - .logged-out-marketing-header { - --header-height: 72px; - } -} diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index b953ff3024b..b87fd3e67d4 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -76,7 +76,7 @@ .referenced-users { color: $gl-text-color; - padding-top: 10px; + padding: 0 $gl-padding $gl-padding-8; } .referenced-commands { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index edebe9c95ad..a32663b17d3 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -263,7 +263,7 @@ @mixin build-log-top-bar($height) { @include build-log-bar($height); position: sticky; - top: $calc-application-header-height; + top: calc(#{$calc-application-header-height} - 1px); } /* diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 5f90dd62426..f2afa94e000 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -231,18 +231,6 @@ } } -.health-status { - .dropdown-body { - .health-divider { - border-top-color: $gray-100; - } - - .dropdown-item:not(.health-dropdown-item) { - padding: 0; - } - } -} - .toggle-right-sidebar-button { @include side-panel-toggle; border-bottom: 1px solid $border-color; @@ -799,29 +787,6 @@ } } -.participants-more, -.user-list-more { - margin-left: 5px; - - a, - .btn-link { - color: $gl-text-color-secondary; - } - - .btn-link { - padding: 0; - } - - .btn-link:hover { - color: $blue-800; - text-decoration: none; - } - - .btn-link:focus { - text-decoration: none; - } -} - .sidebar-help-wrap { .sidebar-help-state { margin: 16px -20px -20px; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 8610c41b43f..fbf9d8c8ca6 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -104,27 +104,6 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; box-shadow: none; } - .context-switcher .gl-new-dropdown-custom-toggle { - width: 100%; - } - - .context-switcher .gl-new-dropdown-panel { - overflow-y: auto; - } - - .context-switcher-search-box input { - @include gl-font-sm; - } - - .gl-new-dropdown-custom-toggle .context-switcher-toggle { - &[aria-expanded='true'] { - background-color: $t-gray-a-08; - } - - &:focus { - @include gl-focus($inset: true); } - } - .btn-with-notification { position: relative; @@ -158,15 +137,6 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } - .nav-item-link { - &:hover, - &:focus-within { - .nav-item-badge { - opacity: 0; - } - } - } - #trial-status-sidebar-widget:hover { text-decoration: none; @include gl-text-contrast-light; @@ -177,6 +147,15 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; display: none; } +.super-sidebar-has-peeked { + margin-top: calc(#{$header-height} - #{$gl-spacing-scale-2}); + margin-bottom: #{$gl-spacing-scale-2}; +} + +.super-sidebar-peek { + margin-left: #{$gl-spacing-scale-2}; +} + .super-sidebar-peek, .super-sidebar-peek-hint { @include gl-shadow; @@ -189,6 +168,14 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } +.super-sidebar-peek { + border-radius: $border-radius-default; + + .user-bar { + border-radius: $border-radius-default $border-radius-default 0 0; + } +} + .page-with-super-sidebar { padding-left: 0; @@ -295,19 +282,71 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } +.transition-opacity-on-hover--context { + .transition-opacity-on-hover--target { + transition: opacity $gl-transition-duration-fast linear; + + &:hover { + transition-delay: $gl-transition-duration-slow; + } + } + + &:hover { + .transition-opacity-on-hover--target { + transition-delay: $gl-transition-duration-slow; + } + } +} + .show-on-focus-or-hover--context { .show-on-focus-or-hover--target { opacity: 0; + + &:hover, + &:focus { + opacity: 1; + } } &:hover, - &:focus { + &:focus-within { + .show-on-focus-or-hover--control { + @include gl-bg-t-gray-a-08; + } + .show-on-focus-or-hover--target { opacity: 1; } } - .show-on-focus-or-hover--target:focus { + .show-on-focus-or-hover--control { + &:hover, + &:focus { + + .show-on-focus-or-hover--target { + opacity: 1; + } + } + } +} + +.hide-on-focus-or-hover--context { + .hide-on-focus-or-hover--target { opacity: 1; } + + &:hover, + &:focus-within { + .hide-on-focus-or-hover--target { + opacity: 0; + } + } + + .hide-on-focus-or-hover--control { + &:hover, + &:focus { + .hide-on-focus-or-hover--target { + opacity: 0; + } + } + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index d632689a4f6..e83f6af603a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -283,75 +283,79 @@ $color-ranges: ( // GitLab themes -$indigo-50: #f7f7ff; -$indigo-100: #ebebfa; -$indigo-200: #d1d1f0; -$indigo-300: #a6a6de; -$indigo-400: #7c7ccc; +$indigo-50: #f1f1ff; +$indigo-100: #dbdbf8; +$indigo-200: #c7c7f2; +$indigo-300: #a2a2e6; +$indigo-400: #8181d7; $indigo-500: #6666c4; -$indigo-600: #5b5bbd; -$indigo-700: #4b4ba3; -$indigo-800: #393982; -$indigo-900: #292961; -$indigo-950: #1a1a40; +$indigo-600: #5252b5; +$indigo-700: #41419f; +$indigo-800: #303083; +$indigo-900: #222261; +$indigo-950: #14143d; // To do this variant right for darkmode, we need to create a variable for it. $indigo-900-alpha-008: rgba($indigo-900, 0.08); -$theme-blue-50: #f4f8fc; -$theme-blue-100: #e6edf5; -$theme-blue-200: #c8d7e6; -$theme-blue-300: #97b3cf; -$theme-blue-400: #648cb4; -$theme-blue-500: #4a79a8; -$theme-blue-600: #3e6fa0; -$theme-blue-700: #305c88; -$theme-blue-800: #25496e; -$theme-blue-900: #1a3652; -$theme-blue-950: #0f2235; - -$theme-light-blue-50: #f2f7fc; -$theme-light-blue-100: #ebf1f7; -$theme-light-blue-200: #c9dcf2; -$theme-light-blue-300: #83abd4; -$theme-light-blue-400: #4d86bf; -$theme-light-blue-500: #367cc2; -$theme-light-blue-600: #3771ab; -$theme-light-blue-700: #2261a1; - -$theme-green-50: #f2faf6; -$theme-green-100: #e4f3ea; -$theme-green-200: #c0dfcd; -$theme-green-300: #8ac2a1; -$theme-green-400: #52a274; -$theme-green-500: #35935c; -$theme-green-600: #288a50; -$theme-green-700: #1c7441; -$theme-green-800: #145d33; -$theme-green-900: #0d4524; -$theme-green-950: #072d16; - -$theme-light-green-700: #156b39; - -$theme-red-50: #fcf4f2; -$theme-red-100: #fae9e6; -$theme-red-200: #ebcac5; -$theme-red-300: #d99b91; -$theme-red-400: #b0655a; +$theme-blue-50: #cdd8e3; +$theme-blue-100: #b9cadc; +$theme-blue-200: #a6bdd5; +$theme-blue-300: #81a5c9; +$theme-blue-400: #628eb9; +$theme-blue-500: #4977a5; +$theme-blue-600: #346596; +$theme-blue-700: #235180; +$theme-blue-800: #153c63; +$theme-blue-900: #0b2640; +$theme-blue-950: #04101c; + +$theme-light-blue-50: #dde6ee; +$theme-light-blue-100: #c1d4e6; +$theme-light-blue-200: #a0bedc; +$theme-light-blue-300: #74a3d3; +$theme-light-blue-400: #4f8bc7; +$theme-light-blue-500: #3476b9; +$theme-light-blue-600: #2268ae; +$theme-light-blue-700: #145aa1; +$theme-light-blue-800: #0e4d8d; +$theme-light-blue-900: #0c4277; +$theme-light-blue-950: #0a3764; + +$theme-green-50: #dde9de; +$theme-green-100: #b1d6b5; +$theme-green-200: #8cc497; +$theme-green-300: #69af7d; +$theme-green-400: #499767; +$theme-green-500: #308258; +$theme-green-600: #25744c; +$theme-green-700: #1b653f; +$theme-green-800: #155635; +$theme-green-900: #0e4328; +$theme-green-950: #052e19; + +$theme-red-50: #f4e9e7; +$theme-red-100: #ecd3d0; +$theme-red-200: #e3bab5; +$theme-red-300: #d59086; +$theme-red-400: #c66e60; $theme-red-500: #ad4a3b; -$theme-red-600: #9e4133; -$theme-red-700: #912f20; -$theme-red-800: #78291d; -$theme-red-900: #691a16; -$theme-red-950: #36140f; - -$theme-light-red-50: #fff6f5; -$theme-light-red-100: #fae2de; -$theme-light-red-200: #f7d5d0; -$theme-light-red-300: #d9796a; -$theme-light-red-400: #cf604e; +$theme-red-600: #a13322; +$theme-red-700: #8f2110; +$theme-red-800: #761405; +$theme-red-900: #580d02; +$theme-red-950: #380700; + +$theme-light-red-50: #faf2f1; +$theme-light-red-100: #f6d9d5; +$theme-light-red-200: #ebada2; +$theme-light-red-300: #e07f6f; +$theme-light-red-400: #d36250; $theme-light-red-500: #c24b38; -$theme-light-red-600: #b03927; -$theme-light-red-700: #a62e21; +$theme-light-red-600: #b53a26; +$theme-light-red-700: #a02e1c; +$theme-light-red-800: #8b2212; +$theme-light-red-900: #751709; +$theme-light-red-950: #5c1105; // Data visualization color palette @@ -459,7 +463,7 @@ $browser-scrollbar-size: 10px; /* * Misc */ -$header-height: var(--header-height, 48px); +$header-height: 48px; $content-wrapper-padding: 100px; $header-zindex: 1000; $zindex-dropdown-menu: 300; @@ -501,7 +505,7 @@ $pages-group-name-color: #4c4e54; * Calculated heights */ $calc-system-headers-height: calc(var(--system-header-height) + var(--performance-bar-height)); -$calc-application-bars-height: calc(#{$header-height} + #{$calc-system-headers-height}); +$calc-application-bars-height: calc(var(--header-height) + #{$calc-system-headers-height}); $calc-application-header-height: calc(#{$calc-application-bars-height} + var(--top-bar-height)); $calc-application-footer-height: var(--system-footer-height); $calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height}); @@ -692,14 +696,6 @@ $ci-skipped-color: #888; */ $issue-boards-font-size: 14px; $issue-boards-card-shadow: rgba(0, 0, 0, 0.1); -/* - The following heights are used in environment_logs.scss and are used for calculation of the log viewer height. -*/ -$environment-logs-breadcrumbs-height: 63px; -$environment-logs-breadcrumbs-height-md: $top-bar-height; - -$environment-logs-difference-xs-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height}); -$environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height-md}); /* * Avatar @@ -845,12 +841,6 @@ $perf-bar-bucket-box-shadow-to: rgba($black, 0.25); $perf-bar-canary-text: $orange-400; /* -Issuable warning -*/ -$issuable-warning-size: 24px; -$issuable-warning-icon-margin: 4px; - -/* Image Commenting cursor */ $image-comment-cursor-left-offset: 12; diff --git a/app/assets/stylesheets/page_bundles/admin/jobs_index.scss b/app/assets/stylesheets/page_bundles/admin/jobs_index.scss deleted file mode 100644 index 7844cae5f87..00000000000 --- a/app/assets/stylesheets/page_bundles/admin/jobs_index.scss +++ /dev/null @@ -1,5 +0,0 @@ -.admin-builds-table { - td:last-child { - min-width: 120px; - } -} diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index 5114f484e53..09c4d184f3f 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -15,6 +15,9 @@ .top-bar { @include build-log-top-bar(50px); + z-index: 2; + border-radius: $border-radius-default $border-radius-default 0 0; + box-shadow: 0 -2px 0 0 var(--white); &.has-archived-block { top: calc(#{$calc-application-header-height} + 28px); @@ -86,8 +89,6 @@ } .right-sidebar.build-sidebar { - padding: 0; - &.right-sidebar-collapsed { display: none; } @@ -100,29 +101,6 @@ -webkit-overflow-scrolling: touch; } - .blocks-container { - padding: 0 $gl-padding; - width: 289px; - } - - .trigger-variables-btn-container { - justify-content: space-between; - align-items: center; - - .trigger-variables-btn { - margin-top: -5px; - margin-bottom: -5px; - } - } - - .trigger-build-variables { - margin: 0; - overflow-x: auto; - width: 100%; - -ms-overflow-style: scrollbar; - -webkit-overflow-scrolling: touch; - } - .trigger-build-variable { font-weight: $gl-font-weight-normal; color: var(--gray-950, $gray-950); @@ -142,38 +120,20 @@ vertical-align: top; } - .badge.badge-pill { - margin-left: 2px; + .blocks-container { + width: 289px; } - .stage-item { - cursor: pointer; - - &:hover { - color: var(--gl-text-color, $gl-text-color); - } + .block { + width: 262px; } .builds-container { - background-color: var(--white, $white); - border-top: 1px solid var(--border-color, $border-color); - border-bottom: 1px solid var(--border-color, $border-color); - max-height: 300px; - width: 289px; overflow: auto; - a { - padding: $gl-padding 10px $gl-padding 40px; - width: 270px; - - &:hover { - color: var(--gl-text-color, $gl-text-color); - } - } - .icon-arrow-right { - left: 15px; - top: 20px; + left: 8px; + top: 12px; } .build-job { @@ -192,9 +152,15 @@ .container-fluid.container-limited { max-width: 100%; } +} - .content-wrapper { - padding-bottom: 6px; +.build-sidebar-item { + display: grid; + grid-template-columns: 1fr 2fr; + grid-gap: $gl-padding-8; + + &:last-of-type { + @include gl-mb-0; } } @@ -205,12 +171,3 @@ margin-bottom: 0; } } - -@include media-breakpoint-down(md) { - .content-list { - &.builds-content-list { - width: 100%; - overflow: auto; - } - } -} diff --git a/app/assets/stylesheets/page_bundles/incidents.scss b/app/assets/stylesheets/page_bundles/incidents.scss index fde35ab3d39..c7873473b86 100644 --- a/app/assets/stylesheets/page_bundles/incidents.scss +++ b/app/assets/stylesheets/page_bundles/incidents.scss @@ -43,8 +43,7 @@ } } - &:last-child, - &.create-timeline-event { + &:last-child { &::before { top: - #{$gl-spacing-scale-5} !important; // Override default positioning @include gl-h-8; diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 1b5da0368c6..5397f3d8895 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -1,15 +1,6 @@ @import 'mixins_and_variables_and_functions'; -.status-box { - padding: 0 $gl-btn-padding; - border-radius: $border-radius-default; - display: block; - float: left; - margin-right: $gl-padding-8; - color: var(--white, $white); - font-size: $gl-font-size; - line-height: $gl-line-height-24; -} +$issuable-warning-size: 24px; .issuable-warning-icon { background-color: var(--orange-50, $orange-50); @@ -18,7 +9,6 @@ width: $issuable-warning-size; height: $issuable-warning-size; text-align: center; - margin-right: $issuable-warning-icon-margin; line-height: $gl-line-height-24; flex: 0 0 auto; } diff --git a/app/assets/stylesheets/page_bundles/merge_request.scss b/app/assets/stylesheets/page_bundles/merge_request.scss index 113a50c4efa..f03efb82860 100644 --- a/app/assets/stylesheets/page_bundles/merge_request.scss +++ b/app/assets/stylesheets/page_bundles/merge_request.scss @@ -3,6 +3,12 @@ $tabs-holder-z-index: 250; $comparison-empty-state-height: 62px; +.apply-suggestions-input-min-width { + @include media-breakpoint-up(lg) { + width: $gl-dropdown-width-wide; + } +} + .space-children { @include clearfix; @@ -199,6 +205,7 @@ $comparison-empty-state-height: 62px; top: $calc-application-header-height; z-index: $tabs-holder-z-index; border-bottom: 1px solid var(--border-color, $border-color); + background-color: var(--gray-10, $white); @include media-breakpoint-up(md) { position: sticky; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index f39247f06c2..b00e1813696 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -372,7 +372,6 @@ $tabs-holder-z-index: 250; white-space: nowrap; } - /* stylelint-disable scss/at-rule-no-unknown */ @container mr-widget-extension (max-width: 600px) { flex-direction: column; align-items: flex-start; @@ -417,7 +416,7 @@ $tabs-holder-z-index: 250; .media-body { min-width: 0; - font-size: 12px; + font-size: $gl-font-size-sm; margin-left: 32px; } @@ -548,6 +547,7 @@ $tabs-holder-z-index: 250; } .mr-widget-section:not(:first-child) > div, + .mr-widget-section:not(:first-child) > section, .mr-widget-section .mr-widget-section > div { border-top: solid 1px var(--border-color, $border-color); } @@ -649,7 +649,6 @@ $tabs-holder-z-index: 250; .label-branch { @include gl-font-monospace; - font-size: 95%; overflow: hidden; word-break: break-all; } @@ -663,7 +662,7 @@ $tabs-holder-z-index: 250; > span { display: inline-block; max-width: 12.5em; - margin-bottom: -5px; + margin-bottom: px-to-rem(-5px); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -987,6 +986,14 @@ $tabs-holder-z-index: 250; } } +.container-fluid.diffs-container-limited { + .flash-container { + @include gl-mx-auto; + @include gl-max-w-container-xl; + @include gl-px-5; + } +} + .submit-review-dropdown { &.show .dropdown-menu { width: calc(100vw - 20px); diff --git a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss deleted file mode 100644 index af2dac7739e..00000000000 --- a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss +++ /dev/null @@ -1,82 +0,0 @@ -@import 'mixins_and_variables_and_functions'; - -.ci-variable-list { - margin-left: 0; - margin-bottom: 0; - padding-left: 0; - list-style: none; - clear: both; -} - -.ci-variable-row { - display: flex; - align-items: flex-start; - - @include media-breakpoint-down(xs) { - align-items: flex-end; - } - - &:not(:last-child) { - margin-bottom: $gl-btn-padding; - - @include media-breakpoint-down(xs) { - margin-bottom: 3 * $gl-btn-padding; - } - } - - &:last-child { - .ci-variable-body-item:last-child { - margin-right: $ci-variable-remove-button-width; - - @include media-breakpoint-down(xs) { - margin-right: 0; - } - } - - .ci-variable-row-remove-button { - display: none; - } - - @include media-breakpoint-down(xs) { - .ci-variable-row-body { - margin-right: $ci-variable-remove-button-width; - } - } - } -} - -.ci-variable-row-body { - display: flex; - align-items: flex-start; - width: 100%; - padding-bottom: $gl-padding; - - @include media-breakpoint-down(xs) { - display: block; - } -} - -.ci-variable-body-item { - flex: 1; - - &:not(:last-child) { - margin-right: $gl-btn-padding; - - @include media-breakpoint-down(xs) { - margin-right: 0; - margin-bottom: $gl-btn-padding; - } - } -} - -.pipeline-schedule-form { - .gl-field-error { - margin: 10px 0 0; - } -} - -.pipeline-schedule-table-row { - a { - color: var(--gl-text-color, $gl-text-color); - } -} diff --git a/app/assets/stylesheets/page_bundles/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss index 1a59f96c6ee..d09ad42a722 100644 --- a/app/assets/stylesheets/page_bundles/profiles/preferences.scss +++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss @@ -1,9 +1,9 @@ @import 'page_bundles/mixins_and_variables_and_functions'; .application-theme { - $ui-gray-bg: #303030; - $ui-light-gray-bg: #f0f0f0; - $ui-dark-mode-bg: #1f1f1f; + $ui-gray-bg: $gray-900; + $ui-light-gray-bg: $gray-50; + $ui-dark-mode-bg: $gray-950; .preview { font-size: 0; @@ -33,7 +33,7 @@ } &.ui-light-green { - background-color: $theme-light-green-700; + background-color: $theme-green-700; } &.ui-red { diff --git a/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss deleted file mode 100644 index 8f2cbc402c9..00000000000 --- a/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss +++ /dev/null @@ -1,19 +0,0 @@ -@import 'mixins_and_variables_and_functions'; - -.storage-type-usage { - &:first-child { - @include gl-rounded-top-left-base; - @include gl-rounded-bottom-left-base; - } - - &:last-child { - @include gl-rounded-top-right-base; - @include gl-rounded-bottom-right-base; - } - - &:not(:last-child) { - @include gl-border-r-2; - @include gl-border-r-solid; - border-right-color: var(--white, $white); - } -} diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index e8fa93e1504..f36cbc129a7 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -173,3 +173,13 @@ $work-item-sticky-header-height: 52px; min-width: 100%; } } + +.work-item-notification-toggle { + .gl-toggle { + margin-left: auto; + } + + .gl-toggle-label { + font-weight: normal; + } +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 0c9d151e3cd..38686d5e713 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -136,7 +136,7 @@ } .icon { - margin-right: $issuable-warning-icon-margin; + margin-right: $gl-padding-4; vertical-align: text-bottom; fill: $orange-600; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b9cae28537d..9ce470dbcf2 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -306,8 +306,6 @@ } .project-details { - max-width: 625px; - p, .commit-row-message { @include gl-mb-0; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 030e41046d3..7616f573412 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -235,7 +235,7 @@ body.gl-dark { } - .navbar-gitlab { + .navbar.navbar-gitlab { background-color: var(--gray-50); box-shadow: 0 1px 0 0 var(--gray-100); diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 8f0e0781918..db20034419a 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -20,7 +20,7 @@ // Header - .navbar-gitlab { + .navbar-gitlab:not(.super-sidebar-logged-out) { background-color: $navbar-theme-color; .navbar-collapse { @@ -283,11 +283,12 @@ $theme-color, $theme-color-darkest, ) { + --sidebar-background: #{mix(white, $theme-color-lightest, 50%)}; --transparent-white-16: rgba(255, 255, 255, 0.16); --transparent-white-24: rgba(255, 255, 255, 0.24); .super-sidebar { - background-color: $theme-color-lightest; + background-color: var(--sidebar-background); } .super-sidebar .user-bar { @@ -335,4 +336,8 @@ .active-indicator { background-color: $theme-color; } + + .super-sidebar-context-header { + color: $theme-color; + } } diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss index 9b7fc10e769..e8357647f48 100644 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ b/app/assets/stylesheets/themes/theme_light_gray.scss @@ -10,7 +10,7 @@ body { $gray-500 ); - .navbar-gitlab { + .navbar-gitlab:not(.super-sidebar-logged-out) { background-color: $gray-50; box-shadow: 0 1px 0 0 $border-color; diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index 720a0ec58b8..6b058b2dd7b 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -6,7 +6,7 @@ body { $theme-green-200, $theme-green-500, $theme-green-500, - $theme-light-green-700, + $theme-green-700, $white ); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index d5e9d35983a..b756e0ed704 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -132,19 +132,6 @@ fill: $red-500; } -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3569 -.gl-mb-n5 { - margin-bottom: -$gl-spacing-scale-5; -} - -.gl-mb-n7 { - margin-bottom: -$gl-spacing-scale-7; -} - -.gl-mb-n8 { - margin-bottom: -$gl-spacing-scale-8; -} - .gl-hover-border-gray-100:hover { border-color: $gray-100; } diff --git a/app/channels/noteable/notes_channel.rb b/app/channels/noteable/notes_channel.rb index 021bc3ccd1b..8ce6c15e123 100644 --- a/app/channels/noteable/notes_channel.rb +++ b/app/channels/noteable/notes_channel.rb @@ -13,7 +13,6 @@ module Noteable }).target return reject if noteable.nil? - return reject if Feature.disabled?(:action_cable_notes, project || noteable.try(:group)) stream_for noteable rescue ActiveRecord::RecordNotFound diff --git a/app/components/pajamas/banner_component.html.haml b/app/components/pajamas/banner_component.html.haml index c2eeae2d8c9..8a177edddb5 100644 --- a/app/components/pajamas/banner_component.html.haml +++ b/app/components/pajamas/banner_component.html.haml @@ -19,5 +19,4 @@ - actions.each do |action| = action - %button.gl-button.gl-banner-close.btn-sm.btn-icon.js-close{ @close_options, class: close_class, type: 'button' } - = sprite_icon('close', size: 16, css_class: 'dismiss-icon') + = render Pajamas::ButtonComponent.new(category: :tertiary, variant: close_button_variant, size: :small, icon: 'close', button_options: @close_options) diff --git a/app/components/pajamas/banner_component.rb b/app/components/pajamas/banner_component.rb index 1a03f3fdd58..5291db91fb2 100644 --- a/app/components/pajamas/banner_component.rb +++ b/app/components/pajamas/banner_component.rb @@ -27,7 +27,7 @@ module Pajamas @svg_path = svg_path.to_s @banner_options = banner_options @button_options = button_options - @close_options = close_options + @close_options = format_options(options: close_options, css_classes: %w[js-close gl-banner-close]) end VARIANT_OPTIONS = [:introduction, :promotion].freeze @@ -41,11 +41,11 @@ module Pajamas classes.join(' ') end - def close_class + def close_button_variant if introduction? - 'btn-confirm btn-confirm-tertiary' + :confirm else - 'btn-default btn-default-tertiary' + :default end end diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb new file mode 100644 index 00000000000..f9c2b14fe77 --- /dev/null +++ b/app/controllers/activity_pub/application_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActivityPub + class ApplicationController < ::ApplicationController + include RoutableActions + + before_action :ensure_feature_flag + skip_before_action :authenticate_user! + after_action :set_content_type + + def can?(object, action, subject = :global) + Ability.allowed?(object, action, subject) + end + + def route_not_found + head :not_found + end + + def set_content_type + self.content_type = "application/activity+json" + end + + def ensure_feature_flag + not_found unless ::Feature.enabled?(:activity_pub) + end + end +end diff --git a/app/controllers/activity_pub/projects/application_controller.rb b/app/controllers/activity_pub/projects/application_controller.rb new file mode 100644 index 00000000000..e54a457743d --- /dev/null +++ b/app/controllers/activity_pub/projects/application_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ApplicationController < ::ActivityPub::ApplicationController + before_action :project + before_action :ensure_project_feature_flag + + private + + def project + return unless params[:project_id] || params[:id] + + path = File.join(params[:namespace_id], params[:project_id] || params[:id]) + + @project = find_routable!(Project, path, request.fullpath, extra_authorization_proc: auth_proc) + end + + def auth_proc + ->(project) { project.public? && !project.pending_delete? } + end + + def ensure_project_feature_flag + not_found unless ::Feature.enabled?(:activity_pub_project, project) + end + end + end +end diff --git a/app/controllers/activity_pub/projects/releases_controller.rb b/app/controllers/activity_pub/projects/releases_controller.rb new file mode 100644 index 00000000000..7c4c2a0322b --- /dev/null +++ b/app/controllers/activity_pub/projects/releases_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesController < ApplicationController + feature_category :release_orchestration + + def index + opts = { + inbox: nil, + outbox: outbox_project_releases_url(@project) + } + + render json: ActivityPub::ReleasesActorSerializer.new.represent(@project, opts) + end + + def outbox + serializer = ActivityPub::ReleasesOutboxSerializer.new.with_pagination(request, response) + render json: serializer.represent(releases) + end + + private + + def releases(params = {}) + ReleasesFinder.new(@project, current_user, params).execute + end + end + end +end diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 329c4e4921a..b48d6f4f7c2 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -3,8 +3,11 @@ class Admin::AbuseReportsController < Admin::ApplicationController feature_category :insider_threat - before_action :set_status_param, only: :index, if: -> { Feature.enabled?(:abuse_reports_list) } + before_action :set_status_param, only: :index before_action :find_abuse_report, only: [:show, :moderate_user, :update, :destroy] + before_action only: :show do + push_frontend_feature_flag(:abuse_report_labels) + end def index @abuse_reports = AbuseReportsFinder.new(params).execute @@ -12,14 +15,11 @@ class Admin::AbuseReportsController < Admin::ApplicationController def show; end - # Kept for backwards compatibility. - # TODO: See https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/167?work_item_iid=443 - # In 16.4 remove or re-use this endpoint after frontend has migrated to using moderate_user endpoint def update - response = Admin::AbuseReports::ModerateUserService.new(@abuse_report, current_user, permitted_params).execute + response = Admin::AbuseReports::UpdateService.new(@abuse_report, current_user, permitted_params).execute if response.success? - render json: { message: response.message } + head :ok else render json: { message: response.message }, status: :unprocessable_entity end @@ -53,6 +53,6 @@ class Admin::AbuseReportsController < Admin::ApplicationController end def permitted_params - params.permit(:user_action, :close, :reason, :comment) + params.permit(:user_action, :close, :reason, :comment, { label_ids: [] }) end end diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb index 5ea8c672993..d0ade3e6024 100644 --- a/app/controllers/admin/jobs_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -7,18 +7,10 @@ class Admin::JobsController < Admin::ApplicationController urgency :low before_action do - push_frontend_feature_flag(:admin_jobs_vue) + push_frontend_feature_flag(:admin_jobs_filter_runner_type, type: :ops) end - def index - # We need all builds for tabs counters - @all_builds = Ci::JobsFinder.new(current_user: current_user).execute - - @scope = params[:scope] - @builds = Ci::JobsFinder.new(current_user: current_user, params: params).execute - @builds = @builds.eager_load_everything - @builds = @builds.page(params[:page]).per(BUILDS_PER_PAGE).without_count - end + def index; end def cancel_all Ci::Build.running_or_pending.each(&:cancel) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index f05b03c2787..1f05e4e7b21 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -221,8 +221,7 @@ class Admin::UsersController < Admin::ApplicationController respond_to do |format| result = Users::UpdateService.new(current_user, user_params_with_pass.merge(user: user)).execute do |user| - user.skip_reconfirmation! - user.send_only_admin_changed_your_password_notification! if admin_making_changes_for_another_user? + prepare_user_for_update(user) end if result[:status] == :success @@ -393,6 +392,12 @@ class Admin::UsersController < Admin::ApplicationController @can_impersonate = helpers.can_impersonate_user(user, impersonation_in_progress?) @impersonation_error_text = @can_impersonate ? nil : helpers.impersonation_error_text(user, impersonation_in_progress?) end + + # method overriden in EE + def prepare_user_for_update(user) + user.skip_reconfirmation! + user.send_only_admin_changed_your_password_notification! if admin_making_changes_for_another_user? + end end Admin::UsersController.prepend_mod_with('Admin::UsersController') diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 08e4f4956df..7c69f43fa3d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -38,7 +38,6 @@ class ApplicationController < ActionController::Base before_action :active_user_check, unless: :devise_controller? before_action :set_usage_stats_consent_flag before_action :check_impersonation_availability - before_action :required_signup_info # Make sure the `auth_user` is memoized so it can be logged, we do this after # all other before filters that could have set the user. @@ -115,6 +114,24 @@ class ApplicationController < ActionController::Base content_security_policy do |p| next if p.directives.blank? + + if Rails.env.development? && Feature.enabled?(:vite) + vite_host = ViteRuby.instance.config.host + vite_port = ViteRuby.instance.config.port + vite_origin = "#{vite_host}:#{vite_port}" + http_origin = "http://#{vite_origin}" + ws_origin = "ws://#{vite_origin}" + wss_origin = "wss://#{vite_origin}" + gitlab_ws_origin = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, 'vite-dev/') + http_path = Gitlab::Utils.append_path(http_origin, 'vite-dev/') + + connect_sources = p.directives['connect-src'] + p.connect_src(*(Array.wrap(connect_sources) | [ws_origin, wss_origin, http_path])) + + worker_sources = p.directives['worker-src'] + p.worker_src(*(Array.wrap(worker_sources) | [gitlab_ws_origin, http_path])) + end + next unless Gitlab::CurrentSettings.snowplow_enabled? && !Gitlab::CurrentSettings.snowplow_collector_hostname.blank? default_connect_src = p.directives['connect-src'] || p.directives['default-src'] @@ -326,9 +343,12 @@ class ApplicationController < ActionController::Base end def check_password_expiration - return if session[:impersonator_id] || !current_user&.allow_password_authentication? + return if session[:impersonator_id] + return if current_user.nil? - redirect_to new_profile_password_path if current_user&.password_expired? + if current_user.password_expired? && current_user.allow_password_authentication? + redirect_to new_profile_password_path + end end def active_user_check @@ -555,15 +575,6 @@ class ApplicationController < ActionController::Base def context_user auth_user if strong_memoized?(:auth_user) end - - def required_signup_info - return unless current_user - return unless current_user.role_required? - - store_location_for :user, request.fullpath - - redirect_to users_sign_up_welcome_path - end end ApplicationController.prepend_mod diff --git a/app/controllers/clusters/agents/dashboard_controller.rb b/app/controllers/clusters/agents/dashboard_controller.rb new file mode 100644 index 00000000000..1f72aaa4775 --- /dev/null +++ b/app/controllers/clusters/agents/dashboard_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class DashboardController < ApplicationController + include KasCookie + + before_action :check_feature_flag! + before_action :find_agent + before_action :authorize_read_cluster_agent! + before_action :set_kas_cookie, only: [:show], if: -> { current_user } + + feature_category :deployment_management + + def show + head :ok + end + + private + + def find_agent + @agent = ::Clusters::Agent.find(params[:agent_id]) + end + + def check_feature_flag! + not_found unless ::Feature.enabled?(:k8s_dashboard, current_user) + end + + def authorize_read_cluster_agent! + not_found unless can?(current_user, :read_cluster_agent, @agent) + end + end + end +end diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb index 84cbdda1581..de53fd4d835 100644 --- a/app/controllers/concerns/access_tokens_actions.rb +++ b/app/controllers/concerns/access_tokens_actions.rb @@ -69,6 +69,7 @@ module AccessTokensActions resource.members.load @scopes = Gitlab::Auth.available_scopes_for(resource) + @scopes.delete(Gitlab::Auth::K8S_PROXY_SCOPE) unless Feature.enabled?(:k8s_proxy_pat, current_user) @active_access_tokens = active_access_tokens end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb index 211566aeda7..9466952e98e 100644 --- a/app/controllers/concerns/harbor/access.rb +++ b/app/controllers/concerns/harbor/access.rb @@ -5,21 +5,13 @@ module Harbor extend ActiveSupport::Concern included do - before_action :harbor_registry_enabled! before_action :authorize_read_harbor_registry! - before_action do - push_frontend_feature_flag(:harbor_registry_integration) - end feature_category :integrations end private - def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration, defined?(group) ? group : project) - end - def authorize_read_harbor_registry! raise NotImplementedError end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 1b49cffd408..28e1056092d 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -174,22 +174,11 @@ module IssuableActions if Gitlab::Database.read_only? || params[:persist_filter] == 'false' notes_filter_param || current_user&.notes_filter_for(issuable) else - notes_filter = current_user&.set_notes_filter(notes_filter_param, issuable) || notes_filter_param - - # We need to invalidate the cache for polling notes otherwise it will - # ignore the filter. - # The ideal would be to invalidate the cache for each user. - issuable.expire_note_etag_cache if notes_filter_updated? - - notes_filter + current_user&.set_notes_filter(notes_filter_param, issuable) || notes_filter_param end end end - def notes_filter_updated? - current_user&.user_preference&.previous_changes&.any? - end - def discussion_cache_context [current_user&.cache_key, project.team.human_max_access(current_user&.id), 'v2'].join(':') end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index b02a636ff74..5479154f667 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -20,26 +20,10 @@ module IssuableCollections set_pagination return if redirect_out_of_range(@issuables, @total_pages) - - if params[:label_name].present? && @project - labels_params = { project_id: @project.id, title: params[:label_name] } - @labels = LabelsFinder.new(current_user, labels_params).execute - end - - @users = [] - if params[:assignee_id].present? - assignee = User.find_by_id(params[:assignee_id]) - @users.push(assignee) if assignee - end - - if params[:author_id].present? - author = User.find_by_id(params[:author_id]) - @users.push(author) if author - end end def set_pagination - row_count = finder.row_count + row_count = request.format.atom? ? -1 : finder.row_count @issuables = @issuables.page(params[:page]) @issuables = per_page_for_relative_position if params[:sort] == 'relative_position' diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 93cf1d15086..31b3d311865 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -33,9 +33,6 @@ module NotesActions notes.map { |note| note_json(note) } end - # Only present an ETag for the empty response - ::Gitlab::EtagCaching::Middleware.skip!(response) if notes.present? - render json: meta.merge(notes: notes) end diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb index 5112ebb3b5d..8a99f5a6c12 100644 --- a/app/controllers/concerns/onboarding/status.rb +++ b/app/controllers/concerns/onboarding/status.rb @@ -31,12 +31,6 @@ module Onboarding last_invited_member&.source end - def invite_with_tasks_to_be_done? - return false if members.empty? - - MemberTask.for_members(members).exists? - end - private attr_reader :user diff --git a/app/controllers/concerns/preferred_language_switcher.rb b/app/controllers/concerns/preferred_language_switcher.rb index 872652100c9..529d1fb78bd 100644 --- a/app/controllers/concerns/preferred_language_switcher.rb +++ b/app/controllers/concerns/preferred_language_switcher.rb @@ -2,6 +2,8 @@ module PreferredLanguageSwitcher extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + include PreferredLanguageSwitcherHelper private @@ -11,8 +13,37 @@ module PreferredLanguageSwitcher def preferred_language cookies[:preferred_language].presence_in(Gitlab::I18n.available_locales) || + selectable_language(marketing_site_language) || + selectable_language(browser_languages) || Gitlab::CurrentSettings.default_preferred_language end + + def selectable_language(language_options) + language_options.find { |lan| ordered_selectable_locales_codes.include?(lan) } + end + + def ordered_selectable_locales_codes + ordered_selectable_locales.pluck(:value) # rubocop:disable CodeReuse/ActiveRecord + end + + def browser_languages + formatted_http_language_header = request.env['HTTP_ACCEPT_LANGUAGE']&.tr('-', '_') + + return [] unless formatted_http_language_header + + formatted_http_language_header.split(%r{[;,]}).reject { |str| str.start_with?('q') } + end + strong_memoize_attr :browser_languages + + def marketing_site_language + return [] unless params[:glm_source] + + locale = params[:glm_source].scan(%r{(\w{2})-(\w{2})}).flatten + + return [] if locale.empty? + + [locale[0], "#{locale[0]}_#{locale[1]}"] + end end PreferredLanguageSwitcher.prepend_mod diff --git a/app/controllers/concerns/search_rate_limitable.rb b/app/controllers/concerns/search_rate_limitable.rb index 1105e9bbbfd..e32fc2f4dd6 100644 --- a/app/controllers/concerns/search_rate_limitable.rb +++ b/app/controllers/concerns/search_rate_limitable.rb @@ -11,7 +11,8 @@ module SearchRateLimitable # scopes to get counts, we apply rate limits on the search scope if it is present. # # If abusive search is detected, we have stricter limits and ignore the search scope. - check_rate_limit!(:search_rate_limit, scope: [current_user, safe_search_scope].compact) + check_rate_limit!(:search_rate_limit, scope: [current_user, safe_search_scope].compact, + users_allowlist: Gitlab::CurrentSettings.current_application_settings.search_rate_limit_allowlist) else check_rate_limit!(:search_rate_limit_unauthenticated, scope: [request.ip]) end diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb index 6affd7bb4cc..cb8aef11e8d 100644 --- a/app/controllers/concerns/verifies_with_email.rb +++ b/app/controllers/concerns/verifies_with_email.rb @@ -9,7 +9,6 @@ module VerifiesWithEmail included do prepend_before_action :verify_with_email, only: :create, unless: -> { skip_verify_with_email? } - skip_before_action :required_signup_info, only: :successful_verification end # rubocop:disable Metrics/PerceivedComplexity diff --git a/app/controllers/concerns/web_hooks/hook_log_actions.rb b/app/controllers/concerns/web_hooks/hook_log_actions.rb index 321cee5a452..dcea7596790 100644 --- a/app/controllers/concerns/web_hooks/hook_log_actions.rb +++ b/app/controllers/concerns/web_hooks/hook_log_actions.rb @@ -20,8 +20,13 @@ module WebHooks end def retry - execute_hook - redirect_to after_retry_redirect_path + if hook_log.url_current? + execute_hook + redirect_to after_retry_redirect_path + else + flash[:warning] = _('The hook URL has changed, and this log entry cannot be retried') + redirect_back(fallback_location: after_retry_redirect_path) + end end private diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index f7c7ee62c1a..5ceabaa734a 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -7,7 +7,6 @@ class ConfirmationsController < Devise::ConfirmationsController include GoogleAnalyticsCSP include GoogleSyndicationCSP - skip_before_action :required_signup_info prepend_before_action :check_recaptcha, only: :create before_action :load_recaptcha, only: :new diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb deleted file mode 100644 index 8ae429de490..00000000000 --- a/app/controllers/groups/email_campaigns_controller.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -class Groups::EmailCampaignsController < Groups::ApplicationController - EMAIL_CAMPAIGNS_SCHEMA_URL = 'iglu:com.gitlab/email_campaigns/jsonschema/1-0-0' - - feature_category :experimentation_activation - urgency :low - - before_action :check_params - - def index - track_click - redirect_to redirect_link - end - - private - - def track_click - if Gitlab.com? - message = Gitlab::Email::Message::InProductMarketing.for(@track).new(group: group, user: current_user, series: @series) - - data = { - namespace_id: group.id, - track: @track.to_s, - series: @series, - subject_line: message.subject_line - } - context = SnowplowTracker::SelfDescribingJson.new(EMAIL_CAMPAIGNS_SCHEMA_URL, data) - - ::Gitlab::Tracking.event(self.class.name, 'click', context: [context], user: current_user, namespace: group) - else - ::Users::InProductMarketingEmail.save_cta_click(current_user, @track, @series) - end - end - - def redirect_link - case @track - when :create - create_track_url - when :verify - project_pipelines_url(group.projects.first) - when :trial, :trial_short - 'https://about.gitlab.com/free-trial/' - when :team, :team_short - group_group_members_url(group) - when :admin_verify - project_settings_ci_cd_path(group.projects.first, anchor: 'js-runners-settings') - end - end - - def create_track_url - [ - new_project_url, - new_project_url(anchor: 'import_project'), - help_page_url('user/project/repository/repository_mirroring') - ][@series] - end - - def check_params - @track = params[:track]&.to_sym - @series = params[:series]&.to_i - - track_valid = @track.in?(Namespaces::InProductMarketingEmailsService::TRACKS.keys) - return render_404 unless track_valid - - series_valid = @series.in?(0..Namespaces::InProductMarketingEmailsService::TRACKS[@track][:interval_days].size - 1) - render_404 unless series_valid - end -end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index f927cae90b1..9535b83e769 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -98,7 +98,10 @@ class Groups::LabelsController < Groups::ApplicationController end def label_params - params.require(:label).permit(:title, :description, :color) + allowed = [:title, :description, :color] + allowed << :lock_on_merge if @group.supports_lock_on_merge? + + params.require(:label).permit(allowed) end def redirect_back_or_group_labels_path(options = {}) diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index b3539da8429..3600a0fbed5 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -42,6 +42,8 @@ class Groups::RunnersController < Groups::ApplicationController @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: group_params).execute .except(:limit, :offset) .find(params[:id]) + rescue Gitlab::Access::AccessDeniedError + nil end def runner_params diff --git a/app/controllers/groups/work_items_controller.rb b/app/controllers/groups/work_items_controller.rb index d1e15c81471..bd85f12119b 100644 --- a/app/controllers/groups/work_items_controller.rb +++ b/app/controllers/groups/work_items_controller.rb @@ -7,5 +7,9 @@ module Groups def index not_found unless Feature.enabled?(:namespace_level_work_items, group) end + + def show + not_found unless Feature.enabled?(:namespace_level_work_items, group) + end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 344de886a93..edc590e1370 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -37,7 +37,6 @@ class GroupsController < Groups::ApplicationController push_frontend_feature_flag(:frontend_caching, group) push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?) push_frontend_feature_flag(:issues_grid_view) - push_frontend_feature_flag(:new_graphql_users_autocomplete, group) end before_action only: :merge_requests do diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 9635e476510..df8128f24fe 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -9,7 +9,7 @@ class HelpController < ApplicationController # Taken from Jekyll # https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13 - YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze + YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m def index @help_index = get_markdown_without_frontmatter(path_to_doc('index.md')) diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index e17cd00d053..ba2743e1002 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -22,8 +22,8 @@ class Import::BitbucketServerController < Import::BaseController # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054) # # Bitbucket Server starts personal project names with a tilde. - VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/.freeze - VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/.freeze + VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/ + VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/ def new end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 8a8ae38c6f3..c058329680a 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -83,8 +83,6 @@ class InvitesController < ApplicationController def authenticate_user! return if current_user - store_location_for(:user, invite_details[:path]) if member - if user_sign_up? set_session_invite_params diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index a1d4df6ff48..a541e7e703f 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -14,7 +14,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController # include the call to session.delete def new if pre_auth.authorizable? - if skip_authorization? || matching_token? + if skip_authorization? || (matching_token? && pre_auth.client.application.confidential?) auth = authorization.authorize parsed_redirect_uri = URI.parse(auth.redirect_uri) session.delete(:user_return_to) diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb index 568cfe6399d..d3c3e878bdf 100644 --- a/app/controllers/organizations/application_controller.rb +++ b/app/controllers/organizations/application_controller.rb @@ -2,7 +2,7 @@ module Organizations class ApplicationController < ::ApplicationController - skip_before_action :authenticate_user! + before_action :check_feature_flag! before_action :organization layout 'organization' @@ -16,11 +16,16 @@ module Organizations end strong_memoize_attr :organization - def authorize_action!(action) - return if Feature.enabled?(:ui_for_organizations, current_user) && - can?(current_user, action, organization) + def check_feature_flag! + access_denied! unless Feature.enabled?(:ui_for_organizations, current_user) + end + + def authorize_create_organization! + access_denied! unless can?(current_user, :create_organization) + end - access_denied! + def authorize_read_organization! + access_denied! unless can?(current_user, :read_organization, organization) end end end diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb index 650ec97c264..88c6c9b3cef 100644 --- a/app/controllers/organizations/organizations_controller.rb +++ b/app/controllers/organizations/organizations_controller.rb @@ -4,10 +4,20 @@ module Organizations class OrganizationsController < ApplicationController feature_category :cell - before_action { authorize_action!(:read_organization) } + skip_before_action :authenticate_user!, except: [:index, :new] - def show; end + def index; end - def groups_and_projects; end + def new + authorize_create_organization! + end + + def show + authorize_read_organization! + end + + def groups_and_projects + authorize_read_organization! + end end end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 38839497fb6..d1ca16bd8fb 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -43,6 +43,7 @@ class PasswordsController < Devise::PasswordsController resource.password_expires_at = nil resource.save(validate: false) if resource.changed? else + log_audit_reset_failure(@user) track_weak_password_error(@user, self.class.name, 'create') end end @@ -50,6 +51,9 @@ class PasswordsController < Devise::PasswordsController protected + # overriden in EE + def log_audit_reset_failure(_user); end + def resource_from_email email = resource_params[:email] self.resource = resource_class.find_by_email(email) diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 02f7dbf8e6f..57e5ca4d55a 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -25,7 +25,7 @@ class Profiles::NotificationsController < Profiles::ApplicationController end def user_params - params.require(:user).permit(:notification_email, :email_opted_in, :notified_of_own_activity) + params.require(:user).permit(:notification_email, :notified_of_own_activity) end private diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 4b6e2f768fa..0e4d9f3c154 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -61,6 +61,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def set_index_vars @scopes = Gitlab::Auth.available_scopes_for(current_user) + @scopes.delete(Gitlab::Auth::K8S_PROXY_SCOPE) unless Feature.enabled?(:k8s_proxy_pat, current_user) @active_access_tokens = active_access_tokens end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 3e8555a4ed1..931070ecdd4 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -55,6 +55,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :gitpod_enabled, :render_whitespace_in_code, :project_shortcut_buttons, + :keyboard_shortcuts_enabled, :markdown_surround_selection, :markdown_automatic_lists, :use_new_navigation, diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index 281ac14d3ce..b596cd74b03 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -66,15 +66,11 @@ module Projects def integration AlertManagement::HttpIntegrationsFinder.new( project, - endpoint_identifier: endpoint_identifier, + endpoint_identifier: params[:endpoint_identifier], active: true ).execute.first end - def endpoint_identifier - params[:endpoint_identifier] || AlertManagement::HttpIntegration::LEGACY_IDENTIFIERS - end - def notification_payload @notification_payload ||= params.permit![:notification] end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 94cd324f312..2d2712ebe4d 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -45,6 +45,8 @@ class Projects::CommitsController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def signatures + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/424527') + respond_to do |format| format.json do render json: { diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb deleted file mode 100644 index 80344c83ab7..00000000000 --- a/app/controllers/projects/environments/sample_metrics_controller.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class Projects::Environments::SampleMetricsController < Projects::ApplicationController - feature_category :metrics - urgency :low - - def query - result = Metrics::SampleMetricsService.new(params[:identifier], range_start: params[:start], range_end: params[:end]).query - - if result - render json: { "status": "success", "data": { "resultType": "matrix", "result": result } } - else - render_404 - end - end -end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 127fe40b0e3..aabea122fb6 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -8,14 +8,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController layout 'project' - before_action only: [:show] do - push_frontend_feature_flag(:environment_details_vue, @project) - end - - before_action only: [:index, :edit, :new] do - push_frontend_feature_flag(:flux_resource_for_environment) - end - before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] @@ -113,10 +105,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController job = stop_actions.first if stop_actions&.count == 1 action_or_env_url = - if job.instance_of?(::Ci::Build) - polymorphic_url([project, job]) - elsif job.instance_of?(::Ci::Bridge) - project_pipeline_url(project, job.pipeline_id) + if job + project_job_url(project, job) else project_environment_url(project, @environment) end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index e73e2a38149..fce7de4c0de 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -34,7 +34,7 @@ class Projects::GraphsController < Projects::ApplicationController { author_name: commit.author_name, author_email: commit.author_email, - date: commit.committed_date.strftime("%Y-%m-%d") + date: commit.committed_date.to_date.iso8601 } end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 6109e29b169..69d349b1f1d 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -12,6 +12,7 @@ class Projects::IncidentsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:moved_mr_sidebar, project) push_frontend_feature_flag(:move_close_into_dropdown, project) + push_force_frontend_feature_flag(:linked_work_items, @project&.linked_work_items_feature_flag_enabled?) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 83947c443f4..9abcc108ace 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -62,7 +62,6 @@ class Projects::IssuesController < Projects::ApplicationController before_action only: [:index, :service_desk] do push_frontend_feature_flag(:or_issuable_queries, project) push_frontend_feature_flag(:frontend_caching, project&.group) - push_frontend_feature_flag(:new_graphql_users_autocomplete, project) end before_action only: :show do @@ -73,7 +72,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_frontend_feature_flag(:moved_mr_sidebar, project) push_frontend_feature_flag(:move_close_into_dropdown, project) - push_frontend_feature_flag(:action_cable_notes, project) + push_force_frontend_feature_flag(:linked_work_items, project.linked_work_items_feature_flag_enabled?) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -114,12 +113,6 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.atom { render layout: 'xml' } - format.json do - render json: { - html: view_to_html_string("projects/issues/_issues"), - labels: @labels.as_json(methods: :text_color) - } - end end end @@ -282,7 +275,6 @@ class Projects::IssuesController < Projects::ApplicationController def service_desk @issues = @issuables - @users.push(User.support_bot) end protected @@ -433,7 +425,7 @@ class Projects::IssuesController < Projects::ApplicationController if service_desk? options.reject! { |key| key == 'author_username' || key == 'author_id' } - options[:author_id] = User.support_bot + options[:author_id] = Users::Internal.support_bot end options diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 4e0b304a2ee..802ffd99e41 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -8,8 +8,8 @@ class Projects::JobsController < Projects::ApplicationController urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw] - before_action :find_job_as_build, except: [:index, :play, :retry] - before_action :find_job_as_processable, only: [:play, :retry] + before_action :find_job_as_build, except: [:index, :play, :retry, :show] + before_action :find_job_as_processable, only: [:play, :retry, :show] before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build! before_action :authorize_update_build!, @@ -27,17 +27,13 @@ class Projects::JobsController < Projects::ApplicationController feature_category :continuous_integration urgency :low - def index - # We need all builds for tabs counters - @all_builds = Ci::JobsFinder.new(current_user: current_user, project: @project).execute - - @scope = params[:scope] - @builds = Ci::JobsFinder.new(current_user: current_user, project: @project, params: params).execute - @builds = @builds.eager_load_everything - @builds = @builds.page(params[:page]).per(30).without_count - end + def index; end def show + if @build.instance_of?(::Ci::Bridge) + redirect_to project_pipeline_path(@build.downstream_pipeline.project, @build.downstream_pipeline.id) + end + respond_to do |format| format.html format.json do @@ -74,6 +70,8 @@ class Projects::JobsController < Projects::ApplicationController end def retry + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/424184') + response = Ci::RetryJobService.new(project, current_user).execute(@build) if response.success? diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 67cff16a76b..e62f912e0f7 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -155,7 +155,10 @@ class Projects::LabelsController < Projects::ApplicationController protected def label_params - params.require(:label).permit(:title, :description, :color) + allowed = [:title, :description, :color] + allowed << :lock_on_merge if @project.supports_lock_on_merge? + + params.require(:label).permit(allowed) end def label diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 6d1b1ced4eb..81ff6c215f9 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -14,6 +14,18 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont private + # Normally the methods with `check_(\w+)_available!` pattern are + # handled by the `method_missing` defined in `ProjectsController::ApplicationController` + # but that logic does not take the member roles into account, therefore, we handle this + # case here manually. + def check_merge_requests_available! + render_404 if project_policy.merge_requests_disabled? + end + + def project_policy + ProjectPolicy.new(current_user, project) + end + def merge_request @issuable = @merge_request ||= diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index 66a358963e2..26f4286233a 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -67,7 +67,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap flash[:notice] = _('All merge conflicts were resolved. The merge request can now be merged.') - render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) } + render json: { redirect_to: project_merge_request_path(@project, @merge_request, resolved_conflicts: true) } rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e render status: :bad_request, json: { message: e.message } end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 30168558eff..53fd7256b19 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -45,12 +45,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:sast_reports_in_inline_diff, project) push_frontend_feature_flag(:mr_experience_survey, project) push_frontend_feature_flag(:saved_replies, current_user) - push_frontend_feature_flag(:code_quality_inline_drawer, project) push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?) push_frontend_feature_flag(:mr_activity_filters, current_user) - push_frontend_feature_flag(:review_apps_redeploy_mr_widget, project) push_frontend_feature_flag(:ci_job_failures_in_mr, project) - push_frontend_feature_flag(:action_cable_notes, project) + push_frontend_feature_flag(:mr_pipelines_graphql, project) end before_action only: [:edit] do @@ -106,11 +104,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo respond_to do |format| format.html format.atom { render layout: 'xml' } - format.json do - render json: { - html: view_to_html_string("projects/merge_requests/_merge_requests") - } - end end end @@ -389,20 +382,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private - # NOTE: Remove this disable with add_prepared_state_to_mr FF removal - # rubocop: disable Metrics/AbcSize def show_merge_request close_merge_request_if_no_source_project @merge_request.check_mergeability(async: true) - # NOTE: Remove the created_at check when removing the FF check - if ::Feature.enabled?(:add_prepared_state_to_mr, @merge_request.project) && - @merge_request.created_at < 5.minutes.ago && - !@merge_request.prepared? - - @merge_request.prepare - end - respond_to do |format| format.html do # use next to appease Rubocop @@ -446,7 +429,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end end - # rubocop: enable Metrics/AbcSize def render_html_page preload_assignees_for_render(@merge_request) diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index acbd26cbdf6..a24273488fb 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -81,6 +81,7 @@ class Projects::MirrorsController < Projects::ApplicationController only_protected_branches keep_divergent_refs auth_method + user password ssh_known_hosts regenerate_ssh_private_key diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 7fcdf220bd2..3d8a787afcb 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -14,8 +14,7 @@ class Projects::NotesController < Projects::ApplicationController feature_category :team_planning, [:index, :create, :update, :destroy, :delete_attachment, :toggle_award_emoji] feature_category :code_review_workflow, [:resolve, :unresolve, :outdated_line_change] - urgency :medium, [:index] - urgency :low, [:create, :update, :destroy, :resolve, :unresolve, :toggle_award_emoji, :outdated_line_change] + urgency :low override :feature_category def feature_category diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 02579cd4283..5b32eb8e58e 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -65,7 +65,15 @@ class Projects::PagesController < Projects::ApplicationController end def project_params_attributes - [:pages_https_only, { project_setting_attributes: [:pages_unique_domain_enabled] }] + [ + :pages_https_only, + { project_setting_attributes: project_setting_attributes } + ] + end + + # overridden in EE + def project_setting_attributes + [:pages_unique_domain_enabled] end end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 42b6d83ee85..83a64579446 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -9,7 +9,6 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :authorize_create_pipeline_schedule!, only: [:new, :create] before_action :authorize_update_pipeline_schedule!, only: [:edit, :update] before_action :authorize_admin_pipeline_schedule!, only: [:take_ownership, :destroy] - before_action :push_schedule_feature_flag, only: [:index, :new, :edit] feature_category :continuous_integration urgency :low @@ -120,8 +119,4 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def authorize_admin_pipeline_schedule! return access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule) end - - def push_schedule_feature_flag - push_frontend_feature_flag(:pipeline_schedules_vue, @project) - end end diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index d77cf095a4f..4b522c88023 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -50,7 +50,7 @@ module Projects end def test_suite - suite = builds.sum do |build| + suite = builds.sum(Gitlab::Ci::Reports::TestSuite.new) do |build| test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) test_report.get_suite(build.test_suite_name) end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index a96ee2215c2..036ea45cc78 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -3,7 +3,6 @@ class Projects::PipelinesController < Projects::ApplicationController include ::Gitlab::Utils::StrongMemoize include ProductAnalyticsTracking - include ProductAnalyticsTracking include ProjectStatsRefreshConflictsGuard urgency :low, [ @@ -34,9 +33,9 @@ class Projects::PipelinesController < Projects::ApplicationController label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', destinations: %i[redis_hll snowplow] - track_event :charts, name: 'p_analytics_ci_cd_pipelines', conditions: -> { should_track_ci_cd_pipelines? } - track_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', conditions: -> { should_track_ci_cd_deployment_frequency? } - track_event :charts, name: 'p_analytics_ci_cd_lead_time', conditions: -> { should_track_ci_cd_lead_time? } + track_internal_event :charts, name: 'p_analytics_ci_cd_pipelines', conditions: -> { should_track_ci_cd_pipelines? } + track_internal_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', conditions: -> { should_track_ci_cd_deployment_frequency? } + track_internal_event :charts, name: 'p_analytics_ci_cd_lead_time', conditions: -> { should_track_ci_cd_lead_time? } track_event :charts, name: 'p_analytics_ci_cd_time_to_restore_service', conditions: -> { should_track_ci_cd_time_to_restore_service? } track_event :charts, name: 'p_analytics_ci_cd_change_failure_rate', conditions: -> { should_track_ci_cd_change_failure_rate? } diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb deleted file mode 100644 index 80a8dbf4729..00000000000 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Projects - module Prometheus - class AlertsController < Projects::ApplicationController - respond_to :json - - protect_from_forgery except: [:notify] - - skip_before_action :project, only: [:notify] - - prepend_before_action :repository, :project_without_auth, only: [:notify] - - before_action :authorize_read_prometheus_alerts!, except: [:notify] - - feature_category :incident_management - urgency :low - - def notify - token = extract_alert_manager_token(request) - result = notify_service.execute(token) - - head result.http_status - end - - private - - def notify_service - Projects::Prometheus::Alerts::NotifyService - .new(project, params.permit!) - end - - def extract_alert_manager_token(request) - Doorkeeper::OAuth::Token.from_bearer_authorization(request) - end - - def project_without_auth - @project ||= Project - .find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}") - end - end - end -end diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb index b1e30e7a45b..ca3cecf5949 100644 --- a/app/controllers/projects/service_desk_controller.rb +++ b/app/controllers/projects/service_desk_controller.rb @@ -36,7 +36,7 @@ class Projects::ServiceDeskController < Projects::ApplicationController service_desk_settings = project.service_desk_setting { - service_desk_address: project.service_desk_address, + service_desk_address: project.service_desk_system_address, service_desk_enabled: project.service_desk_enabled, issue_template_key: service_desk_settings&.issue_template_key, template_file_missing: service_desk_settings&.issue_template_missing?, diff --git a/app/controllers/projects/tracing_controller.rb b/app/controllers/projects/tracing_controller.rb deleted file mode 100644 index 45e773bf62b..00000000000 --- a/app/controllers/projects/tracing_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Projects - class TracingController < Projects::ApplicationController - include ::Observability::ContentSecurityPolicy - - feature_category :tracing - - before_action :check_tracing_enabled - - def index; end - - def show - @trace_id = params[:id] - end - - private - - def check_tracing_enabled - render_404 unless Gitlab::Observability.tracing_enabled?(project) - end - end -end diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb index 7da31c199a1..c3986be31b0 100644 --- a/app/controllers/projects/work_items_controller.rb +++ b/app/controllers/projects/work_items_controller.rb @@ -12,6 +12,7 @@ class Projects::WorkItemsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_force_frontend_feature_flag(:saved_replies, current_user) + push_force_frontend_feature_flag(:linked_work_items, project&.linked_work_items_feature_flag_enabled?) end feature_category :team_planning diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2ad0f11dc91..6a246219f7d 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -46,6 +46,7 @@ class ProjectsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) + push_force_frontend_feature_flag(:linked_work_items, @project&.linked_work_items_feature_flag_enabled?) end layout :determine_layout diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb index bb47bdc8050..8de1b10e1f1 100644 --- a/app/controllers/pwa_controller.rb +++ b/app/controllers/pwa_controller.rb @@ -6,7 +6,7 @@ class PwaController < ApplicationController # rubocop:disable Gitlab/NamespacedC feature_category :navigation urgency :low - skip_before_action :authenticate_user!, :required_signup_info + skip_before_action :authenticate_user! def manifest end diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 68f8248d114..f7a601ec0bd 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -8,7 +8,9 @@ module Registrations include ::Gitlab::Utils::StrongMemoize layout 'minimal' - skip_before_action :required_signup_info, :check_two_factor_requirement + # TODO: Once this is an ee + SaaS only feature, we can remove this. + # To be completed in https://gitlab.com/gitlab-org/gitlab/-/issues/411858 + skip_before_action :check_two_factor_requirement helper_method :welcome_update_params helper_method :onboarding_status @@ -43,7 +45,7 @@ module Registrations end def completed_welcome_step? - current_user.role.present? && !current_user.setup_for_company.nil? + !current_user.setup_for_company.nil? end def update_params @@ -61,9 +63,7 @@ module Registrations end def update_success_path - if onboarding_status.invite_with_tasks_to_be_done? - issues_dashboard_path(assignee_username: current_user.username) - elsif onboarding_status.continue_full_onboarding? # trials/regular registration on .com + if onboarding_status.continue_full_onboarding? # trials/regular registration on .com signup_onboarding_path elsif onboarding_status.single_invite? # invites w/o tasks due to order flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index d8064bbbe82..a8b5ca81f49 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -12,6 +12,8 @@ class RegistrationsController < Devise::RegistrationsController include PreferredLanguageSwitcher include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent include SkipsAlreadySignedInMessage + include Gitlab::RackLoadBalancingHelpers + include ::Gitlab::Utils::StrongMemoize layout 'devise' @@ -46,7 +48,6 @@ class RegistrationsController < Devise::RegistrationsController accept_pending_invitations if new_user.persisted? persist_accepted_terms_if_required(new_user) - set_role_required(new_user) send_custom_confirmation_instructions track_weak_password_error(new_user, self.class.name, 'create') @@ -89,10 +90,6 @@ class RegistrationsController < Devise::RegistrationsController Users::RespondToTermsService.new(new_user, terms).execute(accepted: true) end - def set_role_required(new_user) - new_user.set_role_required! if new_user.persisted? - end - def destroy_confirmation_valid? if current_user.confirm_deletion_with_password? current_user.valid_password?(params[:password]) @@ -138,7 +135,7 @@ class RegistrationsController < Devise::RegistrationsController if identity_verification_enabled? session[:verification_user_id] = resource.id # This is needed to find the user on the identity verification page - User.sticking.stick_or_unstick_request(request.env, :user, resource.id) + load_balancer_stick_request(::User, :user, resource.id) return identity_verification_redirect_path end @@ -251,6 +248,7 @@ class RegistrationsController < Devise::RegistrationsController sign_up_params[:email] == invite_email end + strong_memoize_attr :registered_with_invite_email? def load_recaptcha Gitlab::Recaptcha.load_configurations! diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 6c1d9a20570..d247490402f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -35,10 +35,6 @@ class SearchController < ApplicationController update_scope_for_code_search end - before_action only: :show do - push_frontend_feature_flag(:search_projects_hide_archived, current_user) - end - rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 6c5e709a98a..4f61088ab17 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -29,7 +29,7 @@ class SentNotificationsController < ApplicationController def unsubscribe_and_redirect noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project) - if noteable.is_a?(Issue) && @sent_notification.recipient_id == User.support_bot.id + if noteable.is_a?(Issue) && @sent_notification.recipient_id == Users::Internal.support_bot.id noteable.unsubscribe_email_participant(noteable.external_author) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 66ace16400a..afbadc7f4ac 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -16,6 +16,8 @@ class SessionsController < Devise::SessionsController include GoogleSyndicationCSP include PreferredLanguageSwitcher include SkipsAlreadySignedInMessage + include AcceptsPendingInvitations + extend ::Gitlab::Utils::Override skip_before_action :check_two_factor_requirement, only: [:destroy] skip_before_action :check_password_expiration, only: [:destroy] @@ -78,6 +80,8 @@ class SessionsController < Devise::SessionsController flash[:notice] = nil end + accept_pending_invitations + log_audit_event(current_user, resource, with: authentication_method) log_user_activity(current_user) end @@ -94,6 +98,13 @@ class SessionsController < Devise::SessionsController private + override :after_pending_invitations_hook + def after_pending_invitations_hook + member = resource.members.last + + store_location_for(:user, member.source.activity_path) if member + end + def captcha_enabled? request.headers[CAPTCHA_HEADER] && helpers.recaptcha_enabled? end diff --git a/app/controllers/users/namespace_visits_controller.rb b/app/controllers/users/namespace_visits_controller.rb new file mode 100644 index 00000000000..7c96d78e26e --- /dev/null +++ b/app/controllers/users/namespace_visits_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Users + class NamespaceVisitsController < ApplicationController + feature_category :navigation + + def create + return head :not_found unless Feature.enabled?(:server_side_frecent_namespaces, current_user) + return head :bad_request unless params[:type].present? && params[:id].present? + + Users::TrackNamespaceVisitsWorker.perform_async(params[:type], params[:id], current_user.id, DateTime.now) # rubocop:disable CodeReuse/Worker + head :ok + end + end +end diff --git a/app/experiments/ios_specific_templates_experiment.rb b/app/experiments/ios_specific_templates_experiment.rb index 1731fa87be8..5bd4a3d0287 100644 --- a/app/experiments/ios_specific_templates_experiment.rb +++ b/app/experiments/ios_specific_templates_experiment.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class IosSpecificTemplatesExperiment < ApplicationExperiment + control + before_run(if: :skip_experiment) { throw(:abort) } # rubocop:disable Cop/BanCatchThrow private diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb index 43cebd16d92..ee14372fcd9 100644 --- a/app/finders/abuse_reports_finder.rb +++ b/app/finders/abuse_reports_finder.rb @@ -17,6 +17,8 @@ class AbuseReportsFinder end def execute + @reports = reports.with_labels if Feature.enabled?(:abuse_report_labels) + filter_reports aggregate_reports sort_reports @@ -27,30 +29,16 @@ class AbuseReportsFinder private def filter_reports - if Feature.disabled?(:abuse_reports_list) - filter_by_user_id - return - end - filter_by_status filter_by_user filter_by_reporter filter_by_category end - def filter_by_user_id - return unless params[:user_id].present? - - @reports = @reports.by_user_id(params[:user_id]) - end - def filter_by_status return unless params[:status].present? - status = params[:status] - status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys) - - case status + case status_filter when 'open' @reports = @reports.open when 'closed' @@ -92,11 +80,6 @@ class AbuseReportsFinder end def sort_reports - if Feature.disabled?(:abuse_reports_list) - @reports = @reports.with_order_id_desc - return - end - # let sub_query in aggregate_reports do the sorting if sorting by number of reports return if sort_key.in?(SORT_BY_COUNT) @@ -107,15 +90,6 @@ class AbuseReportsFinder User.by_username(username).pick(:id) end - def status_open? - return unless Feature.enabled?(:abuse_reports_list) && params[:status].present? - - status = params[:status] - status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys) - - status == STATUS_OPEN - end - def aggregate_reports if status_open? sort_by_count = sort_key.in?(SORT_BY_COUNT) @@ -124,4 +98,19 @@ class AbuseReportsFinder @reports end + + def status_filter + @status_filter ||= + if params[:status].in?(AbuseReport.statuses.keys) + params[:status] + else + STATUS_OPEN + end + end + + def status_open? + return false if params[:status].blank? + + status_filter == STATUS_OPEN + end end diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb index 8620dff6973..efacd8143bc 100644 --- a/app/finders/ci/jobs_finder.rb +++ b/app/finders/ci/jobs_finder.rb @@ -5,8 +5,8 @@ module Ci include Gitlab::Allowable def initialize(current_user:, pipeline: nil, project: nil, runner: nil, params: {}, type: ::Ci::Build) - @pipeline = pipeline @current_user = current_user + @pipeline = pipeline @project = project @runner = runner @params = params @@ -16,8 +16,7 @@ module Ci def execute builds = init_collection.order_id_desc - builds = filter_by_with_artifacts(builds) - filter_by_scope(builds) + filter_builds(builds) rescue Gitlab::Access::AccessDeniedError type.none end @@ -58,6 +57,13 @@ module Ci params[:include_retried] ? jobs_scope : jobs_scope.latest end + # Overriden in EE + def filter_builds(builds) + builds = filter_by_with_artifacts(builds) + builds = filter_by_runner_types(builds) + filter_by_scope(builds) + end + def filter_by_scope(builds) return filter_by_statuses!(builds) if params[:scope].is_a?(Array) @@ -73,12 +79,21 @@ module Ci end end + def filter_by_runner_types(builds) + return builds unless use_runner_type_filter? + + builds.with_runner_type(params[:runner_type]) + end + + # Overriden in EE + def use_runner_type_filter? + params[:runner_type].present? && Feature.enabled?(:admin_jobs_filter_runner_type, project, type: :ops) + end + def filter_by_with_artifacts(builds) - if params[:with_artifacts] - builds.with_any_artifacts - else - builds - end + return builds.with_any_artifacts if params[:with_artifacts] + + builds end def filter_by_statuses!(builds) @@ -100,3 +115,5 @@ module Ci end end end + +Ci::JobsFinder.prepend_mod diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 630be17e64b..331f732bff7 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -24,9 +24,6 @@ module Ci request_tag_list! @runners - - rescue Gitlab::Access::AccessDeniedError - Ci::Runner.none end def sort_key diff --git a/app/finders/ci/triggers_finder.rb b/app/finders/ci/triggers_finder.rb new file mode 100644 index 00000000000..a4b429539d6 --- /dev/null +++ b/app/finders/ci/triggers_finder.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ci + class TriggersFinder + def initialize(current_user, project) + @current_user = current_user + @project = project + end + + def execute + return Ci::Trigger.none unless Ability.allowed?(@current_user, :admin_build, @project) + + @project.triggers + end + end +end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 639db58b00d..0336135835a 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -20,6 +20,8 @@ class GroupMembersFinder < UnionFinder # search: string # created_after: datetime # created_before: datetime + # non_invite: boolean + # with_custom_role: boolean attr_reader :params def initialize(group, user = nil, params: {}) @@ -34,7 +36,10 @@ class GroupMembersFinder < UnionFinder Group.shared_into_ancestors(group).public_or_visible_to_user(user) end - members = all_group_members(groups, shared_from_groups).distinct_on_user_with_max_access_level + members = all_group_members(groups, shared_from_groups) + if static_roles_only? + members = members.distinct_on_user_with_max_access_level + end filter_members(members) end @@ -70,7 +75,10 @@ class GroupMembersFinder < UnionFinder members = filter_by_user_type(members) members = apply_additional_filters(members) - by_created_at(members) + members = by_created_at(members) + members = members.non_invite if params[:non_invite] + + members end def can_manage_members @@ -137,6 +145,10 @@ class GroupMembersFinder < UnionFinder # overridden in EE to include additional filtering conditions. members end + + def static_roles_only? + true + end end GroupMembersFinder.prepend_mod_with('GroupMembersFinder') diff --git a/app/finders/groups/accepting_group_transfers_finder.rb b/app/finders/groups/accepting_group_transfers_finder.rb index c95318d0098..e757ecff015 100644 --- a/app/finders/groups/accepting_group_transfers_finder.rb +++ b/app/finders/groups/accepting_group_transfers_finder.rb @@ -14,7 +14,9 @@ module Groups return Group.none unless can_transfer_group? items = find_all_groups - items = by_search(items) + + # Search will perform an ORDER BY to ensure exact matches are returned first. + return by_search(items, exact_matches_first: true) if params[:search].present? sort(items) end diff --git a/app/finders/groups/base.rb b/app/finders/groups/base.rb index 9d2f9f60a63..26d2ad85fd4 100644 --- a/app/finders/groups/base.rb +++ b/app/finders/groups/base.rb @@ -8,10 +8,10 @@ module Groups items.reorder(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord end - def by_search(items) + def by_search(items, exact_matches_first: false) return items if params[:search].blank? - items.search(params[:search], include_parents: true) + items.search(params[:search], include_parents: true, exact_matches_first: exact_matches_first) end end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index bbbf14bb0d0..93b7292bb69 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -48,7 +48,7 @@ class IssuableFinder requires_cross_project_access unless: -> { params.project? } FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*' - FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze + FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/ NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze attr_accessor :current_user, :params diff --git a/app/finders/organizations/groups_finder.rb b/app/finders/organizations/groups_finder.rb new file mode 100644 index 00000000000..2b59a3106a3 --- /dev/null +++ b/app/finders/organizations/groups_finder.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Organizations::GroupsFinder +# +# Used to find Groups within an Organization +module Organizations + class GroupsFinder + # @param organization [Organizations::Organization] + # @param current_user [User] + # @param params [{ sort: { field: [String], direction: [String] }, search: [String] }] + def initialize(organization:, current_user:, params: {}) + @organization = organization + @current_user = current_user + @params = params + end + + def execute + return Group.none if organization.nil? || !authorized? + + filter_groups(all_accessible_groups) + .then { |groups| sort(groups) } + .then(&:with_route) + end + + private + + attr_reader :organization, :params, :current_user + + def all_accessible_groups + current_user.authorized_groups.in_organization(organization) + end + + def filter_groups(groups) + by_search(groups) + end + + def by_search(groups) + return groups unless params[:search].present? + + groups.search(params[:search]) + end + + def sort(groups) + return default_sort_order(groups) if params[:sort].blank? + + field = params[:sort][:field] + direction = params[:sort][:direction] + groups.reorder(field => direction) # rubocop: disable CodeReuse/ActiveRecord + end + + def default_sort_order(groups) + groups.sort_by_attribute('name_asc') + end + + def authorized? + Ability.allowed?(current_user, :read_organization, organization) + end + end +end diff --git a/app/finders/organizations/organization_users_finder.rb b/app/finders/organizations/organization_users_finder.rb new file mode 100644 index 00000000000..899cda7487c --- /dev/null +++ b/app/finders/organizations/organization_users_finder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Organizations::OrganizationUsersFinder +# +# Used to find Users of an Organization +module Organizations + class OrganizationUsersFinder + # @param organization [Organizations::Organization] + # @param current_user [User] + def initialize(organization:, current_user:) + @organization = organization + @current_user = current_user + end + + def execute + return User.none if organization.nil? || !authorized? + + all_organization_users + end + + private + + attr_reader :organization, :current_user + + def all_organization_users + organization.organization_users + end + + def authorized? + Ability.allowed?(current_user, :read_organization_user, organization) + end + end +end diff --git a/app/finders/packages/npm/packages_for_user_finder.rb b/app/finders/packages/npm/packages_for_user_finder.rb new file mode 100644 index 00000000000..f42e49f9184 --- /dev/null +++ b/app/finders/packages/npm/packages_for_user_finder.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Packages + module Npm + class PackagesForUserFinder < ::Packages::GroupOrProjectPackageFinder + def execute + packages + end + + private + + def packages + base.npm + .with_name(@params[:package_name]) + end + end + end +end diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb index 064698d3c37..684b99f8647 100644 --- a/app/finders/packages/nuget/package_finder.rb +++ b/app/finders/packages/nuget/package_finder.rb @@ -32,8 +32,7 @@ module Packages result .with_nuget_version_or_normalized_version( @params[:package_version], - with_normalized: Feature.enabled?(:nuget_normalized_version, @project_or_group) && - client_forces_normalized_version? + with_normalized: client_forces_normalized_version? ) end diff --git a/app/finders/repositories/changelog_commits_finder.rb b/app/finders/repositories/changelog_commits_finder.rb index b80b8e94e59..863a1186205 100644 --- a/app/finders/repositories/changelog_commits_finder.rb +++ b/app/finders/repositories/changelog_commits_finder.rb @@ -21,7 +21,7 @@ module Repositories COMMITS_PER_PAGE = 1024 # The regex to use for extracting the SHA of a reverted commit. - REVERT_REGEX = /^This reverts commit (?<sha>[0-9a-f]{40})/i.freeze + REVERT_REGEX = /^This reverts commit (?<sha>[0-9a-f]{40})/i # The `project` argument specifies the project for which to obtain the # commits. diff --git a/app/finders/work_items/namespace_work_items_finder.rb b/app/finders/work_items/namespace_work_items_finder.rb index aad99d710b6..da6437e0907 100644 --- a/app/finders/work_items/namespace_work_items_finder.rb +++ b/app/finders/work_items/namespace_work_items_finder.rb @@ -2,19 +2,14 @@ module WorkItems class NamespaceWorkItemsFinder < WorkItemsFinder + FilterNotAvailableError = Class.new(ArgumentError) + def initialize(...) super self.parent_param = namespace end - def execute - items = init_collection - items = by_namespace(items) - - sort(items) - end - override :with_confidentiality_access_check def with_confidentiality_access_check return model_class.all if params.user_can_see_all_issuables? @@ -31,6 +26,12 @@ module WorkItems private + def filter_items(items) + items = super(items) + + by_namespace(items) + end + def by_namespace(items) if namespace.blank? || !Ability.allowed?(current_user, "read_#{namespace.to_ability_name}".to_sym, namespace) return klass.none @@ -39,11 +40,23 @@ module WorkItems items.in_namespaces(namespace) end + override :by_search + def by_search(items) + return items unless search + + raise FilterNotAvailableError, 'Searching is not available for work items at the namespace level yet' + end + def namespace return if params[:namespace_id].blank? params[:namespace_id].is_a?(Namespace) ? params[:namespace_id] : Namespace.find_by_id(params[:namespace_id]) end strong_memoize_attr :namespace + + override :by_project + def by_project(items) + items + end end end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 0c7195c5be3..0b56d6f4a90 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -147,6 +147,12 @@ class GitlabSchema < GraphQL::Schema global_ids.map { |gid| parse_gid(gid, ctx) } end + def unauthorized_field(error) + return error.field.if_unauthorized if error.field.respond_to?(:if_unauthorized) && error.field.if_unauthorized + + super + end + private def max_query_complexity(ctx) diff --git a/app/graphql/mutations/admin/abuse_report_labels/create.rb b/app/graphql/mutations/admin/abuse_report_labels/create.rb new file mode 100644 index 00000000000..6ec96297da4 --- /dev/null +++ b/app/graphql/mutations/admin/abuse_report_labels/create.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Admin + module AbuseReportLabels + class Create < BaseMutation + graphql_name 'AbuseReportLabelCreate' + + field :label, Types::LabelType, null: true, description: 'Label after mutation.' + + argument :title, GraphQL::Types::String, required: true, description: 'Title of the label.' + + argument :color, GraphQL::Types::String, required: false, default_value: Label::DEFAULT_COLOR, + see: { + 'List of color keywords at mozilla.org' => + 'https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords' + }, + description: <<~DESC + The color of the label given in 6-digit hex notation with leading '#' sign + (for example, `#FFAABB`) or one of the CSS color names. + DESC + + def resolve(args) + raise_resource_not_available_error! unless current_user.can?(:admin_all_resources) + + label = ::Admin::AbuseReportLabels::CreateService.new(args).execute + + { + label: label.persisted? ? label : nil, + errors: errors_on_object(label) + } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb index 082c345adf6..7df277641bf 100644 --- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb @@ -6,6 +6,7 @@ module Mutations graphql_name 'ProjectCiCdSettingsUpdate' include FindsProject + include Gitlab::Utils::StrongMemoize authorize :admin_project @@ -37,13 +38,11 @@ module Mutations description: 'CI/CD settings after mutation.' def resolve(full_path:, **args) - project = authorized_find!(full_path) - if args[:job_token_scope_enabled] raise Gitlab::Graphql::Errors::ArgumentError, 'job_token_scope_enabled can only be set to false' end - settings = project.ci_cd_settings + settings = project(full_path).ci_cd_settings settings.update(args) { @@ -51,6 +50,14 @@ module Mutations errors: errors_on_object(settings) } end + + private + + def project(full_path) + strong_memoize_with(:project, full_path) do + authorized_find!(full_path) + end + end end end end diff --git a/app/graphql/mutations/issues/bulk_update.rb b/app/graphql/mutations/issues/bulk_update.rb index 9c9dd3cf2fc..05e83fc82bb 100644 --- a/app/graphql/mutations/issues/bulk_update.rb +++ b/app/graphql/mutations/issues/bulk_update.rb @@ -15,7 +15,7 @@ module Mutations argument :parent_id, ::Types::GlobalIDType[::IssueParent], required: true, description: 'Global ID of the parent to which the bulk update will be scoped. ' \ - 'The parent can be a project **(FREE)** or a group **(PREMIUM)**. ' \ + 'The parent can be a project **(FREE ALL)** or a group **(PREMIUM ALL)**. ' \ 'Example `IssueParentID` are `"gid://gitlab/Project/1"` and `"gid://gitlab/Group/1"`.' argument :ids, [::Types::GlobalIDType[::Issue]], diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index 35deb9e0af8..cd02c96e000 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -34,7 +34,8 @@ module Mutations argument :time_estimate, GraphQL::Types::String, required: false, - description: 'Estimated time to complete the issue, or `0` to remove the current estimate.' + description: 'Estimated time to complete the issue. ' \ + 'Use `null` or `0` to remove the current estimate.' def resolve(project_path:, iid:, **args) issue = authorized_find!(project_path: project_path, iid: iid) @@ -67,8 +68,9 @@ module Mutations args[:remove_label_ids] = parse_label_ids(args[:remove_label_ids]) args[:label_ids] = parse_label_ids(args[:label_ids]) - unless args[:time_estimate].nil? - args[:time_estimate] = Gitlab::TimeTrackingFormatter.parse(args[:time_estimate], keep_zero: true) + if args.key?(:time_estimate) + args[:time_estimate] = + args[:time_estimate].nil? ? 0 : Gitlab::TimeTrackingFormatter.parse(args[:time_estimate], keep_zero: true) end args diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb index 470292df86c..427e07e4a70 100644 --- a/app/graphql/mutations/merge_requests/update.rb +++ b/app/graphql/mutations/merge_requests/update.rb @@ -28,7 +28,8 @@ module Mutations argument :time_estimate, GraphQL::Types::String, required: false, - description: 'Estimated time to complete the merge request, or `0` to remove the current estimate.' + description: 'Estimated time to complete the merge request. ' \ + 'Use `null` or `0` to remove the current estimate.' def resolve(project_path:, iid:, **args) merge_request = authorized_find!(project_path: project_path, iid: iid) @@ -55,8 +56,9 @@ module Mutations private def parse_arguments(args) - unless args[:time_estimate].nil? - args[:time_estimate] = Gitlab::TimeTrackingFormatter.parse(args[:time_estimate], keep_zero: true) + if args.key?(:time_estimate) + args[:time_estimate] = + args[:time_estimate].nil? ? 0 : Gitlab::TimeTrackingFormatter.parse(args[:time_estimate], keep_zero: true) end args.compact diff --git a/app/graphql/mutations/metrics/dashboard/annotations/base.rb b/app/graphql/mutations/metrics/dashboard/annotations/base.rb deleted file mode 100644 index ad52f84378d..00000000000 --- a/app/graphql/mutations/metrics/dashboard/annotations/base.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module Metrics - module Dashboard - module Annotations - class Base < BaseMutation - private - - # This method is defined here in order to be used by `authorized_find!` in the subclasses. - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Metrics::Dashboard::Annotation) - end - end - end - end - end -end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb index 59ddffe3aad..e544b96f679 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb @@ -70,28 +70,8 @@ module Mutations private - def ready?(**args) - raise_resource_not_available_error! if Feature.enabled?(:remove_monitor_metrics) - - # Raise error if both cluster_id and environment_id are present or neither is present - unless args[:cluster_id].present? ^ args[:environment_id].present? - raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR - end - - super(**args) - end - - def annotation_create_params(args) - annotation_source = AnnotationSource.new(object: annotation_source(args)) - - args[annotation_source.type] = annotation_source.object - - args - end - - def annotation_source(args) - annotation_source_id = args[:cluster_id] || args[:environment_id] - authorized_find!(id: annotation_source_id) + def ready?(**_args) + raise_resource_not_available_error! end end end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb index 61fcf8e0b13..d2f2d9a0e32 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb @@ -4,12 +4,12 @@ module Mutations module Metrics module Dashboard module Annotations - class Delete < Base + class Delete < BaseMutation graphql_name 'DeleteAnnotation' authorize :admin_metrics_dashboard_annotation - argument :id, ::Types::GlobalIDType[::Metrics::Dashboard::Annotation], + argument :id, GraphQL::Types::String, required: true, description: 'Global ID of the annotation to delete.' diff --git a/app/graphql/mutations/work_items/linked_items/add.rb b/app/graphql/mutations/work_items/linked_items/add.rb index b346b074e85..e0c17a61205 100644 --- a/app/graphql/mutations/work_items/linked_items/add.rb +++ b/app/graphql/mutations/work_items/linked_items/add.rb @@ -9,6 +9,9 @@ module Mutations argument :link_type, ::Types::WorkItems::RelatedLinkTypeEnum, required: false, description: 'Type of link. Defaults to `RELATED`.' + argument :work_items_ids, [::Types::GlobalIDType[::WorkItem]], + required: true, + description: "Global IDs of the items to link. Maximum number of IDs you can provide: #{MAX_WORK_ITEMS}." private diff --git a/app/graphql/mutations/work_items/linked_items/base.rb b/app/graphql/mutations/work_items/linked_items/base.rb index 1d8d74b02ac..a1d9bced930 100644 --- a/app/graphql/mutations/work_items/linked_items/base.rb +++ b/app/graphql/mutations/work_items/linked_items/base.rb @@ -10,9 +10,6 @@ module Mutations argument :id, ::Types::GlobalIDType[::WorkItem], required: true, description: 'Global ID of the work item.' - argument :work_items_ids, [::Types::GlobalIDType[::WorkItem]], - required: true, - description: "Global IDs of the items to link. Maximum number of IDs you can provide: #{MAX_WORK_ITEMS}." field :work_item, Types::WorkItemType, null: true, description: 'Updated work item.' @@ -26,7 +23,7 @@ module Mutations if args[:work_items_ids].size > MAX_WORK_ITEMS raise Gitlab::Graphql::Errors::ArgumentError, format( - _('No more than %{max_work_items} work items can be linked at the same time.'), + _('No more than %{max_work_items} work items can be modified at the same time.'), max_work_items: MAX_WORK_ITEMS ) end @@ -50,7 +47,7 @@ module Mutations private def update_links(work_item, params) - raise NotImplementedError + raise NotImplementedError, "#{self.class} does not implement #{__method__}" end end end diff --git a/app/graphql/mutations/work_items/linked_items/remove.rb b/app/graphql/mutations/work_items/linked_items/remove.rb new file mode 100644 index 00000000000..078f05d2025 --- /dev/null +++ b/app/graphql/mutations/work_items/linked_items/remove.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + module LinkedItems + class Remove < Base + graphql_name 'WorkItemRemoveLinkedItems' + description 'Remove items linked to the work item.' + + argument :work_items_ids, [::Types::GlobalIDType[::WorkItem]], + required: true, + description: "Global IDs of the items to unlink. Maximum number of IDs you can provide: #{MAX_WORK_ITEMS}." + + private + + def update_links(work_item, params) + gids = params.delete(:work_items_ids) + raise Gitlab::Graphql::Errors::ArgumentError, "workItemsIds cannot be empty" if gids.empty? + + work_item_ids = gids.filter_map { |gid| gid.model_id.to_i } + ::WorkItems::RelatedWorkItemLinks::DestroyService + .new(work_item, current_user, { item_ids: work_item_ids }) + .execute + end + end + end + end +end diff --git a/app/graphql/resolvers/blame_resolver.rb b/app/graphql/resolvers/blame_resolver.rb new file mode 100644 index 00000000000..f8b985e6582 --- /dev/null +++ b/app/graphql/resolvers/blame_resolver.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Resolvers + class BlameResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::Blame::BlameType, null: true + calls_gitaly! + + argument :from_line, GraphQL::Types::Int, + required: false, + default_value: 1, + description: 'Range starting from the line. Cannot be less than 1 or greater than `to_line`.' + argument :to_line, GraphQL::Types::Int, + required: false, + default_value: 1, + description: 'Range ending on the line. Cannot be less than 1 or less than `to_line`.' + + alias_method :blob, :object + + def ready?(**args) + validate_line_params!(args) if feature_enabled? + + super + end + + def resolve(from_line:, to_line:) + return unless feature_enabled? + + authorize! + + Gitlab::Blame.new(blob, blob.repository.commit(blob.commit_id), + range: (from_line..to_line)) + end + + private + + def authorize! + read_code? || raise_resource_not_available_error! + end + + def read_code? + Ability.allowed?(current_user, :read_code, blob.repository.project) + end + + def feature_enabled? + Feature.enabled?(:graphql_git_blame, blob.repository.project) + end + + def validate_line_params!(args) + if args[:from_line] <= 0 || args[:to_line] <= 0 + raise Gitlab::Graphql::Errors::ArgumentError, + '`from_line` and `to_line` must be greater than or equal to 1' + end + + return unless args[:from_line] > args[:to_line] + + raise Gitlab::Graphql::Errors::ArgumentError, + '`to_line` must be greater than or equal to `from_line`' + end + end +end diff --git a/app/graphql/resolvers/ci/all_jobs_resolver.rb b/app/graphql/resolvers/ci/all_jobs_resolver.rb index 5d0193e0e1c..3012a7defa6 100644 --- a/app/graphql/resolvers/ci/all_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/all_jobs_resolver.rb @@ -11,14 +11,24 @@ module Resolvers required: false, description: 'Filter jobs by status.' - def resolve_with_lookahead(statuses: nil) - jobs = ::Ci::JobsFinder.new(current_user: current_user, params: { scope: statuses }).execute + argument :runner_types, [::Types::Ci::RunnerTypeEnum], + required: false, + alpha: { milestone: '16.4' }, + description: 'Filter jobs by runner type if ' \ + 'feature flag `:admin_jobs_filter_runner_type` is enabled.' + + def resolve_with_lookahead(**args) + jobs = ::Ci::JobsFinder.new(current_user: current_user, params: params_data(args)).execute apply_lookahead(jobs) end private + def params_data(args) + { scope: args[:statuses], runner_type: args[:runner_types] } + end + def preloads { previous_stage_jobs_or_needs: [:needs, :pipeline], @@ -32,9 +42,21 @@ module Resolvers browse_artifacts_path: [{ project: { namespace: [:route] } }], play_path: [{ project: { namespace: [:route] } }], web_path: [{ project: { namespace: [:route] } }], - tags: [:tags] + tags: [:tags], + ai_failure_analysis: [{ project: [:project_feature, :namespace] }], + trace: [{ project: [:namespace] }, :job_artifacts_trace] } end + + def nested_preloads + super.merge({ + trace: { + html_summary: [:trace_chunks] + } + }) + end end end end + +Resolvers::Ci::AllJobsResolver.prepend_mod diff --git a/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb b/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb index 3d6e3b3e75d..ecbae1f7b55 100644 --- a/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb +++ b/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb @@ -10,7 +10,8 @@ module Resolvers type Types::Ci::PipelineTriggerType.connection_type, null: false def resolve_with_lookahead - apply_lookahead(object.triggers) + triggers = ::Ci::TriggersFinder.new(current_user, object).execute + apply_lookahead(triggers) end private diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb index 9fe25a4d13d..b005702a71d 100644 --- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb @@ -18,6 +18,8 @@ module Resolvers alias_method :runner, :object def resolve_with_lookahead(statuses: nil) + context[:job_field_authorization] = :read_build # Instruct JobType to perform field-level authorization + jobs = ::Ci::JobsFinder.new(current_user: current_user, runner: runner, params: { scope: statuses }).execute apply_lookahead(jobs) @@ -30,7 +32,7 @@ module Resolvers previous_stage_jobs_or_needs: [:needs, :pipeline], artifacts: [:job_artifacts], pipeline: [:user], - project: [{ project: [:route, { namespace: [:route] }] }], + project: [{ project: [:route, { namespace: [:route] }, :project_feature] }], detailed_status: [ :metadata, { pipeline: [:merge_request] }, @@ -42,9 +44,19 @@ module Resolvers play_path: [{ project: { namespace: [:route] } }], web_path: [{ project: { namespace: [:route] } }], short_sha: [:pipeline], - tags: [:tags] + tags: [:tags], + ai_failure_analysis: [{ project: [:project_feature, :namespace] }], + trace: [{ project: [:namespace] }, :job_artifacts_trace] } end + + def nested_preloads + super.merge({ + trace: { + html_summary: [:trace_chunks] + } + }) + end end end end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 632655d3681..3289f1d0056 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -46,10 +46,16 @@ module Resolvers ::Ci::RunnersFinder .new(current_user: current_user, params: runners_finder_params(args)) .execute) + rescue Gitlab::Access::AccessDeniedError + handle_access_denied_error! end protected + def handle_access_denied_error! + raise_resource_not_available_error! + end + def runners_finder_params(params) # Give preference to paused argument over the deprecated 'active' argument paused = params.fetch(:paused, params[:active] ? !params[:active] : nil) @@ -85,24 +91,6 @@ module Resolvers tag_list: [:tags] }) end - - def nested_preloads - { - created_by: { - creator: { - full_path: [:route], - web_path: [:route], - web_url: [:route] - } - }, - owner_project: { - owner_project: { - full_path: [:route, { namespace: [:route] }], - web_url: [:route, { namespace: [:route] }] - } - } - } - end end end end diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb index a2d3af9c664..cccf77452e3 100644 --- a/app/graphql/resolvers/ci/test_suite_resolver.rb +++ b/app/graphql/resolvers/ci/test_suite_resolver.rb @@ -27,7 +27,7 @@ module Resolvers private def load_test_suite_data(builds) - suite = builds.sum do |build| + suite = builds.sum(Gitlab::Ci::Reports::TestSuite.new) do |build| test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) test_report.get_suite(build.test_suite_name) end diff --git a/app/graphql/resolvers/codequality_reports_comparer_resolver.rb b/app/graphql/resolvers/codequality_reports_comparer_resolver.rb new file mode 100644 index 00000000000..1c034887c0d --- /dev/null +++ b/app/graphql/resolvers/codequality_reports_comparer_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class CodequalityReportsComparerResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type ::Types::Security::CodequalityReportsComparerType, null: true + + authorize :read_build + + def resolve + return unless Feature.enabled?(:sast_reports_in_inline_diff, object.project) + + authorize!(object.actual_head_pipeline) + + object.compare_codequality_reports + end + end +end diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb index b967460c7ff..946f10a10fa 100644 --- a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb +++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb @@ -17,8 +17,6 @@ module Resolvers alias_method :dashboard, :object def resolve(**_args) - return if Feature.enabled?(:remove_monitor_metrics) - [] end end diff --git a/app/graphql/resolvers/namespaces/work_item_resolver.rb b/app/graphql/resolvers/namespaces/work_item_resolver.rb new file mode 100644 index 00000000000..d49ef3f53af --- /dev/null +++ b/app/graphql/resolvers/namespaces/work_item_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Namespaces + class WorkItemResolver < Resolvers::BaseResolver + type ::Types::WorkItemType, null: true + + argument :iid, GraphQL::Types::String, required: true, description: 'IID of the work item.' + + def ready?(**args) + return false if Feature.disabled?(:namespace_level_work_items, resource_parent) + + super + end + + def resolve(iid:) + ::WorkItem.find_by_namespace_id_and_iid(resource_parent.id, iid) + end + + private + + def resource_parent + # The namespace could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the namespace to query for work items, so + # make sure it's loaded and not `nil` before continuing. + object.respond_to?(:sync) ? object.sync : object + end + strong_memoize_attr :resource_parent + end + end +end diff --git a/app/graphql/resolvers/namespaces/work_items_resolver.rb b/app/graphql/resolvers/namespaces/work_items_resolver.rb index 54bb8392071..6985a7a898a 100644 --- a/app/graphql/resolvers/namespaces/work_items_resolver.rb +++ b/app/graphql/resolvers/namespaces/work_items_resolver.rb @@ -2,33 +2,31 @@ module Resolvers module Namespaces - class WorkItemsResolver < BaseResolver - prepend ::WorkItems::LookAheadPreloads + # rubocop:disable Graphql/ResolverType (inherited from Resolvers::WorkItemsResolver) + class WorkItemsResolver < ::Resolvers::WorkItemsResolver + def ready?(**args) + return false if Feature.disabled?(:namespace_level_work_items, resource_parent) - type Types::WorkItemType.connection_type, null: true - - def resolve_with_lookahead(**args) - return unless Feature.enabled?(:namespace_level_work_items, resource_parent) - return WorkItem.none if resource_parent.nil? - - finder = ::WorkItems::NamespaceWorkItemsFinder.new(current_user, args.merge( - namespace_id: resource_parent - )) + super + end - Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all do |q| - apply_lookahead(q) - end + override :resolve_with_lookahead + def resolve_with_lookahead(...) + super + rescue ::WorkItems::NamespaceWorkItemsFinder::FilterNotAvailableError => e + raise Gitlab::Graphql::Errors::ArgumentError, e.message end private - def resource_parent - # The project could have been loaded in batch by `BatchLoader`. - # At this point we need the `id` of the project to query for work items, so - # make sure it's loaded and not `nil` before continuing. - object.respond_to?(:sync) ? object.sync : object + override :finder + def finder(args) + ::WorkItems::NamespaceWorkItemsFinder.new( + current_user, + args.merge(namespace_id: resource_parent) + ) end - strong_memoize_attr :resource_parent end + # rubocop:enable Graphql/ResolverType end end diff --git a/app/graphql/resolvers/organizations/groups_resolver.rb b/app/graphql/resolvers/organizations/groups_resolver.rb new file mode 100644 index 00000000000..0f50713b9b4 --- /dev/null +++ b/app/graphql/resolvers/organizations/groups_resolver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class GroupsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include ResolvesGroups + + type Types::GroupType.connection_type, null: true + + authorize :read_group + + argument :search, + GraphQL::Types::String, + required: false, + description: 'Search query for group name or full path.', + alpha: { milestone: '16.4' } + + argument :sort, + Types::Organizations::GroupSortEnum, + description: 'Criteria to sort organization groups by.', + required: false, + default_value: { field: 'name', direction: :asc }, + alpha: { milestone: '16.4' } + + private + + def resolve_groups(**args) + return Group.none if Feature.disabled?(:resolve_organization_groups, context[:current_user]) + + ::Organizations::GroupsFinder + .new(organization: object, current_user: context[:current_user], params: args) + .execute + end + end + end +end diff --git a/app/graphql/resolvers/organizations/organization_resolver.rb b/app/graphql/resolvers/organizations/organization_resolver.rb new file mode 100644 index 00000000000..9194d9a32c5 --- /dev/null +++ b/app/graphql/resolvers/organizations/organization_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class OrganizationResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_organization + + type Types::Organizations::OrganizationType, null: true + + argument :id, + Types::GlobalIDType[::Organizations::Organization], + required: true, + description: 'ID of the organization.' + + def resolve(id:) + authorized_find!(id: id) + end + end + end +end diff --git a/app/graphql/resolvers/organizations/organization_users_resolver.rb b/app/graphql/resolvers/organizations/organization_users_resolver.rb new file mode 100644 index 00000000000..b4790da6c0a --- /dev/null +++ b/app/graphql/resolvers/organizations/organization_users_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class OrganizationUsersResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include LooksAhead + + type Types::Organizations::OrganizationUserType.connection_type, null: true + + authorize :read_organization_user + + alias_method :organization, :object + + def resolve_with_lookahead + authorize!(object) + + apply_lookahead(organization_users) + end + + private + + def organization_users + ::Organizations::OrganizationUsersFinder + .new(organization: organization, current_user: context[:current_user]) + .execute + end + + def preloads + { + user: [:user] + } + end + end + end +end diff --git a/app/graphql/resolvers/work_items/linked_items_resolver.rb b/app/graphql/resolvers/work_items/linked_items_resolver.rb index 9c71cd7c0c9..35a6974163a 100644 --- a/app/graphql/resolvers/work_items/linked_items_resolver.rb +++ b/app/graphql/resolvers/work_items/linked_items_resolver.rb @@ -5,10 +5,16 @@ module Resolvers class LinkedItemsResolver < BaseResolver alias_method :linked_items_widget, :object + argument :filter, Types::WorkItems::RelatedLinkTypeEnum, + required: false, + description: "Filter by link type. " \ + "Supported values: #{Types::WorkItems::RelatedLinkTypeEnum.values.keys.to_sentence}. " \ + 'Returns all types if omitted.' + type Types::WorkItems::LinkedItemType.connection_type, null: true - def resolve - related_work_items.map do |related_work_item| + def resolve(filter: nil) + related_work_items(filter).map do |related_work_item| { link_id: related_work_item.issue_link_id, link_type: related_work_item.issue_link_type, @@ -21,10 +27,10 @@ module Resolvers private - def related_work_items + def related_work_items(type) return [] unless work_item.project.linked_work_items_feature_flag_enabled? - work_item.related_issues(current_user, preload: { project: [:project_feature, :group] }) + work_item.linked_work_items(current_user, preload: { project: [:project_feature, :group] }, link_type: type) end def work_item diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index d4f73361e05..995f54f35d8 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -21,13 +21,18 @@ module Resolvers def resolve_with_lookahead(**args) return WorkItem.none if resource_parent.nil? - finder = ::WorkItems::WorkItemsFinder.new(current_user, prepare_finder_params(args)) - - Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all { |q| apply_lookahead(q) } + Gitlab::Graphql::Loaders::IssuableLoader.new( + resource_parent, + finder(prepare_finder_params(args)) + ).batching_find_all { |q| apply_lookahead(q) } end private + def finder(args) + ::WorkItems::WorkItemsFinder.new(current_user, args) + end + def prepare_finder_params(args) params = super(args) params[:iids] ||= [params.delete(:iid)].compact if params[:iid] diff --git a/app/graphql/types/blame/blame_type.rb b/app/graphql/types/blame/blame_type.rb new file mode 100644 index 00000000000..7e7ba14988d --- /dev/null +++ b/app/graphql/types/blame/blame_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Blame + # rubocop: disable Graphql/AuthorizeTypes + class BlameType < BaseObject + # This is presented through `Repository` that has its own authorization + graphql_name 'Blame' + + present_using Gitlab::BlamePresenter + + field :first_line, GraphQL::Types::String, null: true, + description: 'First line of Git Blame for given range.', calls_gitaly: true + field :groups, [Types::Blame::GroupsType], null: true, + description: 'Git Blame grouped by contiguous lines for commit.', calls_gitaly: true, + method: :groups_commit_data + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/blame/commit_data_type.rb b/app/graphql/types/blame/commit_data_type.rb new file mode 100644 index 00000000000..faac34b06f4 --- /dev/null +++ b/app/graphql/types/blame/commit_data_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Blame + # rubocop: disable Graphql/AuthorizeTypes + class CommitDataType < BaseObject + # This is presented through `Repository` that has its own authorization + graphql_name 'CommitData' + + field :age_map_class, GraphQL::Types::String, null: false, description: 'CSS class for age of commit.' + field :author_avatar, GraphQL::Types::String, null: false, description: 'Link to author avatar.' + field :commit_author_link, GraphQL::Types::String, null: false, description: 'Link to the commit author.' + field :commit_link, GraphQL::Types::String, null: false, description: 'Link to the commit.' + field :project_blame_link, GraphQL::Types::String, + null: true, description: 'Link to blame prior to the change.' + field :time_ago_tooltip, GraphQL::Types::String, null: false, description: 'Time of commit.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/blame/groups_type.rb b/app/graphql/types/blame/groups_type.rb new file mode 100644 index 00000000000..754f3f95364 --- /dev/null +++ b/app/graphql/types/blame/groups_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Blame + # rubocop: disable Graphql/AuthorizeTypes + class GroupsType < BaseObject + # This is presented through `Repository` that has its own authorization + graphql_name 'Groups' + + field :commit, Types::CommitType, null: false, description: 'Commit responsible for specified group.' + field :commit_data, Types::Blame::CommitDataType, null: true, + description: 'HTML data derived from commit needed to present blame.', calls_gitaly: true + field :lineno, GraphQL::Types::Int, null: false, description: 'Starting line number for the commit group.' + field :lines, [GraphQL::Types::String], null: false, description: 'Array of lines added for the commit group.' + field :span, GraphQL::Types::Int, null: false, + description: 'Number of contiguous lines which the blame spans for the commit group.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index 45ecbf5c084..8a49c5a6a95 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -29,6 +29,7 @@ module Types null: true, description: 'Whether merge pipelines are enabled.', method: :merge_pipelines_enabled? + # TODO(Issue 422295): this is EE only and should be moved to the EE file field :merge_trains_enabled, GraphQL::Types::Boolean, null: true, @@ -41,3 +42,5 @@ module Types end end end + +Types::Ci::CiCdSettingType.prepend_mod_with('Types::Ci::CiCdSettingType') diff --git a/app/graphql/types/ci/job_base_field.rb b/app/graphql/types/ci/job_base_field.rb new file mode 100644 index 00000000000..979f1748494 --- /dev/null +++ b/app/graphql/types/ci/job_base_field.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Types + module Ci + # JobBaseField ensures that only allow-listed fields can be returned without a permission check. + # All other fields go through a permissions check based on the :job_field_authorization value passed in the context. + # rubocop: disable Graphql/AuthorizeTypes + class JobBaseField < ::Types::BaseField + PUBLIC_FIELDS = %i[allow_failure duration id kind status created_at finished_at queued_at queued_duration + updated_at runner].freeze + + attr_accessor :if_unauthorized + + def initialize(**kwargs, &block) + @if_unauthorized = kwargs.delete(:if_unauthorized) + + super + end + + def authorized?(object, args, ctx) + current_user = ctx[:current_user] + permission = ctx[:job_field_authorization] + + if permission.nil? || + PUBLIC_FIELDS.include?(ctx[:current_field].original_name) || + current_user.can?(permission, object) + return super + end + + false + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/job_failure_reason_enum.rb b/app/graphql/types/ci/job_failure_reason_enum.rb new file mode 100644 index 00000000000..3b9c13536d6 --- /dev/null +++ b/app/graphql/types/ci/job_failure_reason_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + class JobFailureReasonEnum < BaseEnum + graphql_name 'CiJobFailureReason' + + ::Enums::Ci::CommitStatus.failure_reasons.each_key do |reason| + value reason.to_s.upcase, + description: "A job that failed due to #{reason.to_s.tr('_', ' ')}.", + value: reason + end + end + end +end diff --git a/app/graphql/types/ci/job_trace_type.rb b/app/graphql/types/ci/job_trace_type.rb index a68e26106b8..405c640115d 100644 --- a/app/graphql/types/ci/job_trace_type.rb +++ b/app/graphql/types/ci/job_trace_type.rb @@ -5,13 +5,24 @@ module Types module Ci class JobTraceType < BaseObject graphql_name 'CiJobTrace' + MAX_SIZE_KB = 16 + MAX_SIZE_B = MAX_SIZE_KB * 1024 field :html_summary, GraphQL::Types::String, null: false, - alpha: { milestone: '15.11' }, # As we want the option to change from 10 if needed - description: "HTML summary containing the last 10 lines of the trace." + alpha: { milestone: '15.11' }, + description: 'HTML summary that contains the tail lines of the trace. ' \ + "Returns at most #{MAX_SIZE_KB}KB of raw bytes from the trace. " \ + 'The returned string might start with an unexpected invalid UTF-8 code point due to truncation.' do + argument :last_lines, Integer, + required: false, default_value: 10, + description: 'Number of tail lines to return, up to a maximum of 100 lines.' + end - def html_summary - object.html(last_lines: 10).html_safe + def html_summary(last_lines:) + object.html( + last_lines: last_lines.clamp(1, 100), + max_size: Feature.enabled?(:graphql_job_trace_html_summary_max_size) ? MAX_SIZE_B : nil + ).html_safe end end end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 22eb32993c5..5956d372fe4 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -8,6 +8,7 @@ module Types graphql_name 'CiJob' present_using ::Ci::BuildPresenter + field_class Types::Ci::JobBaseField connection_type_class Types::LimitedCountableConnectionType @@ -87,10 +88,14 @@ module Types description: 'Play path of the job.' field :playable, GraphQL::Types::Boolean, null: false, method: :playable?, description: 'Indicates the job can be played.' + field :previous_stage_jobs, Types::Ci::JobType.connection_type, + null: true, + description: 'Jobs from the previous stage.' field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, ' \ - 'which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' + 'which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.', + deprecated: { reason: 'Replaced by previousStageJobs and needs fields', milestone: '16.4' } field :ref_name, GraphQL::Types::String, null: true, description: 'Ref name of the job.' field :ref_path, GraphQL::Types::String, null: true, @@ -104,7 +109,8 @@ module Types field :scheduling_type, GraphQL::Types::String, null: true, description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.' field :short_sha, type: GraphQL::Types::String, null: false, - description: 'Short SHA1 ID of the commit.' + description: 'Short SHA1 ID of the commit.', + if_unauthorized: 'Unauthorized' field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?, description: 'Indicates the job is stuck.' field :trace, Types::Ci::JobTraceType, null: true, @@ -174,17 +180,17 @@ module Types end def previous_stage_jobs - BatchLoader::GraphQL.for([object.pipeline, object.stage_idx - 1]).batch(default_value: []) do |tuples, loader| - tuples.group_by(&:first).each do |pipeline, keys| - positions = keys.map(&:second) + BatchLoader::GraphQL.for([object.pipeline_id, object.stage_idx - 1]).batch(default_value: []) do |tuples, loader| + pipeline_ids = tuples.map(&:first).uniq + stage_idxs = tuples.map(&:second).uniq - stages = pipeline.stages.by_position(positions) + # This query can fetch unneeded jobs when querying for more than one pipeline. + # It was decided that fetching and discarding the jobs is preferable to making a more complex query. + jobs = CommitStatus.in_pipelines(pipeline_ids).for_stage(stage_idxs).latest + grouped_jobs = jobs.group_by { |job| [job.pipeline_id, job.stage_idx] } - stages.each do |stage| - # Without `.to_a`, the memoization will only preserve the activerecord relation object. And when there is - # a call, the SQL query will be executed again. - loader.call([pipeline, stage.position], stage.latest_statuses.to_a) - end + tuples.each do |tuple| + loader.call(tuple, grouped_jobs.fetch(tuple, [])) end end end diff --git a/app/graphql/types/ci/pipeline_schedule_type.rb b/app/graphql/types/ci/pipeline_schedule_type.rb index 71a1f28ea38..e2e3bd8cfbd 100644 --- a/app/graphql/types/ci/pipeline_schedule_type.rb +++ b/app/graphql/types/ci/pipeline_schedule_type.rb @@ -68,7 +68,10 @@ module Types null: false, description: 'Timestamp of when the pipeline schedule was last updated.' def ref_path - ::Gitlab::Routing.url_helpers.project_commits_path(object.project, object.ref_for_display) + ref_for_display = object.ref_for_display + return unless ref_for_display + + ::Gitlab::Routing.url_helpers.project_commits_path(object.project, ref_for_display) end def edit_path diff --git a/app/graphql/types/ci/runner_job_execution_status_enum.rb b/app/graphql/types/ci/runner_job_execution_status_enum.rb index 686ea085199..0db79c56a93 100644 --- a/app/graphql/types/ci/runner_job_execution_status_enum.rb +++ b/app/graphql/types/ci/runner_job_execution_status_enum.rb @@ -8,12 +8,12 @@ module Types value 'IDLE', description: "Runner is idle.", value: :idle, - deprecated: { milestone: '15.7', reason: :alpha } + alpha: { milestone: '15.7' } value 'RUNNING', description: 'Runner is executing jobs.', value: :running, - deprecated: { milestone: '15.7', reason: :alpha } + alpha: { milestone: '15.7' } end end end diff --git a/app/graphql/types/ci/runner_membership_filter_enum.rb b/app/graphql/types/ci/runner_membership_filter_enum.rb index d59a68b427b..eb691166944 100644 --- a/app/graphql/types/ci/runner_membership_filter_enum.rb +++ b/app/graphql/types/ci/runner_membership_filter_enum.rb @@ -21,7 +21,7 @@ module Types "Include all runners. This list includes runners for all projects in the group " \ "and subgroups, as well as for the parent groups and instance.", value: :all_available, - deprecated: { milestone: '15.5', reason: :alpha } + alpha: { milestone: '15.5' } end end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 258cf1539fb..74e7f256b44 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -268,6 +268,12 @@ module Types alpha: { milestone: '16.3' }, resolver: ::Resolvers::Namespaces::WorkItemsResolver + field :work_item, Types::WorkItemType, + resolver: Resolvers::Namespaces::WorkItemResolver, + alpha: { milestone: '16.4' }, + description: 'Find a work item by IID directly associated with the group. Returns `null` if the ' \ + '`namespace_level_work_items` feature flag is disabled.' + field :autocomplete_users, null: true, resolver: Resolvers::AutocompleteUsersResolver, diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 4b7118d75a5..1c8a654a841 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -171,6 +171,8 @@ module Types field :escalation_status, Types::IncidentManagement::EscalationStatusEnum, null: true, description: 'Escalation status of the issue.' + field :external_author, GraphQL::Types::String, null: true, description: 'Email address of non-GitLab user reporting the issue. For guests, the email address is obfuscated.' + markdown_field :title_html, null: true markdown_field :description_html, null: true diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 4848ee30950..d4fac949c93 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -18,6 +18,9 @@ module Types description: 'Description of the label (Markdown rendered as HTML for caching).' field :id, GraphQL::Types::ID, null: false, description: 'Label ID.' + field :lock_on_merge, GraphQL::Types::Boolean, null: false, + description: 'Indicates this label is locked for merge requests ' \ + 'that have been merged.' field :text_color, GraphQL::Types::String, null: false, description: 'Text color of the label.' field :title, GraphQL::Types::String, null: false, diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 3fe8a05b311..4fd2b245de9 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -185,6 +185,8 @@ module Types description: 'Users from whom a review has been requested.' field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, description: 'Indicates if the currently logged in user is subscribed to this merge request.' + field :supports_lock_on_merge, GraphQL::Types::Boolean, null: false, method: :supports_lock_on_merge?, + description: 'Indicates if the merge request supports locked labels.' field :task_completion_status, Types::TaskCompletionStatus, null: false, description: Types::TaskCompletionStatus.description field :time_estimate, GraphQL::Types::Int, null: false, @@ -231,6 +233,14 @@ module Types field :prepared_at, Types::TimeType, null: true, description: 'Timestamp of when the merge request was prepared.' + field :codequality_reports_comparer, + type: ::Types::Security::CodequalityReportsComparerType, + null: true, + alpha: { milestone: '16.4' }, + description: 'Code quality reports comparison reported on the merge request. Returns `null` ' \ + 'if `sast_reports_in_inline_diff` feature flag is disabled.', + resolver: ::Resolvers::CodequalityReportsComparerResolver + markdown_field :title_html, null: true markdown_field :description_html, null: true @@ -297,7 +307,7 @@ module Types end def security_auto_fix - object.author == User.security_bot + object.author == ::Users::Internal.security_bot end def merge_user diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 957fd10690f..445f26e2fcf 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -181,6 +181,7 @@ module Types mount_mutation Mutations::WorkItems::Export, alpha: { milestone: '15.10' } mount_mutation Mutations::WorkItems::Convert, alpha: { milestone: '15.11' } mount_mutation Mutations::WorkItems::LinkedItems::Add, alpha: { milestone: '16.3' } + mount_mutation Mutations::WorkItems::LinkedItems::Remove, alpha: { milestone: '16.3' } mount_mutation Mutations::SavedReplies::Create mount_mutation Mutations::SavedReplies::Update mount_mutation Mutations::Pages::MarkOnboardingComplete @@ -188,6 +189,7 @@ module Types mount_mutation Mutations::Uploads::Delete mount_mutation Mutations::Users::SetNamespaceCommitEmail mount_mutation Mutations::WorkItems::Subscribe, alpha: { milestone: '16.3' } + mount_mutation Mutations::Admin::AbuseReportLabels::Create, alpha: { milestone: '16.4' } end end diff --git a/app/graphql/types/organizations/group_sort_enum.rb b/app/graphql/types/organizations/group_sort_enum.rb new file mode 100644 index 00000000000..8fb2f553539 --- /dev/null +++ b/app/graphql/types/organizations/group_sort_enum.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module Organizations + class GroupSortEnum < BaseEnum + graphql_name 'OrganizationGroupSort' + description 'Values for sorting organization groups' + + sortable_fields = ['ID', 'Name', 'Path', 'Updated at', 'Created at'] + + sortable_fields.each do |field| + value "#{field.upcase.tr(' ', '_')}_ASC", + value: { field: field.downcase.tr(' ', '_'), direction: :asc }, + description: "#{field} in ascending order.", + alpha: { milestone: '16.4' } + + value "#{field.upcase.tr(' ', '_')}_DESC", + value: { field: field.downcase.tr(' ', '_'), direction: :desc }, + description: "#{field} in descending order.", + alpha: { milestone: '16.4' } + end + end + end +end diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb new file mode 100644 index 00000000000..cae0ef2232e --- /dev/null +++ b/app/graphql/types/organizations/organization_type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Organizations + class OrganizationType < BaseObject + graphql_name 'Organization' + + authorize :read_organization + + field :groups, + Types::GroupType.connection_type, + null: false, + description: 'Groups within this organization that the user has access to.', + alpha: { milestone: '16.4' }, + resolver: ::Resolvers::Organizations::GroupsResolver + field :id, + GraphQL::Types::ID, + null: false, + description: 'ID of the organization.', + alpha: { milestone: '16.4' } + field :name, + GraphQL::Types::String, + null: false, + description: 'Name of the organization.', + alpha: { milestone: '16.4' } + field :organization_users, + null: false, + description: 'Users with access to the organization.', + alpha: { milestone: '16.4' }, + resolver: ::Resolvers::Organizations::OrganizationUsersResolver + field :path, + GraphQL::Types::String, + null: false, + description: 'Path of the organization.', + alpha: { milestone: '16.4' } + end + end +end diff --git a/app/graphql/types/organizations/organization_user_type.rb b/app/graphql/types/organizations/organization_user_type.rb new file mode 100644 index 00000000000..41924586f38 --- /dev/null +++ b/app/graphql/types/organizations/organization_user_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Organizations + class OrganizationUserType < BaseObject + graphql_name 'OrganizationUser' + description 'A user with access to the organization.' + + include UsersHelper + + authorize :read_organization_user + + alias_method :organization_user, :object + + field :badges, + [GraphQL::Types::String], + null: true, + description: 'Badges describing the user within the organization.', + alpha: { milestone: '16.4' } + field :id, + GraphQL::Types::ID, + null: false, + description: 'ID of the organization user.', + alpha: { milestone: '16.4' } + field :user, + ::Types::UserType, + null: false, + description: 'User that is associated with the organization.', + alpha: { milestone: '16.4' } + + def badges + user_badges_in_admin_section(organization_user.user).pluck(:text) # rubocop:disable CodeReuse/ActiveRecord + end + end + end +end diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb index d9946fc4ea6..40b708a7885 100644 --- a/app/graphql/types/permission_types/work_item.rb +++ b/app/graphql/types/permission_types/work_item.rb @@ -8,7 +8,7 @@ module Types abilities :read_work_item, :update_work_item, :delete_work_item, :admin_work_item, :admin_parent_link, :set_work_item_metadata, - :create_note + :create_note, :admin_work_item_link end end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 38b8973034d..d02b3e4136f 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -96,6 +96,12 @@ module Types required: true, description: 'Global ID of the note.' end + field :organization, + Types::Organizations::OrganizationType, + null: true, + resolver: Resolvers::Organizations::OrganizationResolver, + description: "Find an organization.", + alpha: { milestone: '16.4' } 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 @@ -122,7 +128,8 @@ module Types field :runners, Types::Ci::RunnerType.connection_type, null: true, resolver: Resolvers::Ci::RunnersResolver, - description: "Find runners visible to the current user." + description: "Get all runners in the GitLab instance (project and shared). " \ + "Access is restricted to users with administrator access." field :snippets, Types::SnippetType.connection_type, null: true, diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index c5d6e26e94b..3959118631f 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -86,6 +86,9 @@ module Types field :blame_path, GraphQL::Types::String, null: true, description: 'Web path to blob blame page.' + field :blame, Types::Blame::BlameType, null: true, + description: 'Blob blame. Available only when feature flag `graphql_git_blame` is enabled.', alpha: { milestone: '16.3' }, resolver: Resolvers::BlameResolver + field :history_path, GraphQL::Types::String, null: true, description: 'Web path to blob history page.' diff --git a/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb b/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb new file mode 100644 index 00000000000..fb7d722069f --- /dev/null +++ b/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Types + module Security + module CodequalityReportsComparer + # rubocop: disable Graphql/AuthorizeTypes (The resolver authorizes the request) + class DegradationType < BaseObject + graphql_name 'CodequalityReportsComparerReportDegradation' + + description 'Represents a degradation on the compared codequality report.' + + field :description, GraphQL::Types::String, + null: false, + description: 'Description of the code quality degradation.' + + field :fingerprint, GraphQL::Types::String, + null: false, + description: 'Unique fingerprint to identify the code quality degradation. For example, an MD5 hash.' + + field :severity, Types::Ci::CodeQualityDegradationSeverityEnum, + null: false, + description: + "Severity of the code quality degradation " \ + "(#{::Gitlab::Ci::Reports::CodequalityReports::SEVERITY_PRIORITIES.keys.map(&:upcase).join(', ')})." + + field :file_path, GraphQL::Types::String, + null: false, + description: 'Relative path to the file containing the code quality degradation.' + + field :line, GraphQL::Types::Int, + null: false, + description: 'Line on which the code quality degradation occurred.' + + field :web_url, GraphQL::Types::String, + null: true, + description: 'URL to the file along with line number.' + + field :engine_name, GraphQL::Types::String, + null: false, + description: 'Code quality plugin that reported the degradation.' + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/security/codequality_reports_comparer/report_type.rb b/app/graphql/types/security/codequality_reports_comparer/report_type.rb new file mode 100644 index 00000000000..8a41160141a --- /dev/null +++ b/app/graphql/types/security/codequality_reports_comparer/report_type.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Types + module Security + module CodequalityReportsComparer + # rubocop: disable Graphql/AuthorizeTypes (Parent node applies authorization) + class ReportType < BaseObject + graphql_name 'CodequalityReportsComparerReport' + + description 'Represents compared code quality report.' + + field :status, + type: CodequalityReportsComparer::StatusEnum, + null: false, + description: 'Status of report.' + + field :new_errors, + type: [CodequalityReportsComparer::DegradationType], + null: false, + description: 'New code quality degradations.' + + field :resolved_errors, + type: [CodequalityReportsComparer::DegradationType], + null: true, + description: 'Resolved code quality degradations.' + + field :existing_errors, + type: [CodequalityReportsComparer::DegradationType], + null: true, + description: 'All code quality degradations.' + + field :summary, + type: CodequalityReportsComparer::SummaryType, + null: false, + description: 'Codequality report summary.' + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/security/codequality_reports_comparer/status_enum.rb b/app/graphql/types/security/codequality_reports_comparer/status_enum.rb new file mode 100644 index 00000000000..9cab2664db8 --- /dev/null +++ b/app/graphql/types/security/codequality_reports_comparer/status_enum.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Security + module CodequalityReportsComparer + class StatusEnum < BaseEnum + graphql_name 'CodequalityReportsComparerReportStatus' + description 'Report comparison status' + + value 'SUCCESS', value: 'success', description: 'Report successfully generated.' + value 'FAILED', value: 'failed', description: 'Report failed to generate.' + value 'NOT_FOUND', value: 'not_found', description: 'Head report or base report not found.' + end + end + end +end diff --git a/app/graphql/types/security/codequality_reports_comparer/summary_type.rb b/app/graphql/types/security/codequality_reports_comparer/summary_type.rb new file mode 100644 index 00000000000..cd4a594c193 --- /dev/null +++ b/app/graphql/types/security/codequality_reports_comparer/summary_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module Security + module CodequalityReportsComparer + # rubocop: disable Graphql/AuthorizeTypes (The resolver authorizes the request) + class SummaryType < BaseObject + graphql_name 'CodequalityReportsComparerReportSummary' + + description 'Represents a summary of the compared codequality report.' + + field :total, + type: GraphQL::Types::Int, + null: true, + description: 'Total count of code quality degradations.' + + field :resolved, + type: GraphQL::Types::Int, + null: true, + description: 'Count of resolved code quality degradations.' + + field :errored, + type: GraphQL::Types::Int, + null: true, + description: 'Count of code quality errors.' + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/security/codequality_reports_comparer_type.rb b/app/graphql/types/security/codequality_reports_comparer_type.rb new file mode 100644 index 00000000000..3b0f790af81 --- /dev/null +++ b/app/graphql/types/security/codequality_reports_comparer_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Security + # rubocop: disable Graphql/AuthorizeTypes (The resolver authorizes the request) + class CodequalityReportsComparerType < BaseObject + graphql_name 'CodequalityReportsComparer' + + description 'Represents reports comparison for code quality.' + + field :report, + type: CodequalityReportsComparer::ReportType, + null: true, + hash_key: 'data', + description: 'Compared codequality report.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index a6f5b7e7456..170f28103eb 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -3,7 +3,7 @@ module Types class UserType < ::Types::BaseObject graphql_name 'UserCore' - description 'Core represention of a GitLab user.' + description 'Core representation of a GitLab user.' implements ::Types::UserInterface authorize :read_user diff --git a/app/graphql/types/work_items/award_emoji_update_action_enum.rb b/app/graphql/types/work_items/award_emoji_update_action_enum.rb index 5b2512a215f..e068e231af3 100644 --- a/app/graphql/types/work_items/award_emoji_update_action_enum.rb +++ b/app/graphql/types/work_items/award_emoji_update_action_enum.rb @@ -8,6 +8,7 @@ module Types value 'ADD', 'Adds the emoji.', value: :add value 'REMOVE', 'Removes the emoji.', value: :remove + value 'TOGGLE', 'Toggles the status of the emoji.', value: :toggle end end end diff --git a/app/graphql/types/work_items/widgets/linked_items_type.rb b/app/graphql/types/work_items/widgets/linked_items_type.rb index fa51742b9c1..2611c2456c5 100644 --- a/app/graphql/types/work_items/widgets/linked_items_type.rb +++ b/app/graphql/types/work_items/widgets/linked_items_type.rb @@ -13,7 +13,7 @@ module Types field :linked_items, Types::WorkItems::LinkedItemType.connection_type, null: true, complexity: 5, alpha: { milestone: '16.3' }, - description: 'Linked items for the work item. Returns `null`' \ + description: 'Linked items for the work item. Returns `null` ' \ 'if `linked_work_items` feature flag is disabled.', resolver: Resolvers::WorkItems::LinkedItemsResolver end diff --git a/app/helpers/admin/abuse_reports_helper.rb b/app/helpers/admin/abuse_reports_helper.rb index 275bed406f1..015d4513091 100644 --- a/app/helpers/admin/abuse_reports_helper.rb +++ b/app/helpers/admin/abuse_reports_helper.rb @@ -18,7 +18,8 @@ module Admin def abuse_report_data(report) { - abuse_report_data: Admin::AbuseReportDetailsSerializer.new.represent(report).to_json + abuse_report_data: Admin::AbuseReportDetailsSerializer.new.represent(report).to_json, + abuse_reports_list_path: admin_abuse_reports_path } end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2bf239979f7..e3a630024d9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -315,7 +315,8 @@ module ApplicationHelper class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards) class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards) class_names << 'with-performance-bar' if performance_bar_enabled? - class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar + class_names << 'with-header' if !show_super_sidebar? || !current_user + class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar_padding class_names << system_message_class class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com? && !show_super_sidebar? @@ -485,6 +486,15 @@ module ApplicationHelper end end + def controller_full_path + action = case controller.action_name + when 'create' then 'new' + when 'update' then 'edit' + else controller.action_name + end + "#{controller.controller_path}/#{action}" + end + private def browser_id diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index a45425474b5..ef91915ce38 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -238,6 +238,7 @@ module ApplicationSettingsHelper :container_expiration_policies_enable_historic_entries, :container_registry_expiration_policies_caching, :container_registry_token_expire_delay, + :decompress_archive_file_timeout, :default_artifacts_expire_in, :default_branch_name, :default_branch_protection, @@ -306,7 +307,6 @@ module ApplicationSettingsHelper :housekeeping_optimize_repository_period, :html_emails_enabled, :import_sources, - :in_product_marketing_emails_enabled, :inactive_projects_delete_after_months, :inactive_projects_min_size_mb, :inactive_projects_send_warning_email_after_months, @@ -413,6 +413,7 @@ module ApplicationSettingsHelper :throttle_protected_paths_period_in_seconds, :throttle_protected_paths_requests_per_period, :protected_paths_raw, + :protected_paths_for_get_request_raw, :time_tracking_limit_to_hours, :two_factor_grace_period, :update_runner_versions_enabled, @@ -436,6 +437,7 @@ module ApplicationSettingsHelper :mailgun_events_enabled, :snowplow_collector_hostname, :snowplow_cookie_domain, + :snowplow_database_collector_hostname, :snowplow_enabled, :snowplow_app_id, :push_event_hooks_limit, @@ -478,12 +480,14 @@ module ApplicationSettingsHelper :sentry_dsn, :sentry_clientside_dsn, :sentry_environment, + :sentry_clientside_traces_sample_rate, :sidekiq_job_limiter_mode, :sidekiq_job_limiter_compression_threshold_bytes, :sidekiq_job_limiter_limit_bytes, :suggest_pipeline_enabled, :search_rate_limit, :search_rate_limit_unauthenticated, + :search_rate_limit_allowlist_raw, :users_get_by_id_limit, :users_get_by_id_limit_allowlist_raw, :runner_token_expiration_interval, diff --git a/app/helpers/artifacts_helper.rb b/app/helpers/artifacts_helper.rb index f90d59409ed..10d2714840d 100644 --- a/app/helpers/artifacts_helper.rb +++ b/app/helpers/artifacts_helper.rb @@ -5,8 +5,7 @@ module ArtifactsHelper { project_path: project.full_path, project_id: project.id, - can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s, - artifacts_management_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg') + can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s } end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index c928c6479de..b7acc562be5 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module AuthHelper - PROVIDERS_WITH_ICONS = %w( + PROVIDERS_WITH_ICONS = %w[ alicloud atlassian_oauth2 auth0 @@ -15,12 +15,11 @@ module AuthHelper google_oauth2 jwt openid_connect - salesforce shibboleth twitter - ).freeze - LDAP_PROVIDER = /\Aldap/.freeze - POPULAR_PROVIDERS = %w(google_oauth2 github).freeze + ].freeze + LDAP_PROVIDER = /\Aldap/ + POPULAR_PROVIDERS = %w[google_oauth2 github].freeze delegate :slack_app_id, to: :'Gitlab::CurrentSettings.current_application_settings' diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 6e0ba748d85..e6212ee7d8d 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -7,6 +7,15 @@ module ButtonHelper # :text - Text to copy (optional) # :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) + # :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) + # :item_prop - itemprop attribute + # :variant - Button variant (optional, default: :default) + # :category - Button category (optional, default: :tertiary) + # :size - Button size (optional, default: :small) # # Examples: # @@ -20,6 +29,65 @@ module ButtonHelper # # See http://clipboardjs.com/#usage def clipboard_button(data = {}) + css_class = data.delete(:class) + title = data.delete(:title) || _('Copy') + button_text = data[:button_text] || nil + hide_tooltip = data[:hide_tooltip] || false + hide_button_icon = data[:hide_button_icon] || false + item_prop = data[:itemprop] || nil + variant = data[:variant] || :default + category = data[:category] || :tertiary + size = data[:size] || :small + + # This supports code in app/assets/javascripts/copy_to_clipboard.js that + # works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM. + if text = data.delete(:text) + data[:clipboard_text] = + if gfm = data.delete(:gfm) + { text: text, gfm: gfm } + else + text + end + end + + target = data.delete(:target) + data[:clipboard_target] = target if target + + unless hide_tooltip + data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data) + end + + render ::Pajamas::ButtonComponent.new( + icon: hide_button_icon ? nil : 'copy-to-clipboard', + 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_text + end + end + + # Output a "Copy to Clipboard" button + # Note: This is being replaced by a Pajamas-compliant helper that renders the button + # via ::Pajamas::ButtonComponent. Please use clipboard_button instead. + # + # data - Data attributes passed to `content_tag` (default: {}): + # :text - Text to copy (optional) + # :gfm - GitLab Flavored Markdown to copy, if different from `text` (optional) + # :target - Selector for target element to copy from (optional) + # + # Examples: + # + # # Define the clipboard's text + # clipboard_button(text: "Foo") + # # => "<button class='...' data-clipboard-text='Foo'>...</button>" + # + # # Define the target element + # clipboard_button(target: "div#foo") + # # => "<button class='...' data-clipboard-target='div#foo'>...</button>" + # + # See http://clipboardjs.com/#usage + def deprecated_clipboard_button(data = {}) css_class = data.delete(:class) || 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm' title = data.delete(:title) || _('Copy') button_text = data[:button_text] || nil diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb index ea5b613cb78..5d526a6abb6 100644 --- a/app/helpers/ci/status_helper.rb +++ b/app/helpers/ci/status_helper.rb @@ -9,55 +9,6 @@ # module Ci module StatusHelper - def ci_label_for_status(status) - if detailed_status?(status) - return status.label - end - - label = case status - when 'success' - 'passed' - when 'success-with-warnings' - 'passed with warnings' - when 'manual' - 'waiting for manual action' - when 'scheduled' - 'waiting for delayed job' - else - status - end - translation = "CiStatusLabel|#{label}" - s_(translation) - end - - def ci_text_for_status(status) - if detailed_status?(status) - return status.text - end - - case status - when 'success' - s_('CiStatusText|passed') - when 'success-with-warnings' - s_('CiStatusText|passed') - when 'manual' - s_('CiStatusText|blocked') - when 'scheduled' - s_('CiStatusText|delayed') - else - # All states are already being translated inside the detailed statuses: - # :running => Gitlab::Ci::Status::Running - # :skipped => Gitlab::Ci::Status::Skipped - # :failed => Gitlab::Ci::Status::Failed - # :success => Gitlab::Ci::Status::Success - # :canceled => Gitlab::Ci::Status::Canceled - # The following states are customized above: - # :manual => Gitlab::Ci::Status::Manual - status_translation = "CiStatusText|#{status}" - s_(status_translation) - end - end - def ci_status_for_statuseable(subject) status = subject.try(:status) || 'not found' status.humanize @@ -138,11 +89,34 @@ module Ci end end + private + def detailed_status?(status) status.respond_to?(:text) && status.respond_to?(:group) && status.respond_to?(:label) && status.respond_to?(:icon) end + + def ci_label_for_status(status) + if detailed_status?(status) + return status.label + end + + label = case status + when 'success' + 'passed' + when 'success-with-warnings' + 'passed with warnings' + when 'manual' + 'waiting for manual action' + when 'scheduled' + 'waiting for delayed job' + else + status + end + translation = "CiStatusLabel|#{label}" + s_(translation) + end end end diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb index a492c48e58c..0dbd1adeb71 100644 --- a/app/helpers/ci/variables_helper.rb +++ b/app/helpers/ci/variables_helper.rb @@ -42,8 +42,8 @@ module Ci def ci_variable_type_options [ - %w(Variable env_var), - %w(File file) + %w[Variable env_var], + %w[File file] ] end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 5c410a28229..1989d6ab3d5 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -92,7 +92,7 @@ module ClustersHelper end def cluster_created?(cluster) - !cluster.status_name.in?(%i/scheduled creating/) + !cluster.status_name.in?(%i[scheduled creating]) end def can_admin_cluster?(user, cluster) diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb index 80cf6f197e5..3cd7263c39e 100644 --- a/app/helpers/colors_helper.rb +++ b/app/helpers/colors_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ColorsHelper - HEX_COLOR_PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze + HEX_COLOR_PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/ def hex_color_to_rgb_array(hex_color) unless hex_color.is_a?(String) && HEX_COLOR_PATTERN.match?(hex_color) diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index c5df53ec606..9a78d4d9ad5 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -16,7 +16,7 @@ module DiffHelper def diff_view @diff_view ||= begin - diff_views = %w(inline parallel) + diff_views = %w[inline parallel] diff_view = params[:view] || cookies[:diff_view] diff_view = diff_views.first unless diff_views.include?(diff_view) diff_view.to_sym diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index af0f1bd6808..69b3fdc2271 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -16,7 +16,7 @@ module EmailsHelper def action_title(url) return unless url - %w(merge_requests issues commit).each do |action| + %w[merge_requests issues commit].each do |action| if url.split("/").include?(action) return "View #{action.humanize.singularize}" end diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 8140ee97291..6e9379a5926 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -62,7 +62,7 @@ module EnvironmentHelper klass = "ci-status ci-#{status.dasherize} #{ci_icon_utilities}" text = "#{ci_icon_for_status(status)} <span class=\"gl-ml-2\">#{status_text}</span>".html_safe - if deployment.deployable + if deployment.deployable.instance_of?(::Ci::Build) link_to(text, deployment_path(deployment), class: klass) else content_tag(:span, text, class: klass) diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index cd768ba8a7b..80a56493653 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -57,11 +57,9 @@ module EnvironmentsHelper 'default_branch' => project.default_branch, 'project_path' => project_path(project), 'tags_path' => project_tags_path(project), - 'external_dashboard_url' => project.metrics_setting_external_dashboard_url, 'custom_metrics_path' => project_prometheus_metrics_path(project), 'validate_query_path' => validate_query_project_prometheus_metrics_path(project), - 'custom_metrics_available' => custom_metrics_available?(project).to_s, - 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase + 'custom_metrics_available' => custom_metrics_available?(project).to_s } end @@ -93,8 +91,7 @@ module EnvironmentsHelper 'empty_loading_svg_path' => image_path('illustrations/monitoring/loading.svg'), 'empty_no_data_svg_path' => image_path('illustrations/monitoring/no_data.svg'), 'empty_no_data_small_svg_path' => image_path('illustrations/chart-empty-state-small.svg'), - 'empty_unable_to_connect_svg_path' => image_path('illustrations/monitoring/unable_to_connect.svg'), - 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT + 'empty_unable_to_connect_svg_path' => image_path('illustrations/monitoring/unable_to_connect.svg') } end end diff --git a/app/helpers/external_link_helper.rb b/app/helpers/external_link_helper.rb index 53dacfe0566..40079c0803d 100644 --- a/app/helpers/external_link_helper.rb +++ b/app/helpers/external_link_helper.rb @@ -7,6 +7,6 @@ module ExternalLinkHelper link = link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do "#{body}#{sprite_icon('external-link', css_class: 'gl-ml-2')}".html_safe end - sanitize(link, tags: %w(a svg use), attributes: %w(target rel data-testid class href).concat(options.stringify_keys.keys)) + sanitize(link, tags: %w[a svg use], attributes: %w[target rel data-testid class href].concat(options.stringify_keys.keys)) end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index bba3fac7468..ebebdfa56e6 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -29,9 +29,10 @@ module IconsHelper ActionController::Base.helpers.image_path('file_icons/file_icons.svg', host: sprite_base_url) end - def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil) + def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil, file_icon: false) memoized_icon("#{icon_name}_#{size}_#{css_class}") do - if known_sprites&.exclude?(icon_name) + unknown_icon = file_icon ? unknown_file_icon_sprite(icon_name) : unknown_icon_sprite(icon_name) + if unknown_icon exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg") Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception) end @@ -39,10 +40,11 @@ module IconsHelper css_classes = [] css_classes << "s#{size}" if size css_classes << css_class.to_s unless css_class.blank? + sprite_path = file_icon ? sprite_file_icons_path : sprite_icon_path content_tag( :svg, - content_tag(:use, '', { 'href' => "#{sprite_icon_path}##{icon_name}" }), + content_tag(:use, '', { 'href' => "#{sprite_path}##{icon_name}" }), class: css_classes.empty? ? nil : css_classes.join(' '), data: { testid: "#{icon_name}-icon" } ) @@ -123,61 +125,73 @@ module IconsHelper def file_type_icon_class(type, mode, name) if type == 'folder' - icon_class = 'folder-o' + 'folder-o' elsif type == 'archive' - icon_class = 'archive' + 'archive' elsif mode == '120000' - icon_class = 'share' + 'share' else # Guess which icon to choose based on file extension. # If you think a file extension is missing, feel free to add it on PR case File.extname(name).downcase when '.pdf' - icon_class = 'document' + 'document' when '.jpg', '.jpeg', '.jif', '.jfif', - '.jp2', '.jpx', '.j2k', '.j2c', - '.apng', '.png', '.gif', '.tif', '.tiff', - '.svg', '.ico', '.bmp', '.webp' - icon_class = 'doc-image' + '.jp2', '.jpx', '.j2k', '.j2c', + '.apng', '.png', '.gif', '.tif', '.tiff', + '.svg', '.ico', '.bmp', '.webp' + 'doc-image' when '.zip', '.zipx', '.tar', '.gz', '.gzip', '.tgz', '.bz', '.bzip', - '.bz2', '.bzip2', '.car', '.tbz', '.xz', 'txz', '.rar', '.7z', - '.lz', '.lzma', '.tlz' - icon_class = 'doc-compressed' + '.bz2', '.bzip2', '.car', '.tbz', '.xz', 'txz', '.rar', '.7z', + '.lz', '.lzma', '.tlz' + 'doc-compressed' when '.mp3', '.wma', '.ogg', '.oga', '.wav', '.flac', '.aac', '.3ga', - '.ac3', '.midi', '.m4a', '.ape', '.mpa' - icon_class = 'volume-up' + '.ac3', '.midi', '.m4a', '.ape', '.mpa' + 'volume-up' when '.mp4', '.m4p', '.m4v', - '.mpg', '.mp2', '.mpeg', '.mpe', '.mpv', - '.mpg', '.mpeg', '.m2v', '.m2ts', - '.avi', '.mkv', '.flv', '.ogv', '.mov', - '.3gp', '.3g2' - icon_class = 'live-preview' + '.mpg', '.mp2', '.mpeg', '.mpe', '.mpv', + '.mpg', '.mpeg', '.m2v', '.m2ts', + '.avi', '.mkv', '.flv', '.ogv', '.mov', + '.3gp', '.3g2' + 'live-preview' when '.doc', '.dot', '.docx', '.docm', '.dotx', '.dotm', '.docb', - '.odt', '.ott', '.uot', '.rtf' - icon_class = 'doc-text' + '.odt', '.ott', '.uot', '.rtf' + 'doc-text' when '.xls', '.xlt', '.xlm', '.xlsx', '.xlsm', '.xltx', '.xltm', - '.xlsb', '.xla', '.xlam', '.xll', '.xlw', '.ots', '.ods', '.uos' - icon_class = 'document' + '.xlsb', '.xla', '.xlam', '.xll', '.xlw', '.ots', '.ods', '.uos' + 'document' when '.ppt', '.pot', '.pps', '.pptx', '.pptm', '.potx', '.potm', - '.ppam', '.ppsx', '.ppsm', '.sldx', '.sldm', '.odp', '.otp', '.uop' - icon_class = 'doc-chart' + '.ppam', '.ppsx', '.ppsm', '.sldx', '.sldm', '.odp', '.otp', '.uop' + 'doc-chart' else - icon_class = 'doc-text' + 'doc-text' end end - - icon_class end private + def unknown_icon_sprite(icon_name) + known_sprites&.exclude?(icon_name) + end + + def unknown_file_icon_sprite(icon_name) + known_file_icon_sprites&.exclude?(icon_name) + end + def known_sprites return if Rails.env.production? @known_sprites ||= Gitlab::Json.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons'] end + def known_file_icon_sprites + return if Rails.env.production? + + @known_file_icon_sprites ||= Gitlab::Json.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/file_icons/file_icons.json')))['icons'] + end + def memoized_icon(key) @rendered_icons ||= {} diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 645a08bfcc0..a88be976337 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -277,11 +277,11 @@ module IntegrationsHelper s_("ProjectService|Trigger event for new comments.") when "confidential_note", "confidential_note_events" s_("ProjectService|Trigger event for new comments on confidential issues.") - when "issue", "issue_events" + when "issue", "issue_events", "issues_events" s_("ProjectService|Trigger event when an issue is created, updated, or closed.") - when "confidential_issue", "confidential_issue_events" + when "confidential_issue", "confidential_issue_events", "confidential_issues_events" s_("ProjectService|Trigger event when a confidential issue is created, updated, or closed.") - when "merge_request", "merge_request_events" + when "merge_request", "merge_request_events", "merge_requests_events" s_("ProjectService|Trigger event when a merge request is created, updated, or merged.") when "pipeline", "pipeline_events" s_("ProjectService|Trigger event when a pipeline status changes.") @@ -289,16 +289,20 @@ module IntegrationsHelper s_("ProjectService|Trigger event when a wiki page is created or updated.") when "commit", "commit_events" s_("ProjectService|Trigger event when a commit is created or updated.") - when "deployment" + when "deployment", "deployment_events" s_("ProjectService|Trigger event when a deployment starts or finishes.") - when "alert" + when "alert", "alert_events" s_("ProjectService|Trigger event when a new, unique alert is recorded.") - when "incident" + when "incident", "incident_events" s_("ProjectService|Trigger event when an incident is created.") when "group_mention" s_("ProjectService|Trigger event when a group is mentioned in a public context.") when "group_confidential_mention" s_("ProjectService|Trigger event when a group is mentioned in a confidential context.") + when "build_events" + s_("ProjectService|Trigger event when a build is created.") + when "archive_trace_events" + s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') end end # rubocop:enable Metrics/CyclomaticComplexity @@ -323,12 +327,15 @@ module IntegrationsHelper def serialize_integration(integration, group: nil, project: nil) { - active: integration.operating?, + id: integration.id, + active: integration.activated?, + configured: integration.persisted?, title: integration.title, description: integration.description, updated_at: integration.updated_at, edit_path: scoped_edit_integration_path(integration, group: group, project: project), - name: integration.to_param + name: integration.to_param, + icon: integration.try(:avatar_url) } end end diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 422380f3cc6..0443861903b 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -37,23 +37,13 @@ module InviteMembersHelper # Overridden in EE def common_invite_modal_dataset(source) - dataset = { + { id: source.id, root_id: source.root_ancestor&.id, name: source.name, default_access_level: Gitlab::Access::GUEST, full_path: source.full_path } - - if current_user && show_invite_members_for_task? - dataset.merge!( - tasks_to_be_done_options: tasks_to_be_done_options.to_json, - projects: projects_for_source(source).to_json, - new_project_path: source.is_a?(Group) ? new_project_path(namespace_id: source.id) : '' - ) - end - - dataset end private @@ -70,19 +60,6 @@ module InviteMembersHelper def users_filter_data(group) {} end - - def show_invite_members_for_task? - params[:open_modal] == 'invite_members_for_task' - end - - def tasks_to_be_done_options - ::MemberTask::TASKS.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } } - end - - def projects_for_source(source) - projects = source.is_a?(Project) ? [source] : source.projects - projects.map { |project| { id: project.id, title: project.title } } - end end InviteMembersHelper.prepend_mod_with('InviteMembersHelper') diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index c83545fa7a7..7f948db2f71 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -33,22 +33,6 @@ module IssuablesHelper end end - def sidebar_milestone_tooltip_label(milestone) - return _('Milestone') unless milestone.present? - - [escape_once(milestone[:title]), sidebar_milestone_remaining_days(milestone) || _('Milestone')].join('<br/>') - end - - def sidebar_milestone_remaining_days(milestone) - due_date_with_remaining_days(milestone[:due_date], milestone[:start_date]) - end - - def due_date_with_remaining_days(due_date, start_date = nil) - return unless due_date - - "#{due_date.to_fs(:medium)} (#{remaining_days_in_words(due_date, start_date)})" - end - def multi_label_name(current_labels, default_label) return default_label if current_labels.blank? @@ -131,46 +115,6 @@ module IssuablesHelper end # rubocop: enable CodeReuse/ActiveRecord - def issuable_meta_author_status(author) - return "" unless author&.status&.customized? && status = user_status(author) - - status.to_s.html_safe - end - - def issuable_meta(issuable, project) - output = [] - - if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.issue_type) - output << content_tag(:span, sprite_icon(issuable.work_item_type.icon_name.to_s, css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' }) - output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: IntegrationsHelper.integration_issue_type(issuable.issue_type), created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2') - else - output << content_tag(:span, s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2') - end - - if issuable.is_a?(Issue) && issuable.service_desk_reply_to - output << "#{html_escape(issuable.present(current_user: current_user).service_desk_reply_to)} via " - end - - output << content_tag(:strong) do - author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline-block") - author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none") - - author_output << issuable_meta_author_status(issuable.author) - - author_output - end - - if access = project.team.human_max_access(issuable.author_id) - output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: project.name }) - elsif project.team.contributor?(issuable.author_id) - output << content_tag(:span, _("Contributor"), class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3", title: _("This user has previously committed to the %{name} project.") % { name: project.name }) - end - - output << content_tag(:span, (sprite_icon('first-contribution', css_class: 'gl-icon gl-vertical-align-middle') if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!')) - - output.join.html_safe - end - def issuables_state_counter_text(issuable_type, state, display_count) titles = { opened: _("Open"), @@ -248,71 +192,6 @@ module IssuablesHelper data end - def issue_only_initial_data(issuable) - return {} unless issuable.is_a?(Issue) - - data = { - authorId: issuable.author.id, - authorName: issuable.author.name, - authorUsername: issuable.author.username, - authorWebUrl: url_for(user_path(issuable.author)), - createdAt: issuable.created_at.to_time.iso8601, - hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0, - isFirstContribution: issuable.first_contribution?, - issueType: issuable.issue_type, - serviceDeskReplyTo: issuable.present(current_user: current_user).service_desk_reply_to, - zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), - sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord - iid: issuable.iid.to_s, - isHidden: issue_hidden?(issuable), - canCreateIncident: create_issue_type_allowed?(issuable.project, :incident), - **incident_only_initial_data(issuable) - } - - data.tap do |d| - if issuable.duplicated? && can?(current_user, :read_issue, issuable.duplicated_to) - d[:duplicatedToIssueUrl] = url_for([issuable.duplicated_to.project, issuable.duplicated_to, { only_path: false }]) - end - - if issuable.moved? && can?(current_user, :read_issue, issuable.moved_to) - d[:movedToIssueUrl] = url_for([issuable.moved_to.project, issuable.moved_to, { only_path: false }]) - end - end - end - - def incident_only_initial_data(issue) - return {} unless issue.incident_type_issue? - - { - hasLinkedAlerts: issue.alert_management_alerts.any?, - canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issue), - currentPath: url_for(safe_params), - currentTab: safe_params[:incident_tab] - } - end - - def path_data(parent) - return { groupPath: parent.path } if parent.is_a?(Group) - - { - projectPath: ref_project.path, - projectId: ref_project.id, - projectNamespace: ref_project.namespace.full_path - } - end - - def updated_at_by(issuable) - return {} unless issuable.edited? - - { - updatedAt: issuable.last_edited_at.to_time.iso8601, - updatedBy: { - name: issuable.last_edited_by.name, - path: user_path(issuable.last_edited_by) - } - } - end - def issuables_count_for_state(issuable_type, state) Gitlab::IssuablesCountForState.new(finder, fast_fail: true, store_in_redis_cache: true)[state] end @@ -333,15 +212,6 @@ module IssuablesHelper issuable.author == current_user end - def issuable_display_type(issuable) - case issuable - when Issue - issuable.issue_type.downcase - when MergeRequest - issuable.model_name.human.downcase - end - end - def has_filter_bar_param? finder.class.scalar_params.any? { |p| params[p].present? } end @@ -353,12 +223,6 @@ module IssuablesHelper end end - def reviewer_sidebar_data(reviewer, merge_request: nil) - { avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }.tap do |data| - data[:can_merge] = merge_request.can_be_merged_by?(reviewer) if merge_request - end - end - def issuable_squash_option?(issuable, project) if issuable.persisted? issuable.squash @@ -428,27 +292,6 @@ module IssuablesHelper cookies[:collapsed_gutter] == 'true' end - def issuable_todo_button_data(issuable, is_collapsed) - { - todo_text: _('Add a to do'), - mark_text: _('Mark as done'), - todo_icon: sprite_icon('todo-add'), - mark_icon: sprite_icon('todo-done', css_class: 'todo-undone'), - issuable_id: issuable[:id], - issuable_type: issuable[:type], - create_path: issuable[:create_todo_path], - delete_path: issuable.dig(:current_user, :todo, :delete_path), - placement: is_collapsed ? 'left' : nil, - container: is_collapsed ? 'body' : nil, - boundary: 'viewport', - is_collapsed: is_collapsed, - track_label: "right_sidebar", - track_property: "update_todo", - track_action: "click_button", - track_value: "" - } - end - def close_reopen_params(issuable, action) { issuable.model_name.to_s.underscore => { state_event: action } @@ -520,6 +363,86 @@ module IssuablesHelper number_with_delimiter(count) end end + + def issue_only_initial_data(issuable) + return {} unless issuable.is_a?(Issue) + + { + canCreateIncident: create_issue_type_allowed?(issuable.project, :incident), + fullPath: issuable.project.full_path, + iid: issuable.iid, + issuableId: issuable.id, + issueType: issuable.issue_type, + isHidden: issue_hidden?(issuable), + sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord + zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), + **incident_only_initial_data(issuable), + **issue_header_data(issuable), + **work_items_data + } + end + + def incident_only_initial_data(issue) + return {} unless issue.incident_type_issue? + + { + hasLinkedAlerts: issue.alert_management_alerts.any?, + canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issue), + currentPath: url_for(safe_params), + currentTab: safe_params[:incident_tab] + } + end + + def issue_header_data(issuable) + data = { + authorId: issuable.author.id, + authorName: issuable.author.name, + authorUsername: issuable.author.username, + authorWebUrl: url_for(user_path(issuable.author)), + createdAt: issuable.created_at.to_time.iso8601, + isFirstContribution: issuable.first_contribution?, + serviceDeskReplyTo: issuable.present(current_user: current_user).service_desk_reply_to + } + + data.tap do |d| + if issuable.duplicated? && can?(current_user, :read_issue, issuable.duplicated_to) + d[:duplicatedToIssueUrl] = url_for([issuable.duplicated_to.project, issuable.duplicated_to, { only_path: false }]) + end + + if issuable.moved? && can?(current_user, :read_issue, issuable.moved_to) + d[:movedToIssueUrl] = url_for([issuable.moved_to.project, issuable.moved_to, { only_path: false }]) + end + end + end + + def work_items_data + { + registerPath: new_user_registration_path(redirect_to_referer: 'yes'), + signInPath: new_session_path(:user, redirect_to_referer: 'yes') + } + end + + def path_data(parent) + return { groupPath: parent.path } if parent.is_a?(Group) + + { + projectPath: ref_project.path, + projectId: ref_project.id, + projectNamespace: ref_project.namespace.full_path + } + end + + def updated_at_by(issuable) + return {} unless issuable.edited? + + { + updatedAt: issuable.last_edited_at.to_time.iso8601, + updatedBy: { + name: issuable.last_edited_by.name, + path: user_path(issuable.last_edited_by) + } + } + end end IssuablesHelper.prepend_mod_with('IssuablesHelper') diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index ed655b562c2..4419b573701 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -35,15 +35,6 @@ module IssuesHelper end end - def issue_status_visibility(issue, status_box:) - case status_box - when :open - 'hidden' if issue.closed? - when :closed - 'hidden' unless issue.closed? - end - end - def confidential_icon(issue) sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential? end @@ -128,24 +119,6 @@ module IssuesHelper can?(current_user, :create_merge_request_in, @project) end - def issue_closed_link(issue, current_user, css_class: '') - if issue.moved? && can?(current_user, :read_issue, issue.moved_to) - link_to(s_('IssuableStatus|moved'), issue.moved_to, class: css_class) - elsif issue.duplicated? && can?(current_user, :read_issue, issue.duplicated_to) - link_to(s_('IssuableStatus|duplicated'), issue.duplicated_to, class: css_class) - end - end - - def issue_closed_text(issue, current_user) - link = issue_closed_link(issue, current_user, css_class: 'text-underline gl-reset-color!') - - if link - s_('IssuableStatus|Closed (%{link})').html_safe % { link: link } - else - s_('IssuableStatus|Closed') - end - end - def show_moved_service_desk_issue_warning?(issue) return false unless issue.moved_from return false unless issue.from_service_desk? @@ -167,11 +140,8 @@ module IssuesHelper can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s, can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s, can_update_issue: can?(current_user, :update_issue, issuable).to_s, - iid: issuable.iid, - issuable_id: issuable.id, is_issue_author: (issuable.author == current_user).to_s, issue_path: issuable_path(issuable), - issue_type: issuable_display_type(issuable), new_issue_path: new_project_issue_path(project, new_issuable_params), project_path: project.full_path, report_abuse_path: add_category_abuse_reports_path, diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 79bab0969d1..a6bc9bcf205 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -68,7 +68,7 @@ module LabelsHelper # We need the `label` argument here for EE def wrap_label_html(label_html, small:, label:) - wrapper_classes = %w(gl-label) + wrapper_classes = %w[gl-label] wrapper_classes << 'gl-label-sm' if small %(<span class="#{wrapper_classes.join(' ')}">#{label_html}</span>).html_safe @@ -220,10 +220,16 @@ module LabelsHelper project || group&.subgroup? end + def label_lock_on_merge_help_text + _('IMPORTANT: Use this setting only for VERY strict auditing purposes. ' \ + 'When turned on, nobody will be able to remove the label from any merge requests after they are merged. ' \ + 'In addition, nobody will be able to turn off this setting or delete this label.') + end + private def render_label_link(label_html, link:, title:, dataset:) - classes = %w(gl-link gl-label-link) + classes = %w[gl-link gl-label-link] dataset ||= {} if title.present? diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 1a44f3554b0..1bd5cc41961 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -63,7 +63,7 @@ module MarkupHelper md = markdown_field(object, attribute, options.merge(post_process: false)) return unless md.present? - tags = %w(a gl-emoji b strong i em pre code p span) + tags = %w[a gl-emoji b strong i em pre code p span] context = markdown_field_render_context(object, attribute, options) context.reverse_merge!(truncate_visible_max_chars: max_chars || md.length) @@ -73,11 +73,11 @@ module MarkupHelper text, tags: tags, attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + - %w( + %w[ style data-src data-name data-unicode-version data-html data-reference-type data-project-path data-iid data-mr-title data-user - ) + ] ) render_links(text) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 42ffe338367..e4c1d7932aa 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -54,14 +54,6 @@ module MembersHelper end end - def localized_tasks_to_be_done_choices - { - code: s_('TasksToBeDone|Create/import code into a project (repository)'), - ci: s_('TasksToBeDone|Set up CI/CD pipelines to build, test, deploy, and monitor code'), - issues: s_('TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work') - }.freeze - end - private def source_text(member) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 32a183d6cd8..a90a16e120c 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -226,6 +226,13 @@ module MergeRequestsHelper } end + def mr_compare_form_data(_, merge_request) + { + source_branch_url: project_new_merge_request_branch_from_path(merge_request.source_project), + target_branch_url: project_new_merge_request_branch_to_path(merge_request.source_project) + } + end + private def review_requested_merge_requests_count @@ -269,7 +276,7 @@ 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: 'btn btn-default btn-sm gl-button btn-default-tertiary btn-icon gl-display-none! gl-md-display-inline-block! js-source-branch-copy') + 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') target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index 306c4d8694e..5274ace3d8a 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -157,7 +157,7 @@ module Nav partial: partial, component: 'invite_members', data: { - trigger_source: 'top-nav', + trigger_source: 'top_nav', trigger_element: 'text-emoji' } ) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 4cbd5029ac9..d3707183964 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -79,11 +79,11 @@ module NavHelper end def admin_monitoring_nav_links - %w(system_info background_migrations background_jobs health_check) + %w[system_info background_migrations background_jobs health_check] end def admin_analytics_nav_links - %w(dev_ops_report usage_trends) + %w[dev_ops_report usage_trends] end def show_super_sidebar?(user = current_user) diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb new file mode 100644 index 00000000000..6b5c4342c5c --- /dev/null +++ b/app/helpers/organizations/organization_helper.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Organizations + module OrganizationHelper + def organization_show_app_data(organization) + { + organization: organization.slice(:id, :name), + 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 + association_counts: { + groups: 10, + projects: 5, + users: 1050 + } + }.merge(shared_groups_and_projects_app_data).to_json + end + + def organization_groups_and_projects_app_data + shared_groups_and_projects_app_data.to_json + end + + private + + def shared_groups_and_projects_app_data + { + projects_empty_state_svg_path: image_path('illustrations/empty-state/empty-projects-md.svg'), + groups_empty_state_svg_path: image_path('illustrations/empty-state/empty-groups-md.svg'), + new_group_path: new_group_path, + new_project_path: new_project_path + } + end + end +end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 05605394d57..8d260d5e455 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -34,7 +34,7 @@ module ProfilesHelper def middle_dot_divider_classes(stacking, breakpoint) ['gl-mb-3'].tap do |classes| if stacking - classes.concat(%w(middle-dot-divider-sm gl-display-block gl-sm-display-inline-block)) + classes.concat(%w[middle-dot-divider-sm gl-display-block gl-sm-display-inline-block]) else classes << 'gl-display-inline-block' classes << if breakpoint.nil? diff --git a/app/helpers/projects/observability_helper.rb b/app/helpers/projects/observability_helper.rb deleted file mode 100644 index 4515fdb1bc3..00000000000 --- a/app/helpers/projects/observability_helper.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Projects - module ObservabilityHelper - def observability_tracing_view_model(project) - Gitlab::Json.generate({ - tracingUrl: Gitlab::Observability.tracing_url(project), - provisioningUrl: Gitlab::Observability.provisioning_url(project), - oauthUrl: Gitlab::Observability.oauth_url - }) - end - - def observability_tracing_details_model(project, trace_id) - Gitlab::Json.generate({ - tracingIndexUrl: namespace_project_tracing_index_path(project.group, project), - traceId: trace_id, - tracingUrl: Gitlab::Observability.tracing_url(project), - provisioningUrl: Gitlab::Observability.provisioning_url(project), - oauthUrl: Gitlab::Observability.oauth_url - }) - end - end -end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 42e8e44c94c..0c3b7d26fe2 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -18,7 +18,7 @@ module Projects suite_endpoint: project_pipeline_test_path(project, pipeline, suite_name: 'suite', format: :json), blob_path: project_blob_path(project, pipeline.sha), has_test_report: pipeline.has_test_reports?, - empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'), + empty_state_image_path: image_path('illustrations/empty-todos-md.svg'), empty_dag_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), artifacts_expired_image_path: image_path('illustrations/pipeline.svg'), tests_count: pipeline.test_report_summary.total[:count] diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 754e1b7c2a2..e45b38f2266 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -373,14 +373,6 @@ module ProjectsHelper false end - def metrics_external_dashboard_url - @project.metrics_setting_external_dashboard_url - end - - def metrics_dashboard_timezone - @project.metrics_setting_dashboard_timezone - end - def grafana_integration_url @project.grafana_integration&.grafana_url end diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb index 4acba9b68d7..c2c142bca4d 100644 --- a/app/helpers/registrations_helper.rb +++ b/app/helpers/registrations_helper.rb @@ -7,7 +7,7 @@ module RegistrationsHelper min_length_message: s_('SignUp|Username is too short (minimum is %{min_length} characters).') % { min_length: User::MIN_USERNAME_LENGTH }, max_length: User::MAX_USERNAME_LENGTH, max_length_message: s_('SignUp|Username is too long (maximum is %{max_length} characters).') % { max_length: User::MAX_USERNAME_LENGTH }, - qa_selector: 'new_user_username_field' + testid: 'new-user-username-field' } end diff --git a/app/helpers/routing/projects_helper.rb b/app/helpers/routing/projects_helper.rb index 9b4aafe49b4..06de9022be4 100644 --- a/app/helpers/routing/projects_helper.rb +++ b/app/helpers/routing/projects_helper.rb @@ -43,12 +43,11 @@ module Routing end def work_item_url(entity, *args) - # TODO: we do not have a route to access group level work items yet. - # That is to be done as part of view group level work item issue: - # see https://gitlab.com/gitlab-org/gitlab/-/work_items/393987 - return unless entity.project.present? - - project_work_items_url(entity.project, entity.iid, *args) + if entity.project.present? + project_work_items_url(entity.project, entity.iid, *args) + else + group_work_item_url(entity.namespace, entity.iid, *args) + end end def merge_request_url(entity, *args) @@ -94,6 +93,8 @@ module Routing private def use_work_items_path?(issue) + return true if issue.project.blank? && issue.namespace.present? + issue.issue_type == 'task' end end diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb index d39a972f3f3..71bfc9ecb40 100644 --- a/app/helpers/safe_format_helper.rb +++ b/app/helpers/safe_format_helper.rb @@ -36,7 +36,7 @@ module SafeFormatHelper # Returns an empty Hash if +tag+ is not a valid paired tag (e.g. <p>foo</p>). # an empty Hash is returned. # - # @param [String] tag is a HTML-safe output from tag helper + # @param [String] html_tag is a HTML-safe output from tag helper # @param [Symbol,Object] open_name name of opening tag # @param [Symbol,Object] close_name name of closing tag # @raise [ArgumentError] if +tag+ is not HTML-safe diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index cd32023adb6..f002a0c454d 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -242,7 +242,7 @@ module SearchHelper elsif current_controller?(:commits) 'commits' elsif current_controller?(:groups) - if %w(issues merge_requests).include?(controller.action_name) + if %w[issues merge_requests].include?(controller.action_name) controller.action_name end end @@ -479,7 +479,7 @@ module SearchHelper end.to_json end - def search_filter_input_options(type, placeholder = _('Search or filter results...')) + def search_filter_input_options(type, placeholder = _('Search or filter results…')) opts = { id: "filtered-search-#{type}", @@ -537,14 +537,14 @@ module SearchHelper source, count_tags: false, count_tail: false, - filtered_tags: %w(img), + filtered_tags: %w[img], max_length: 200 ) end def search_sanitize(html) # Truncato's filtered_tags and filtered_attributes are not quite the same - sanitize(html, tags: %w(a p ol ul li pre code)) + sanitize(html, tags: %w[a p ol ul li pre code]) end # _search_highlight is used in EE override diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 1bd7da0a352..33ca5ad584e 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -64,7 +64,8 @@ module SidebarsHelper gitlab_version: Gitlab.version_info, gitlab_version_check: gitlab_version_check, search: search_data, - panel_type: panel_type + panel_type: panel_type, + shortcut_links: shortcut_links } end @@ -106,7 +107,8 @@ module SidebarsHelper update_pins_url: pins_path, is_impersonating: impersonating?, stop_impersonation_path: admin_impersonation_path, - shortcut_links: shortcut_links(user, project: project) + shortcut_links: shortcut_links(user: user, project: project), + track_visits_path: track_namespace_visits_path }) end @@ -114,32 +116,43 @@ module SidebarsHelper nav: nil, project: nil, user: nil, group: nil, current_ref: nil, ref_type: nil, viewed_user: nil, organization: nil) context_adds = { route_is_active: method(:active_nav_link?), is_super_sidebar: true } - case nav - when 'project' - context = project_sidebar_context(project, user, current_ref, ref_type: ref_type, **context_adds) - Sidebars::Projects::SuperSidebarPanel.new(context) - when 'group' - context = group_sidebar_context(group, user, **context_adds) - Sidebars::Groups::SuperSidebarPanel.new(context) - when 'profile' - context = Sidebars::Context.new(current_user: user, container: user, **context_adds) - Sidebars::UserSettings::Panel.new(context) - when 'user_profile' - context = Sidebars::Context.new(current_user: user, container: viewed_user, **context_adds) - Sidebars::UserProfile::Panel.new(context) - when 'explore' - Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) - when 'search' - context = Sidebars::Context.new(current_user: user, container: nil, **context_adds) - Sidebars::Search::Panel.new(context) - when 'admin' - Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) - when 'organization' - context = organization_sidebar_context(organization, user, **context_adds) - Sidebars::Organizations::SuperSidebarPanel.new(context) - else + panel = case nav + when 'project' + context = project_sidebar_context(project, user, current_ref, ref_type: ref_type, **context_adds) + Sidebars::Projects::SuperSidebarPanel.new(context) + when 'group' + context = group_sidebar_context(group, user, **context_adds) + Sidebars::Groups::SuperSidebarPanel.new(context) + when 'profile' + context = Sidebars::Context.new(current_user: user, container: user, **context_adds) + Sidebars::UserSettings::Panel.new(context) + when 'user_profile' + context = Sidebars::Context.new(current_user: user, container: viewed_user, **context_adds) + Sidebars::UserProfile::Panel.new(context) + when 'explore' + Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) + when 'search' + context = Sidebars::Context.new(current_user: user, container: nil, **context_adds) + Sidebars::Search::Panel.new(context) + when 'admin' + Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) + when 'organization' + context = organization_sidebar_context(organization, user, **context_adds) + Sidebars::Organizations::SuperSidebarPanel.new(context) + when 'your_work' + context = your_work_sidebar_context(user, **context_adds) + Sidebars::YourWork::Panel.new(context) + end + + # We only return the panel if any menu item is rendered, otherwise fallback + return panel if panel&.render? + + # Fallback menu "Your work" for logged-in users, "Explore" for logged-out + if user context = your_work_sidebar_context(user, **context_adds) Sidebars::YourWork::Panel.new(context) + else + Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: nil, container: nil, **context_adds)) end end @@ -387,7 +400,29 @@ module SidebarsHelper !!session[:impersonator_id] end - def shortcut_links(user, project: nil) + def shortcut_links_anonymous + [ + { + title: _('Snippets'), + href: explore_snippets_path, + css_class: 'dashboard-shortcuts-snippets' + }, + { + title: _('Groups'), + href: explore_groups_path, + css_class: 'dashboard-shortcuts-groups' + }, + { + title: _('Projects'), + href: explore_projects_path, + css_class: 'dashboard-shortcuts-projects' + } + ] + end + + def shortcut_links(user: nil, project: nil) + return shortcut_links_anonymous unless user + shortcut_links = [ { title: _('Milestones'), @@ -403,6 +438,16 @@ module SidebarsHelper title: _('Activity'), href: activity_dashboard_path, css_class: 'dashboard-shortcuts-activity' + }, + { + title: _('Groups'), + href: dashboard_groups_path, + css_class: 'dashboard-shortcuts-groups' + }, + { + title: _('Projects'), + href: dashboard_projects_path, + css_class: 'dashboard-shortcuts-projects' } ] diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb index 07d83b8d850..21aa82aff1c 100644 --- a/app/helpers/sidekiq_helper.rb +++ b/app/helpers/sidekiq_helper.rb @@ -8,7 +8,7 @@ module SidekiqHelper (?<state>[DIEKNRSTVWXZLpsl\+<>/\d]+)\s+ (?<start>.+?)\s+ (?<command>(?:ruby\d+:\s+)?sidekiq.*\].*) - \z}x.freeze + \z}x def parse_sidekiq_ps(line) match = line.strip.match(SIDEKIQ_PS_REGEXP) diff --git a/app/helpers/stat_anchors_helper.rb b/app/helpers/stat_anchors_helper.rb index d9429f28be7..957985d6953 100644 --- a/app/helpers/stat_anchors_helper.rb +++ b/app/helpers/stat_anchors_helper.rb @@ -3,7 +3,7 @@ module StatAnchorsHelper def stat_anchor_attrs(anchor) {}.tap do |attrs| - attrs[:class] = %w(nav-link gl-display-flex gl-align-items-center) << extra_classes(anchor) + attrs[:class] = %w[nav-link gl-display-flex gl-align-items-center] << extra_classes(anchor) attrs[:itemprop] = anchor.itemprop if anchor.itemprop attrs[:data] = anchor.data if anchor.data end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 4f17634f3e4..0d885621b6c 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -272,9 +272,9 @@ module TodosHelper def show_todo_state?(todo) case todo.target when MergeRequest, Issue - %w(closed merged).include?(todo.target.state) + %w[closed merged].include?(todo.target.state) when AlertManagement::Alert - %i(resolved).include?(todo.target.state) + %i[resolved].include?(todo.target.state) else false end diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 12f78d9bd16..1b5d0b276a3 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -14,7 +14,6 @@ module Users PAGES_MOVED_CALLOUT = 'pages_moved_callout' REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze WEB_HOOK_DISABLED = 'web_hook_disabled' - ULTIMATE_FEATURE_REMOVAL_BANNER = 'ultimate_feature_removal_banner' BRANCH_RULES_INFO_CALLOUT = 'branch_rules_info_callout' NEW_NAVIGATION_CALLOUT = 'new_navigation_callout' @@ -94,12 +93,6 @@ module Users Gitlab.com? && current_user.created_at >= Date.new(2023, 6, 2) end - def ultimate_feature_removal_banner_dismissed?(project) - return false unless project - - user_dismissed?(ULTIMATE_FEATURE_REMOVAL_BANNER, object: project) - end - private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, object: nil) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index ac279904fd2..30f8f6fdfe5 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -164,7 +164,7 @@ module UsersHelper messageHtml: message, actionPrimary: { text: s_('AdminUsers|Confirm user'), - attributes: [{ variant: 'confirm', 'data-qa-selector': 'confirm_user_confirm_button' }] + attributes: [{ variant: 'confirm', 'data-testid': 'confirm-user-confirm-button' }] }, actionSecondary: { text: _('Cancel'), @@ -176,7 +176,7 @@ module UsersHelper path: confirm_admin_user_path(user), method: 'put', modal_attributes: modal_attributes, - qa_selector: 'confirm_user_button' + testid: 'confirm-user-button' } end diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index dc8ef4e44be..45a4b292eb5 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -5,9 +5,8 @@ module VersionCheckHelper def show_version_check? return false unless Gitlab::CurrentSettings.version_check_enabled - return false if User.single_user&.requires_usage_stats_consent? - current_user&.can_read_all_resources? + current_user&.can_read_all_resources? && !User.single_user&.requires_usage_stats_consent? end def gitlab_version_check diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb new file mode 100644 index 00000000000..4d1085a5169 --- /dev/null +++ b/app/helpers/vite_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ViteHelper + def universal_javascript_include_tag(*args) + if vite_enabled + vite_javascript_tag(*args) + else + javascript_include_tag(*args) + end + end + + def universal_asset_path(*args) + if vite_enabled + vite_asset_path(*args) + else + asset_path(*args) + end + end + + private + + def vite_enabled + Feature.enabled?(:vite) && !Rails.env.test? && vite_running + end + + def vite_running + ViteRuby.instance.dev_server_running? + end +end diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index ba3c232bec4..92874168798 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module WebpackHelper + include ViteHelper + def prefetch_link_tag(source) href = asset_path(source) @@ -14,7 +16,11 @@ module WebpackHelper end def webpack_bundle_tag(bundle) - javascript_include_tag(*webpack_entrypoint_paths(bundle)) + if vite_running + vite_javascript_tag bundle + else + javascript_include_tag(*webpack_entrypoint_paths(bundle)) + end end def webpack_preload_asset_tag(asset, options = {}) @@ -32,6 +38,8 @@ module WebpackHelper end def webpack_controller_bundle_tags + return if Feature.enabled?(:vite) && !Rails.env.test? + chunks = [] action = case controller.action_name diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index 9036c7c8347..1969c98de8b 100644 --- a/app/helpers/work_items_helper.rb +++ b/app/helpers/work_items_helper.rb @@ -11,4 +11,10 @@ module WorkItemsHelper report_abuse_path: add_category_abuse_reports_path } end + + def work_items_list_data(group) + { + full_path: group.full_path + } + end end diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb index 972c1da065a..92743dc1926 100644 --- a/app/mailers/emails/in_product_marketing.rb +++ b/app/mailers/emails/in_product_marketing.rb @@ -12,15 +12,6 @@ module Emails 'X-Mailgun-Tag' => 'marketing' }.freeze - def in_product_marketing_email(recipient_id, group_id, track, series) - group = Group.find(group_id) - user = User.find(recipient_id) - email = user.notification_email_for(group) - @message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, user: user, series: series) - - mail_to(to: email, subject: @message.subject_line) - end - def build_ios_app_guide_email(recipient_email) @message = ::Gitlab::Email::Message::BuildIosAppGuide.new diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 25d68d47228..a9e1efbdd5d 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -2,6 +2,8 @@ module Emails module Profile + include SafeFormatHelper + def new_user_email(user_id, token = nil) @current_user = @user = User.find(user_id) @target_url = user_url(@user) @@ -58,6 +60,28 @@ module Emails end # rubocop: enable CodeReuse/ActiveRecord + def resource_access_tokens_about_to_expire_email(recipient, resource, token_names) + @user = recipient + @token_names = token_names + @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE + @resource = resource + @target_url = if resource.is_a?(Group) + group_settings_access_tokens_url(resource) + else + project_settings_access_tokens_url(resource) + end + + mail_with_locale( + to: recipient.notification_email_or_default, + subject: subject( + safe_format( + _("Your resource access tokens will expire in %{days_to_expire} or less"), + days_to_expire: pluralize(@days_to_expire, _('day')) + ) + ) + ) + end + def access_token_created_email(user, token_name) return unless user&.active? @@ -155,7 +179,7 @@ module Emails @user = user @email = email - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) end def new_achievement_email(user, achievement) diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index f609c9318da..9f3611df2cc 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -4,6 +4,7 @@ module Emails module ServiceDesk extend ActiveSupport::Concern include MarkupHelper + include ::ServiceDesk::CustomEmails::Logger EMAIL_ATTACHMENTS_SIZE_LIMIT = 10.megabytes.freeze @@ -61,9 +62,10 @@ module Emails def service_desk_custom_email_verification_email(service_desk_setting) @service_desk_setting = service_desk_setting + @project = @service_desk_setting.project email_sender = sender( - User.support_bot.id, + Users::Internal.support_bot.id, send_from_user_email: false, sender_name: @service_desk_setting.outgoing_name, sender_email: @service_desk_setting.custom_email @@ -73,7 +75,7 @@ module Emails subject = format(s_("Notify|Verify custom email address %{email} for %{project_name}"), email: @service_desk_setting.custom_email, - project_name: @service_desk_setting.project.name + project_name: @project.name ) options = { @@ -119,7 +121,7 @@ module Emails def setup_service_desk_mail(issue_id) @issue = Issue.find(issue_id) @project = @issue.project - @support_bot = User.support_bot + @support_bot = Users::Internal.support_bot @service_desk_setting = @project.service_desk_setting @@ -139,6 +141,11 @@ module Emails return mail if !service_desk_custom_email_enabled? && !force return mail unless @service_desk_setting.custom_email_credential.present? + # Only set custom email reply address if it's enabled, not when we force it. + inject_service_desk_custom_email_reply_address unless force + + log_info(project: @project) + mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_credential.delivery_options) end @@ -146,6 +153,15 @@ module Emails Feature.enabled?(:service_desk_custom_email, @project) && @service_desk_setting&.custom_email_enabled? end + def inject_service_desk_custom_email_reply_address + return unless Feature.enabled?(:service_desk_custom_email_reply, @project) + + reply_address = Gitlab::Email::ServiceDesk::CustomEmail.reply_address(@issue, reply_key) + headers['Reply-To'] = Mail::Address.new(reply_address).tap do |address| + address.display_name = reply_display_name(@issue) + end + end + def service_desk_sender_email_address return unless service_desk_custom_email_enabled? diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 4180e76e1a0..77d32a55941 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -208,6 +208,7 @@ class Notify < ApplicationMailer headers["#{prefix}-ID"] = object.id headers["#{prefix}-IID"] = object.iid if object.respond_to?(:iid) + headers["#{prefix}-State"] = object.state if object.respond_to?(:state) end def add_project_headers diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index f43f4511913..638df56b770 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -64,6 +64,10 @@ class NotifyPreview < ActionMailer::Preview end end + def resource_access_token_about_to_expire_email + Notify.resource_access_tokens_about_to_expire_email(user, group, ['token_name']) + end + def access_token_created_email Notify.access_token_created_email(user, 'token_name').message end diff --git a/app/models/ability.rb b/app/models/ability.rb index 4da4d113a7f..d8510524c1f 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -46,6 +46,7 @@ class Ability issues.select { |issue| issue.visible_to_user?(user) } end end + alias_method :work_items_readable_by_user, :issues_readable_by_user # Returns an Array of MergeRequests that can be read by the given user. # diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 75c90d370c3..bf25c539830 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -61,10 +61,11 @@ class AbuseReport < ApplicationRecord validates :screenshot, file_size: { maximum: MAX_FILE_SIZE } validate :validate_screenshot_is_image - scope :by_user_id, ->(id) { where(user_id: id) } - scope :by_reporter_id, ->(id) { where(reporter_id: id) } + scope :by_user_id, ->(user_id) { where(user_id: user_id) } + scope :by_reporter_id, ->(reporter_id) { where(reporter_id: reporter_id) } scope :by_category, ->(category) { where(category: category) } scope :with_users, -> { includes(:reporter, :user) } + scope :with_labels, -> { includes(:labels) } enum category: { spam: 1, @@ -141,8 +142,14 @@ class AbuseReport < ApplicationRecord end end - def other_reports_for_user - user.abuse_reports.id_not_in(id) + def past_closed_reports_for_user + user.abuse_reports.closed.id_not_in(id) + end + + def similar_open_reports_for_user + return AbuseReport.none unless open? + + user.abuse_reports.open.by_category(category).id_not_in(id).includes(:reporter) end private diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 7d025fb7738..e42f9eeef23 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -102,17 +102,16 @@ class ActiveSession # set marketing cookie when user has active session def self.set_active_user_cookie(auth) - auth.cookies[:about_gitlab_active_user] = + expiration_time = 2.weeks.from_now + + auth.cookies[:gitlab_user] = { value: true, - domain: Gitlab.config.gitlab.host + domain: Gitlab.config.gitlab.host, + expires: expiration_time } end - def self.unset_active_user_cookie(auth) - auth.cookies.delete :about_gitlab_active_user - end - def self.list(user) Gitlab::Redis::Sessions.with do |redis| cleaned_up_lookup_entries(redis, user).map do |raw_session| diff --git a/app/models/alerting/project_alerting_setting.rb b/app/models/alerting/project_alerting_setting.rb index 34fa27eb29b..7e94d41137f 100644 --- a/app/models/alerting/project_alerting_setting.rb +++ b/app/models/alerting/project_alerting_setting.rb @@ -14,6 +14,8 @@ module Alerting algorithm: 'aes-256-gcm' before_validation :ensure_token + after_create :create_http_integration + after_update :sync_http_integration private @@ -24,5 +26,31 @@ module Alerting def generate_token SecureRandom.hex end + + # Remove in next required stop after %16.4 + # https://gitlab.com/gitlab-org/gitlab/-/issues/338838 + def sync_http_integration + project.alert_management_http_integrations + .for_endpoint_identifier('legacy-prometheus') + .take + &.update_columns( + encrypted_token: encrypted_token, + encrypted_token_iv: encrypted_token_iv + ) + end + + # Remove in next required stop after %16.4 + # https://gitlab.com/gitlab-org/gitlab/-/issues/338838 + def create_http_integration + AlertManagement::HttpIntegration.insert({ + project_id: project_id, + encrypted_token: encrypted_token, + encrypted_token_iv: encrypted_token_iv, + active: true, + name: 'Prometheus', + endpoint_identifier: 'legacy-prometheus', + type_identifier: :prometheus + }) + end end end diff --git a/app/models/analytics/cycle_analytics/runtime_limiter.rb b/app/models/analytics/cycle_analytics/runtime_limiter.rb new file mode 100644 index 00000000000..063377c3ddb --- /dev/null +++ b/app/models/analytics/cycle_analytics/runtime_limiter.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class RuntimeLimiter + delegate :monotonic_time, to: :'Gitlab::Metrics::System' + + DEFAULT_MAX_RUNTIME = 200.seconds + + attr_reader :max_runtime, :start_time + + def initialize(max_runtime = DEFAULT_MAX_RUNTIME) + @start_time = monotonic_time + @max_runtime = max_runtime + end + + def elapsed_time + monotonic_time - start_time + end + + def over_time? + @last_check = elapsed_time >= max_runtime + end + + def was_over_time? + !!@last_check + end + end + end +end diff --git a/app/models/analytics/cycle_analytics/stage_event_hash.rb b/app/models/analytics/cycle_analytics/stage_event_hash.rb index 6443a970945..7dcabd01ebf 100644 --- a/app/models/analytics/cycle_analytics/stage_event_hash.rb +++ b/app/models/analytics/cycle_analytics/stage_event_hash.rb @@ -13,7 +13,7 @@ module Analytics # Atomic, safe insert without retrying query = <<~SQL - WITH insert_cte AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} ( + WITH insert_cte AS MATERIALIZED ( INSERT INTO #{quoted_table_name} (hash_sha256) VALUES (#{casted_hash_code}) ON CONFLICT DO NOTHING RETURNING ID ) SELECT ids.id FROM ( diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index f67efaf4f58..153257636ba 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -14,38 +14,30 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord ignore_column :send_user_confirmation_email, remove_with: '15.8', remove_after: '2022-12-18' ignore_column :web_ide_clientside_preview_enabled, remove_with: '15.11', remove_after: '2023-04-22' ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22' - ignore_columns %i[ - encrypted_tofa_access_token_expires_in - encrypted_tofa_access_token_expires_in_iv - encrypted_tofa_client_library_args - encrypted_tofa_client_library_args_iv - encrypted_tofa_client_library_class - encrypted_tofa_client_library_class_iv - encrypted_tofa_client_library_create_credentials_method - encrypted_tofa_client_library_create_credentials_method_iv - encrypted_tofa_client_library_fetch_access_token_method - encrypted_tofa_client_library_fetch_access_token_method_iv - encrypted_tofa_credentials - encrypted_tofa_credentials_iv - encrypted_tofa_host - encrypted_tofa_host_iv - encrypted_tofa_request_json_keys - encrypted_tofa_request_json_keys_iv - encrypted_tofa_request_payload - encrypted_tofa_request_payload_iv - encrypted_tofa_response_json_keys - encrypted_tofa_response_json_keys_iv - encrypted_tofa_url - encrypted_tofa_url_iv - vertex_project - ], remove_with: '16.3', remove_after: '2023-07-22' ignore_column :database_apdex_settings, remove_with: '16.4', remove_after: '2023-08-22' + ignore_columns %i[ dashboard_notification_limit dashboard_enforcement_limit dashboard_limit_new_namespace_creation_enforcement_date ], remove_with: '16.5', remove_after: '2023-08-22' + ignore_column %i[ + relay_state_domain_allowlist + in_product_marketing_emails_enabled + ], remove_with: '16.6', remove_after: '2023-10-22' + + ignore_columns %i[ + encrypted_product_analytics_clickhouse_connection_string + encrypted_product_analytics_clickhouse_connection_string_iv + encrypted_jitsu_administrator_password + encrypted_jitsu_administrator_password_iv + jitsu_host + jitsu_project_xid + jitsu_administrator_email + ], remove_with: '16.5', remove_after: '2023-09-22' + ignore_columns %i[ai_access_token ai_access_token_iv], remove_with: '16.6', remove_after: '2023-10-22' + INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ 'Admin Area > Settings > Metrics and profiling > Metrics - Grafana' @@ -244,6 +236,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord hostname: true, if: :snowplow_enabled + validates :snowplow_database_collector_hostname, + allow_blank: true, + hostname: true, + length: { maximum: 255 } + validates :max_attachment_size, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -300,6 +297,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord 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 } + validates :repository_storages, presence: true validate :check_repository_storages validate :check_repository_storages_weighted @@ -310,7 +311,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord if: :auto_devops_enabled? validates :enabled_git_access_protocol, - inclusion: { in: %w(ssh http), allow_blank: true } + inclusion: { in: %w[ssh http], allow_blank: true } validates :domain_denylist, presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' }, @@ -551,7 +552,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord if: :external_authorization_service_enabled validates :spam_check_endpoint_url, - addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ schemes: %w(tls grpc) }), allow_blank: true + addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ schemes: %w[tls grpc] }), allow_blank: true validates :spam_check_endpoint_url, presence: true, @@ -666,6 +667,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :gitlab_shell_operation_limit end + validates :search_rate_limit_allowlist, + length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, + allow_nil: false + validates :notes_create_limit_allowlist, length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false @@ -794,18 +799,20 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord attr_encrypted :arkose_labs_public_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm - attr_encrypted :jitsu_administrator_password, encryption_options_base_32_aes_256_gcm attr_encrypted :telesign_customer_xid, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :telesign_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :product_analytics_clickhouse_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :product_analytics_configurator_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :openai_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :anthropic_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) - attr_encrypted :ai_access_token, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :vertex_ai_credentials, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + # Restricting the validation to `on: :update` only to avoid cyclical dependencies with + # License <--> ApplicationSetting. This method calls a license check when we create + # ApplicationSetting from defaults which in turn depends on ApplicationSetting record. + # The currect default is defined in the `defaults` method so we don't need to validate + # it here. validates :disable_feed_token, - inclusion: { in: [true, false], message: N_('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') }, on: :update validates :disable_admin_oauth_scopes, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -962,7 +969,7 @@ 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::UrlBlocker.validate!(kroki_url, schemes: %w[http https], enforce_sanitization: true)[0] rescue Gitlab::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 f6bf535158a..5a90e246499 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -9,12 +9,12 @@ module ApplicationSettingImplementation \s # any whitespace character | # or [\r\n] # any number of newline characters - }x.freeze + }x # Setting a key restriction to `-1` means that all keys of this type are # forbidden. FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN - VALID_RUNNER_REGISTRAR_TYPES = %w(project group).freeze + VALID_RUNNER_REGISTRAR_TYPES = %w[project group].freeze DEFAULT_PROTECTED_PATHS = [ '/users/password', @@ -37,7 +37,6 @@ module ApplicationSettingImplementation { admin_mode: false, after_sign_up_text: nil, - ai_access_token: nil, akismet_enabled: false, akismet_api_key: nil, allow_local_requests_from_system_hooks: true, @@ -53,6 +52,7 @@ module ApplicationSettingImplementation container_registry_vendor: '', container_registry_version: '', custom_http_clone_url_root: nil, + decompress_archive_file_timeout: 210, default_artifacts_expire_in: '30 days', default_branch_name: nil, default_branch_protection: Settings.gitlab['default_branch_protection'], @@ -171,6 +171,7 @@ module ApplicationSettingImplementation snowplow_app_id: nil, snowplow_collector_hostname: nil, snowplow_cookie_domain: nil, + snowplow_database_collector_hostname: nil, snowplow_enabled: false, sourcegraph_enabled: false, sourcegraph_public_only: true, @@ -254,6 +255,7 @@ module ApplicationSettingImplementation user_deactivation_emails_enabled: true, search_rate_limit: 30, search_rate_limit_unauthenticated: 10, + search_rate_limit_allowlist: [], users_get_by_id_limit: 300, users_get_by_id_limit_allowlist: [], can_create_group: true, @@ -380,6 +382,14 @@ module ApplicationSettingImplementation self.protected_paths = strings_to_array(values) end + def protected_paths_for_get_request_raw + array_to_string(protected_paths_for_get_request) + end + + def protected_paths_for_get_request_raw=(values) + self.protected_paths_for_get_request = strings_to_array(values) + end + def notes_create_limit_allowlist_raw array_to_string(notes_create_limit_allowlist) end @@ -396,6 +406,14 @@ module ApplicationSettingImplementation self.users_get_by_id_limit_allowlist = strings_to_array(values).map(&:downcase) end + def search_rate_limit_allowlist_raw + array_to_string(search_rate_limit_allowlist) + end + + def search_rate_limit_allowlist_raw=(values) + self.search_rate_limit_allowlist = strings_to_array(values).map(&:downcase) + end + def asset_proxy_whitelist=(values) values = strings_to_array(values) if values.is_a?(String) diff --git a/app/models/approval.rb b/app/models/approval.rb index 9ded44fe425..ecc15077c8d 100644 --- a/app/models/approval.rb +++ b/app/models/approval.rb @@ -3,10 +3,13 @@ class Approval < ApplicationRecord include CreatedAtFilterable include Importable + include ShaAttribute belongs_to :user belongs_to :merge_request + sha_attribute :patch_id_sha + validates :merge_request_id, presence: true, unless: :importing? validates :user_id, presence: true, uniqueness: { scope: [:merge_request_id] } diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index ebc43b04b1b..73e3fa709b0 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -78,7 +78,7 @@ class AwardEmoji < ApplicationRecord end def broadcast_note_update - awardable.expire_etag_cache + awardable.broadcast_noteable_notes_changed awardable.trigger_note_subscription_update end diff --git a/app/models/badge.rb b/app/models/badge.rb index 23e6f305c32..f4e719887ba 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -18,7 +18,7 @@ class Badge < ApplicationRecord # This regex is built dynamically using the keys from the PLACEHOLDER struct. # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash. # This regex will build the new PLACEHOLDER_REGEX with the new information - PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze + PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/ default_scope { order_created_at_asc } # rubocop:disable Cop/DefaultScope diff --git a/app/models/blob_viewer/binary_stl.rb b/app/models/blob_viewer/binary_stl.rb index 425f72decae..6ccf75200e5 100644 --- a/app/models/blob_viewer/binary_stl.rb +++ b/app/models/blob_viewer/binary_stl.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'stl' - self.extensions = %w(stl) + self.extensions = %w[stl] self.binary = true end end diff --git a/app/models/blob_viewer/cargo_toml.rb b/app/models/blob_viewer/cargo_toml.rb index 2f1ebd25b4f..eb2a6f4433d 100644 --- a/app/models/blob_viewer/cargo_toml.rb +++ b/app/models/blob_viewer/cargo_toml.rb @@ -4,7 +4,7 @@ module BlobViewer class CargoToml < DependencyManager include Static - self.file_types = %i(cargo_toml) + self.file_types = %i[cargo_toml] def manager_name 'Cargo' diff --git a/app/models/blob_viewer/cartfile.rb b/app/models/blob_viewer/cartfile.rb index ea0494033bf..58fc97a9ffc 100644 --- a/app/models/blob_viewer/cartfile.rb +++ b/app/models/blob_viewer/cartfile.rb @@ -4,7 +4,7 @@ module BlobViewer class Cartfile < DependencyManager include Static - self.file_types = %i(cartfile) + self.file_types = %i[cartfile] def manager_name 'Carthage' diff --git a/app/models/blob_viewer/changelog.rb b/app/models/blob_viewer/changelog.rb index 8810bd25809..7992fbf542c 100644 --- a/app/models/blob_viewer/changelog.rb +++ b/app/models/blob_viewer/changelog.rb @@ -6,7 +6,7 @@ module BlobViewer include Static self.partial_name = 'changelog' - self.file_types = %i(changelog) + self.file_types = %i[changelog] self.binary = false def render_error diff --git a/app/models/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb index aac7271242e..3449780f50f 100644 --- a/app/models/blob_viewer/composer_json.rb +++ b/app/models/blob_viewer/composer_json.rb @@ -4,7 +4,7 @@ module BlobViewer class ComposerJson < DependencyManager include ServerSide - self.file_types = %i(composer_json) + self.file_types = %i[composer_json] def manager_name 'Composer' diff --git a/app/models/blob_viewer/contributing.rb b/app/models/blob_viewer/contributing.rb index fa224309e31..524104f176a 100644 --- a/app/models/blob_viewer/contributing.rb +++ b/app/models/blob_viewer/contributing.rb @@ -6,7 +6,7 @@ module BlobViewer include Static self.partial_name = 'contributing' - self.file_types = %i(contributing) + self.file_types = %i[contributing] self.binary = false end end diff --git a/app/models/blob_viewer/csv.rb b/app/models/blob_viewer/csv.rb index 633e3bd63d8..97fa890653d 100644 --- a/app/models/blob_viewer/csv.rb +++ b/app/models/blob_viewer/csv.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.binary = false - self.extensions = %w(csv) + self.extensions = %w[csv] self.partial_name = 'csv' self.switcher_icon = 'table' end diff --git a/app/models/blob_viewer/gemfile.rb b/app/models/blob_viewer/gemfile.rb index 77220cdbd08..84edacb32bd 100644 --- a/app/models/blob_viewer/gemfile.rb +++ b/app/models/blob_viewer/gemfile.rb @@ -4,7 +4,7 @@ module BlobViewer class Gemfile < DependencyManager include Static - self.file_types = %i(gemfile gemfile_lock) + self.file_types = %i[gemfile gemfile_lock] def manager_name 'Bundler' diff --git a/app/models/blob_viewer/gemspec.rb b/app/models/blob_viewer/gemspec.rb index 274859a7710..645458467f4 100644 --- a/app/models/blob_viewer/gemspec.rb +++ b/app/models/blob_viewer/gemspec.rb @@ -4,7 +4,7 @@ module BlobViewer class Gemspec < DependencyManager include ServerSide - self.file_types = %i(gemspec) + self.file_types = %i[gemspec] def manager_name 'RubyGems' diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb index e255b6d15d2..9cee536d15b 100644 --- a/app/models/blob_viewer/gitlab_ci_yml.rb +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -7,7 +7,7 @@ module BlobViewer self.partial_name = 'gitlab_ci_yml' self.loading_partial_name = 'gitlab_ci_yml_loading' - self.file_types = %i(gitlab_ci) + self.file_types = %i[gitlab_ci] self.binary = false def validation_message(opts) diff --git a/app/models/blob_viewer/go_mod.rb b/app/models/blob_viewer/go_mod.rb index d4d117f899c..eebf057c6dc 100644 --- a/app/models/blob_viewer/go_mod.rb +++ b/app/models/blob_viewer/go_mod.rb @@ -11,9 +11,9 @@ module BlobViewer (?<name>.*?) (?# module name) \s*(?://.*)? (?# comment) (?:\n|\z) (?# newline or end of file) - }x.freeze + }x - self.file_types = %i(go_mod go_sum) + self.file_types = %i[go_mod go_sum] def manager_name 'Go Modules' diff --git a/app/models/blob_viewer/godeps_json.rb b/app/models/blob_viewer/godeps_json.rb index 743c759aea5..37a133848a0 100644 --- a/app/models/blob_viewer/godeps_json.rb +++ b/app/models/blob_viewer/godeps_json.rb @@ -4,7 +4,7 @@ module BlobViewer class GodepsJson < DependencyManager include Static - self.file_types = %i(godeps_json) + self.file_types = %i[godeps_json] def manager_name 'godep' diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb index 3427227ad26..489b29380d0 100644 --- a/app/models/blob_viewer/license.rb +++ b/app/models/blob_viewer/license.rb @@ -6,7 +6,7 @@ module BlobViewer include Static self.partial_name = 'license' - self.file_types = %i(license) + self.file_types = %i[license] self.binary = false def license diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb index 6f002a6b224..4b04d8425fd 100644 --- a/app/models/blob_viewer/markup.rb +++ b/app/models/blob_viewer/markup.rb @@ -7,7 +7,7 @@ module BlobViewer self.partial_name = 'markup' self.extensions = Gitlab::MarkupHelper::EXTENSIONS - self.file_types = %i(readme) + self.file_types = %i[readme] self.binary = false def banzai_render_context diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb index 351502d451f..e6f1988d7a6 100644 --- a/app/models/blob_viewer/notebook.rb +++ b/app/models/blob_viewer/notebook.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'notebook' - self.extensions = %w(ipynb) + self.extensions = %w[ipynb] self.binary = false self.switcher_icon = 'doc-text' self.switcher_title = 'notebook' diff --git a/app/models/blob_viewer/open_api.rb b/app/models/blob_viewer/open_api.rb index 0551f3bb1e3..5d9c5bea8dc 100644 --- a/app/models/blob_viewer/open_api.rb +++ b/app/models/blob_viewer/open_api.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'openapi' - self.file_types = %i(openapi) + self.file_types = %i[openapi] self.binary = false self.switcher_icon = 'api' end diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb index 5350b6b0626..c205c10b536 100644 --- a/app/models/blob_viewer/package_json.rb +++ b/app/models/blob_viewer/package_json.rb @@ -4,7 +4,7 @@ module BlobViewer class PackageJson < DependencyManager include ServerSide - self.file_types = %i(package_json) + self.file_types = %i[package_json] def manager_name yarn? ? 'yarn' : 'npm' diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb index e3542b91d5c..61957ef4228 100644 --- a/app/models/blob_viewer/pdf.rb +++ b/app/models/blob_viewer/pdf.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'pdf' - self.extensions = %w(pdf) + self.extensions = %w[pdf] self.binary = true self.switcher_icon = 'document' self.switcher_title = 'PDF' diff --git a/app/models/blob_viewer/podfile.rb b/app/models/blob_viewer/podfile.rb index 73d714f48ca..dcabcfc4d57 100644 --- a/app/models/blob_viewer/podfile.rb +++ b/app/models/blob_viewer/podfile.rb @@ -4,7 +4,7 @@ module BlobViewer class Podfile < DependencyManager include Static - self.file_types = %i(podfile) + self.file_types = %i[podfile] def manager_name 'CocoaPods' diff --git a/app/models/blob_viewer/podspec.rb b/app/models/blob_viewer/podspec.rb index 2303471583d..50ca3f5bd16 100644 --- a/app/models/blob_viewer/podspec.rb +++ b/app/models/blob_viewer/podspec.rb @@ -4,7 +4,7 @@ module BlobViewer class Podspec < DependencyManager include ServerSide - self.file_types = %i(podspec) + self.file_types = %i[podspec] def manager_name 'CocoaPods' diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb index d606f72376d..03e680e2a8b 100644 --- a/app/models/blob_viewer/podspec_json.rb +++ b/app/models/blob_viewer/podspec_json.rb @@ -2,7 +2,7 @@ module BlobViewer class PodspecJson < Podspec - self.file_types = %i(podspec_json) + self.file_types = %i[podspec_json] def package_name @package_name ||= fetch_from_json('name') diff --git a/app/models/blob_viewer/readme.rb b/app/models/blob_viewer/readme.rb index f1a5c6a6acc..ec84977d8c5 100644 --- a/app/models/blob_viewer/readme.rb +++ b/app/models/blob_viewer/readme.rb @@ -6,7 +6,7 @@ module BlobViewer include Static self.partial_name = 'readme' - self.file_types = %i(readme) + self.file_types = %i[readme] self.binary = false def visible_to?(current_user) diff --git a/app/models/blob_viewer/requirements_txt.rb b/app/models/blob_viewer/requirements_txt.rb index 58161e83493..7322e416c4c 100644 --- a/app/models/blob_viewer/requirements_txt.rb +++ b/app/models/blob_viewer/requirements_txt.rb @@ -4,7 +4,7 @@ module BlobViewer class RequirementsTxt < DependencyManager include Static - self.file_types = %i(requirements_txt) + self.file_types = %i[requirements_txt] def manager_name 'pip' diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb index 6731536dfe1..a8c64bd5e6a 100644 --- a/app/models/blob_viewer/route_map.rb +++ b/app/models/blob_viewer/route_map.rb @@ -7,7 +7,7 @@ module BlobViewer self.partial_name = 'route_map' self.loading_partial_name = 'route_map_loading' - self.file_types = %i(route_map) + self.file_types = %i[route_map] self.binary = false def validation_message diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb index 90bc9be29f4..b7b1d412eff 100644 --- a/app/models/blob_viewer/sketch.rb +++ b/app/models/blob_viewer/sketch.rb @@ -6,7 +6,7 @@ module BlobViewer include ClientSide self.partial_name = 'sketch' - self.extensions = %w(sketch) + self.extensions = %w[sketch] self.binary = true self.switcher_icon = 'doc-image' self.switcher_title = 'preview' diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb index 60a11fbd97e..afcd3a7c735 100644 --- a/app/models/blob_viewer/svg.rb +++ b/app/models/blob_viewer/svg.rb @@ -6,7 +6,7 @@ module BlobViewer include ServerSide self.partial_name = 'svg' - self.extensions = %w(svg) + self.extensions = %w[svg] self.binary = false self.switcher_icon = 'doc-image' self.switcher_title = 'image' diff --git a/app/models/blob_viewer/yarn_lock.rb b/app/models/blob_viewer/yarn_lock.rb index 196d9f96f23..75369370602 100644 --- a/app/models/blob_viewer/yarn_lock.rb +++ b/app/models/blob_viewer/yarn_lock.rb @@ -4,7 +4,7 @@ module BlobViewer class YarnLock < DependencyManager include Static - self.file_types = %i(yarn_lock) + self.file_types = %i[yarn_lock] def manager_name 'Yarn' diff --git a/app/models/bulk_imports/batch_tracker.rb b/app/models/bulk_imports/batch_tracker.rb index 2e79d41d46e..eb7fe9f9913 100644 --- a/app/models/bulk_imports/batch_tracker.rb +++ b/app/models/bulk_imports/batch_tracker.rb @@ -18,6 +18,8 @@ module BulkImports event :start do transition created: :started + # To avoid errors when re-starting a pipeline in case of network errors + transition started: :started end event :retry do diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 644673e249e..437118c36e8 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -196,6 +196,10 @@ class BulkImports::Entity < ApplicationRecord update!(has_failures: true) end + def source_version + @source_version ||= bulk_import.source_version_info + end + private def validate_parent_is_a_group @@ -240,7 +244,9 @@ class BulkImports::Entity < ApplicationRecord errors.add( :source_full_path, - Gitlab::Regex.bulk_import_source_full_path_regex_message + s_('BulkImport|must have a relative path structure with no HTTP ' \ + 'protocol characters, or leading or trailing forward slashes. Path segments must not start or ' \ + 'end with a special character, and must not contain consecutive special characters') ) end end diff --git a/app/models/bulk_imports/file_transfer/group_config.rb b/app/models/bulk_imports/file_transfer/group_config.rb index 6766c00246b..67d53056444 100644 --- a/app/models/bulk_imports/file_transfer/group_config.rb +++ b/app/models/bulk_imports/file_transfer/group_config.rb @@ -3,7 +3,7 @@ module BulkImports module FileTransfer class GroupConfig < BaseConfig - SKIPPED_RELATIONS = %w(members).freeze + SKIPPED_RELATIONS = %w[members].freeze def import_export_yaml ::Gitlab::ImportExport.group_config_file diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb index 8d4c68f7b5a..890a0fb6ee4 100644 --- a/app/models/bulk_imports/file_transfer/project_config.rb +++ b/app/models/bulk_imports/file_transfer/project_config.rb @@ -3,10 +3,10 @@ module BulkImports module FileTransfer class ProjectConfig < BaseConfig - SKIPPED_RELATIONS = %w( + SKIPPED_RELATIONS = %w[ project_members group_members - ).freeze + ].freeze LFS_OBJECTS_RELATION = 'lfs_objects' REPOSITORY_BUNDLE_RELATION = 'repository' diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index cda19273f52..d3fbfe3aa55 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -3,9 +3,6 @@ class ChatName < ApplicationRecord LAST_USED_AT_INTERVAL = 1.hour - include IgnorableColumns - ignore_column :integration_id, remove_with: '16.0', remove_after: '2023-04-22' - belongs_to :user validates :user, presence: true diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 7a623b0cefb..2abb8e4be48 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -165,7 +165,10 @@ module Ci scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where("#{quoted_table_name}.id = #{Ci::BuildTraceChunk.quoted_table_name}.build_id").select(1)) } scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) } scope :finished_before, -> (date) { finished.where('finished_at < ?', date) } - scope :license_management_jobs, -> { where(name: %i(license_management license_scanning)) } # handle license rename https://gitlab.com/gitlab-org/gitlab/issues/8911 + scope :license_management_jobs, -> { where(name: %i[license_management license_scanning]) } # handle license rename https://gitlab.com/gitlab-org/gitlab/issues/8911 + # WARNING: This scope could lead to performance implications for large size of tables `ci_builds` and ci_runners`. + # 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 :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) @@ -388,6 +391,9 @@ module Ci name == 'pages' end + # overridden on EE + def pages_path_prefix; end + def runnable? true end @@ -408,7 +414,7 @@ module Ci end def options_scheduled_at - ChronicDuration.parse(options[:start_in])&.seconds&.from_now + ChronicDuration.parse(options[:start_in], use_complete_matcher: true)&.seconds&.from_now end def action? @@ -487,10 +493,7 @@ module Ci Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless persisted? && persisted_environment.present? - variables.concat(persisted_environment.predefined_variables) - - variables.append(key: 'CI_ENVIRONMENT_ACTION', value: environment_action) - variables.append(key: 'CI_ENVIRONMENT_TIER', value: environment_tier) + variables.append(key: 'CI_ENVIRONMENT_SLUG', value: environment_slug) # Here we're passing unexpanded environment_url for runner to expand, # and we need to make sure that CI_ENVIRONMENT_NAME and @@ -735,7 +738,7 @@ module Ci def artifacts_expire_in=(value) self.artifacts_expire_at = if value - ChronicDuration.parse(value)&.seconds&.from_now + ChronicDuration.parse(value, use_complete_matcher: true)&.seconds&.from_now end end @@ -1039,6 +1042,13 @@ module Ci end end + def time_in_queue_seconds + return if queued_at.nil? + + (::Time.current - queued_at).seconds.to_i + end + strong_memoize_attr :time_in_queue_seconds + protected def run_status_commit_hooks! diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 317f2523f69..00241908644 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -7,15 +7,16 @@ module Ci include SafelyChangeColumnDefault include BulkInsertSafe + MAX_JOB_NAME_LENGTH = 128 + columns_changing_default :partition_id - ignore_column :id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22' belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs partitionable scope: :build validates :build, presence: true - validates :name, presence: true, length: { maximum: 128 } + validates :name, presence: true, length: { maximum: MAX_JOB_NAME_LENGTH } validates :optional, inclusion: { in: [true, false] } scope :scoped_build, -> { where("#{Ci::Build.quoted_table_name}.id = #{quoted_table_name}.build_id") } diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index eaa2e1c428e..e197217bb70 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -20,7 +20,7 @@ module Ci partitionable scope: :build validates :build, presence: true - validates :url, public_url: { schemes: %w(https) } + validates :url, public_url: { schemes: %w[https] } def terminal_specification wss_url = Gitlab::UrlHelpers.as_wss(Addressable::URI.escape(url)) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3a5db04a687..5bf4e846304 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -162,7 +162,7 @@ module Ci validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? - validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create + validates :source, exclusion: { in: %w[unknown], unless: :importing? }, on: :create after_create :keep_around_commits, unless: :importing? after_find :observe_age_in_minutes, unless: :importing? diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 8d93429fd24..91c919dc662 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -52,7 +52,7 @@ module Ci RUNNER_QUEUE_EXPIRY_TIME = 1.hour # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated - UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze + UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes) # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner will be considered stale STALE_TIMEOUT = 3.months @@ -532,7 +532,9 @@ module Ci 'virtualbox' => :virtualbox, 'docker+machine' => :docker_machine, 'docker-ssh+machine' => :docker_ssh_machine, - 'kubernetes' => :kubernetes + 'kubernetes' => :kubernetes, + 'docker-autoscaler' => :docker_autoscaler, + 'instance' => :instance }.freeze EXECUTOR_TYPE_TO_NAMES = EXECUTOR_NAME_TO_TYPES.invert.freeze @@ -552,9 +554,7 @@ module Ci end def cleanup_runner_queue - Gitlab::Redis::SharedState.with do |redis| - redis.del(runner_queue_key) - end + ::Gitlab::Workhorse.cleanup_key(runner_queue_key) end def runner_queue_key diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 8dc866929f3..cbea7efc70e 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -50,7 +50,7 @@ module Clusters end def connected? - agent_tokens.active.where("last_used_at > ?", INACTIVE_AFTER.ago).exists? + agent_tokens.connected.exists? end def activity_event_deletion_cutoff diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index b2b13f6cef7..f4c497a42cc 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -2,10 +2,15 @@ module Clusters class AgentToken < ApplicationRecord + TOKEN_PREFIX = "glagent-" + include RedisCacheable include TokenAuthenticatable - add_authentication_token_field :token, encrypted: :required, token_generator: -> { Devise.friendly_token(50) } + add_authentication_token_field :token, + encrypted: :required, + token_generator: -> { Devise.friendly_token(50) }, + format_with_prefix: :glagent_prefix cached_attr_reader :last_used_at self.table_name = 'cluster_agent_tokens' @@ -21,6 +26,7 @@ module Clusters scope :order_last_used_at_desc, -> { order(arel_table[:last_used_at].desc.nulls_last) } scope :with_status, -> (status) { where(status: status) } scope :active, -> { where(status: :active) } + scope :connected, -> { active.where("last_used_at > ?", Clusters::Agent::INACTIVE_AFTER.ago) } enum status: { active: 0, @@ -30,5 +36,9 @@ module Clusters def to_ability_name :cluster end + + def glagent_prefix + TOKEN_PREFIX + end end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 123ad0ebfaf..5efbec45561 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -8,7 +8,7 @@ module Clusters include ReactiveCaching include NullifyIfBlank - RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze + RESERVED_NAMESPACES = %w[gitlab-managed-apps].freeze REQUIRED_K8S_MIN_VERSION = 23 IGNORED_CONNECTION_EXCEPTIONS = [ diff --git a/app/models/commit.rb b/app/models/commit.rb index d7aa66588d3..39e12b53f21 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -30,10 +30,10 @@ class Commit MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH MAX_SHA_LENGTH = Gitlab::Git::Commit::MAX_SHA_LENGTH - COMMIT_SHA_PATTERN = Gitlab::Git::Commit::SHA_PATTERN.freeze - EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/.freeze + COMMIT_SHA_PATTERN = Gitlab::Git::Commit::SHA_PATTERN + EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/ # Used by GFM to match and present link extensions on node texts and hrefs. - LINK_EXTENSION_PATTERN = /(patch)/.freeze + LINK_EXTENSION_PATTERN = /(patch)/ DEFAULT_MAX_DIFF_LINES_SETTING = 50_000 DEFAULT_MAX_DIFF_FILES_SETTING = 1_000 @@ -432,7 +432,7 @@ class Commit end def cherry_pick_message(user) - %{#{message}\n\n#{cherry_pick_description(user)}} + %(#{message}\n\n#{cherry_pick_description(user)}) end def revert_description(user) @@ -444,7 +444,7 @@ class Commit end def revert_message(user) - %{Revert "#{title.strip}"\n\n#{revert_description(user)}} + %(Revert "#{title.strip}"\n\n#{revert_description(user)}) end def reverts_commit?(commit, user) @@ -539,7 +539,7 @@ class Commit # added by `git commit --fixup` which is used by some community members. # https://gitlab.com/gitlab-org/gitlab/-/issues/342937#note_892065311 # - DRAFT_REGEX = /\A\s*#{Gitlab::Regex.merge_request_draft}|(fixup!|squash!)\s/.freeze + DRAFT_REGEX = /\A\s*#{Gitlab::Regex.merge_request_draft}|(fixup!|squash!)\s/ def draft? !!(title =~ DRAFT_REGEX) @@ -554,10 +554,10 @@ class Commit "commit:#{sha}" end - def expire_note_etag_cache + def broadcast_notes_changed super - expire_note_etag_cache_for_related_mrs + broadcast_notes_changed_for_related_mrs end def readable_by?(user) @@ -614,8 +614,8 @@ class Commit end end - def expire_note_etag_cache_for_related_mrs - MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each(&:expire_note_etag_cache) + def broadcast_notes_changed_for_related_mrs + MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each(&:broadcast_notes_changed) end def commit_reference(from, referable_commit_id, full: false) diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index d882a185464..cb24297f2c8 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -28,11 +28,11 @@ class CommitRange # The beginning and ending refs can be named or SHAs, and # the range notation can be double- or triple-dot. - REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/.freeze - PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/.freeze + REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/ + PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/ # In text references, the beginning and ending refs can only be valid SHAs. - STRICT_PATTERN = /#{Gitlab::Git::Commit::RAW_SHA_PATTERN}\.{2,3}#{Gitlab::Git::Commit::RAW_SHA_PATTERN}/.freeze + STRICT_PATTERN = /#{Gitlab::Git::Commit::RAW_SHA_PATTERN}\.{2,3}#{Gitlab::Git::Commit::RAW_SHA_PATTERN}/ def self.reference_prefix '@' diff --git a/app/models/commit_signatures/gpg_signature.rb b/app/models/commit_signatures/gpg_signature.rb index a9e8ca2dd33..45937b68691 100644 --- a/app/models/commit_signatures/gpg_signature.rb +++ b/app/models/commit_signatures/gpg_signature.rb @@ -3,6 +3,7 @@ module CommitSignatures class GpgSignature < ApplicationRecord include CommitSignature include SignatureType + include EachBatch sha_attribute :gpg_key_primary_keyid diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index c2425e9460a..3761aa81bf7 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -9,16 +9,19 @@ class CommitStatus < Ci::ApplicationRecord include BulkInsertableAssociations include TaggableQueries - ROUTING_FEATURE_FLAG = :ci_partitioning_use_ci_builds_routing_table + def self.switch_table_names + if Gitlab::Utils.to_boolean(ENV['USE_CI_BUILDS_ROUTING_TABLE']) + :p_ci_builds + else + :ci_builds + end + end - self.table_name = 'ci_builds' - self.sequence_name = 'ci_builds_id_seq' + self.table_name = self.switch_table_names + self.sequence_name = :ci_builds_id_seq self.primary_key = :id - partitionable scope: :pipeline, through: { - table: :p_ci_builds, - flag: ROUTING_FEATURE_FLAG - } + partitionable scope: :pipeline belongs_to :user belongs_to :project diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index f419fa8518e..e342939b3d6 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -48,12 +48,6 @@ module Avatarable end end - class_methods do - def bot_avatar(image:) - Rails.root.join('lib', 'assets', 'images', 'bot_avatars', image).open - end - end - def avatar_type unless self.avatar.image? errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::SAFE_IMAGE_EXT.join(', ')}" diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb index af4905115b1..7b7b61fdf06 100644 --- a/app/models/concerns/chronic_duration_attribute.rb +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -17,7 +17,12 @@ module ChronicDurationAttribute chronic_duration_attributes[virtual_attribute] = value.presence || parameters[:default].presence.to_s begin - new_value = value.present? ? ChronicDuration.parse(value).to_i : parameters[:default].presence + new_value = if value.present? + ChronicDuration.parse(value, use_complete_matcher: true).to_i + else + parameters[:default].presence + end + assign_attributes(source_attribute => new_value) rescue ChronicDuration::DurationParseError # ignore error as it will be caught by validation diff --git a/app/models/concerns/ci/deployable.rb b/app/models/concerns/ci/deployable.rb index b3b80989410..d25151f9a34 100644 --- a/app/models/concerns/ci/deployable.rb +++ b/app/models/concerns/ci/deployable.rb @@ -138,7 +138,11 @@ module Ci end def environment_url - options&.dig(:environment, :url) || persisted_environment&.external_url + options&.dig(:environment, :url) || persisted_environment.try(:external_url) + end + + def environment_slug + persisted_environment.try(:slug) end def environment_status diff --git a/app/models/concerns/ci/has_runner_executor.rb b/app/models/concerns/ci/has_runner_executor.rb index dc70cdb2018..6d4622945fe 100644 --- a/app/models/concerns/ci/has_runner_executor.rb +++ b/app/models/concerns/ci/has_runner_executor.rb @@ -17,7 +17,9 @@ module Ci virtualbox: 8, docker_machine: 9, docker_ssh_machine: 10, - kubernetes: 11 + kubernetes: 11, + docker_autoscaler: 12, + instance: 13 }, _suffix: true end end diff --git a/app/models/concerns/ci/maskable.rb b/app/models/concerns/ci/maskable.rb index e2cef0981d1..15240385dd8 100644 --- a/app/models/concerns/ci/maskable.rb +++ b/app/models/concerns/ci/maskable.rb @@ -11,12 +11,12 @@ module Ci # * Minimal length of 8 characters # * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':', '.', and '~' # * Absolutely no fun is allowed - REGEX = %r{\A[a-zA-Z0-9_+=/@:.~-]{8,}\z}.freeze + REGEX = %r{\A[a-zA-Z0-9_+=/@:.~-]{8,}\z} # * Single line # * No spaces # * Minimal length of 8 characters # * Some fun is allowed - MASK_AND_RAW_REGEX = %r{\A\S{8,}\z}.freeze + MASK_AND_RAW_REGEX = %r{\A\S{8,}\z} included do validates :masked, inclusion: { in: [true, false] } diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index ec6c85d888d..c4b1281fa72 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -107,7 +107,10 @@ module Ci partitioned_by :partition_id, strategy: :ci_sliding_list, next_partition_if: proc { false }, - detach_partition_if: proc { false } + detach_partition_if: proc { false }, + # Most of the db tasks are run in a weekly basis, e.g. execute_batched_migrations. + # Therefore, let's start with 1.week and see how it'd go. + analyze_interval: 1.week end end end diff --git a/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb b/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb index eef68bfd349..9528a708ee1 100644 --- a/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb +++ b/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb @@ -17,7 +17,7 @@ module Clusters class_methods do def available_ci_access_fields(_project) - %w(agent) + %w[agent] end end end diff --git a/app/models/concerns/cross_database_ignored_tables.rb b/app/models/concerns/cross_database_ignored_tables.rb index c97e405cce4..14a9703a734 100644 --- a/app/models/concerns/cross_database_ignored_tables.rb +++ b/app/models/concerns/cross_database_ignored_tables.rb @@ -4,6 +4,12 @@ module CrossDatabaseIgnoredTables extend ActiveSupport::Concern class_methods do + def temporary_ignore_cross_database_tables(tables, url:, &blk) + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + tables, url: url, &blk + ) + end + def cross_database_ignore_tables(tables, options = {}) raise "missing issue url" if options[:url].blank? @@ -40,8 +46,7 @@ module CrossDatabaseIgnoredTables return yield unless options[:if].nil? || instance_eval(&options[:if]) url = options[:url] - Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( - tables, url: url, &blk - ) + + self.class.temporary_ignore_cross_database_tables(tables, url: url, &blk) end end diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index b10b318fb7c..2f64129b65f 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -14,7 +14,7 @@ module DiffPositionableNote validates :position, json_schema: { filename: "position", hash_conversion: true } end - %i(original_position position change_position).each do |meth| + %i[original_position position change_position].each do |meth| define_method "#{meth}=" do |new_position| if new_position.is_a?(String) new_position = begin diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 945d286a2fd..0c8cf861c38 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -54,7 +54,7 @@ module EachBatch 'the column: argument must be set to a column name to use for ordering rows' end - start = except(:select) + start = except(:select, :includes, :preload) .select(column) .reorder(column => order) @@ -69,7 +69,7 @@ module EachBatch 1.step do |index| start_cond = arel_table[column].gteq(start_id) start_cond = arel_table[column].lteq(start_id) if order == :desc - stop = except(:select) + stop = except(:select, :includes, :preload) .select(column) .where(start_cond) .reorder(column => order) diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb index 2e49e720ac9..be9858bf49b 100644 --- a/app/models/concerns/editable.rb +++ b/app/models/concerns/editable.rb @@ -8,6 +8,6 @@ module Editable end def last_edited_by - super || User.ghost + super || Users::Internal.ghost end end diff --git a/app/models/concerns/enums/prometheus_metric.rb b/app/models/concerns/enums/prometheus_metric.rb index e65a01990a3..2cc765b7a3c 100644 --- a/app/models/concerns/enums/prometheus_metric.rb +++ b/app/models/concerns/enums/prometheus_metric.rb @@ -30,37 +30,37 @@ module Enums # built-in groups nginx_ingress_vts: { group_title: _('Response metrics (NGINX Ingress VTS)'), - required_metrics: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg), + required_metrics: %w[nginx_upstream_responses_total nginx_upstream_response_msecs_avg], priority: 10 }.freeze, nginx_ingress: { group_title: _('Response metrics (NGINX Ingress)'), - required_metrics: %w(nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum), + required_metrics: %w[nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum], priority: 10 }.freeze, ha_proxy: { group_title: _('Response metrics (HA Proxy)'), - required_metrics: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total), + required_metrics: %w[haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total], priority: 10 }.freeze, aws_elb: { group_title: _('Response metrics (AWS ELB)'), - required_metrics: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum), + required_metrics: %w[aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum], priority: 10 }.freeze, nginx: { group_title: _('Response metrics (NGINX)'), - required_metrics: %w(nginx_server_requests nginx_server_requestMsec), + required_metrics: %w[nginx_server_requests nginx_server_requestMsec], priority: 10 }.freeze, kubernetes: { group_title: _('System metrics (Kubernetes)'), - required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total), + required_metrics: %w[container_memory_usage_bytes container_cpu_usage_seconds_total], priority: 5 }.freeze, cluster_health: { group_title: _('Cluster Health'), - required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total), + required_metrics: %w[container_memory_usage_bytes container_cpu_usage_seconds_total], priority: 10 }.freeze }.merge(custom_group_details).freeze diff --git a/app/models/concerns/has_unique_internal_users.rb b/app/models/concerns/has_unique_internal_users.rb deleted file mode 100644 index 25b56f6d70f..00000000000 --- a/app/models/concerns/has_unique_internal_users.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module HasUniqueInternalUsers - extend ActiveSupport::Concern - - class_methods do - private - - def unique_internal(scope, username, email_pattern, &block) - scope.first || create_unique_internal(scope, username, email_pattern, &block) - end - - def create_unique_internal(scope, username, email_pattern, &creation_block) - # Since we only want a single one of these in an instance, we use an - # exclusive lease to ensure than this block is never run concurrently. - lease_key = "user:unique_internal:#{username}" - lease = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.minute.to_i) - - until uuid = lease.try_obtain - # Keep trying until we obtain the lease. To prevent hammering Redis too - # much we'll wait for a bit between retries. - sleep(1) - end - - # Recheck if the user is already present. One might have been - # added between the time we last checked (first line of this method) - # and the time we acquired the lock. - existing_user = uncached { scope.first } - return existing_user if existing_user.present? - - uniquify = Gitlab::Utils::Uniquify.new - - username = uniquify.string(username) { |s| User.find_by_username(s) } - - email = uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s| - User.find_by_email(s) - end - - user = scope.build( - username: username, - email: email, - &creation_block - ) - - Users::UpdateService.new(user, user: user).execute(validate: false) - user - ensure - Gitlab::ExclusiveLease.cancel(lease_key, uuid) - end - end -end diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 2d0ff82e624..c3f702a4e69 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -74,4 +74,21 @@ module HasUserType # https://gitlab.com/gitlab-org/gitlab/-/issues/346058 '****' end + + def resource_bot_resource + return unless project_bot? + + projects&.first || groups&.first + end + + def resource_bot_owners + return [] unless project_bot? + + resource = resource_bot_resource + return [] unless resource + + return resource.maintainers if resource.is_a?(Project) + + resource.owners + end end diff --git a/app/models/concerns/integrations/enable_ssl_verification.rb b/app/models/concerns/integrations/enable_ssl_verification.rb index 11dc8a76a2b..9735a9bf5f6 100644 --- a/app/models/concerns/integrations/enable_ssl_verification.rb +++ b/app/models/concerns/integrations/enable_ssl_verification.rb @@ -19,13 +19,16 @@ module Integrations url_index = fields.index { |field| field[:name].ends_with?('_url') } insert_index = url_index ? url_index + 1 : -1 - fields.insert(insert_index, { - type: 'checkbox', - name: 'enable_ssl_verification', - title: s_('Integrations|SSL verification'), - checkbox_label: s_('Integrations|Enable SSL verification'), - help: s_('Integrations|Clear if using a self-signed certificate.') - }) + fields.insert(insert_index, + Field.new( + name: 'enable_ssl_verification', + integration_class: self, + type: :checkbox, + title: s_('Integrations|SSL verification'), + checkbox_label: s_('Integrations|Enable SSL verification'), + help: s_('Integrations|Clear if using a self-signed certificate.') + ) + ) end end end diff --git a/app/models/concerns/integrations/reset_secret_fields.rb b/app/models/concerns/integrations/reset_secret_fields.rb index f79c4392f19..24d716fe5dd 100644 --- a/app/models/concerns/integrations/reset_secret_fields.rb +++ b/app/models/concerns/integrations/reset_secret_fields.rb @@ -12,9 +12,7 @@ module Integrations end def exposing_secrets_fields - # TODO: Once all integrations use `Integrations::Field` we can remove the `.try` here. - # See: https://gitlab.com/groups/gitlab-org/-/epics/7652 - fields.select { _1.try(:exposes_secrets) }.pluck(:name) + fields.select(&:exposes_secrets).pluck(:name) end private diff --git a/app/models/concerns/integrations/slack_mattermost_fields.rb b/app/models/concerns/integrations/slack_mattermost_fields.rb new file mode 100644 index 00000000000..a8e63c4e405 --- /dev/null +++ b/app/models/concerns/integrations/slack_mattermost_fields.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Integrations + module SlackMattermostFields + extend ActiveSupport::Concern + + included do + field :webhook, + help: -> { webhook_help }, + required: true, + if: -> { requires_webhook? } + + field :username, + placeholder: 'GitLab-integration', + if: -> { requires_webhook? } + + field :notify_only_broken_pipelines, + type: :checkbox, + section: Integration::SECTION_TYPE_CONFIGURATION, + 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') }, + choices: -> { branch_choices } + + field :labels_to_be_notified, + section: Integration::SECTION_TYPE_CONFIGURATION, + 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.' + + field :labels_to_be_notified_behavior, + type: :select, + section: Integration::SECTION_TYPE_CONFIGURATION, + choices: [ + ['Match any of the labels', Integrations::BaseChatNotification::MATCH_ANY_LABEL], + ['Match all of the labels', Integrations::BaseChatNotification::MATCH_ALL_LABELS] + ] + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 9a513ea0e5b..a9a00ab1c44 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -19,6 +19,7 @@ module Issuable include Awardable include Taskable include Importable + include Transitionable include Editable include AfterCommitQueue include Sortable @@ -33,7 +34,7 @@ module Issuable TITLE_HTML_LENGTH_MAX = 800 DESCRIPTION_LENGTH_MAX = 1.megabyte DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes - SEARCHABLE_FIELDS = %w(title description).freeze + SEARCHABLE_FIELDS = %w[title description].freeze MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 200 STATE_ID_MAP = { @@ -225,6 +226,10 @@ module Issuable false end + def supports_lock_on_merge? + false + end + def severity return IssuableSeverity::DEFAULT unless supports_severity? @@ -235,6 +240,10 @@ module Issuable super + [:notes] end + def importing_or_transitioning? + importing? || transitioning? + end + private def validate_description_length? @@ -408,14 +417,14 @@ module Issuable sort = sort.to_s grouping_columns = [arel_table[:id]] - if %w(milestone_due_desc milestone_due_asc milestone).include?(sort) + if %w[milestone_due_desc milestone_due_asc milestone].include?(sort) milestone_table = Milestone.arel_table grouping_columns << milestone_table[:id] grouping_columns << milestone_table[:due_date] - elsif %w(merged_at_desc merged_at_asc merged_at).include?(sort) + elsif %w[merged_at_desc merged_at_asc merged_at].include?(sort) grouping_columns << MergeRequest::Metrics.arel_table[:id] grouping_columns << MergeRequest::Metrics.arel_table[:merged_at] - elsif %w(closed_at_desc closed_at_asc closed_at).include?(sort) + elsif %w[closed_at_desc closed_at_asc closed_at].include?(sort) grouping_columns << MergeRequest::Metrics.arel_table[:id] grouping_columns << MergeRequest::Metrics.arel_table[:latest_closed_at] end diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index 3f65e701da7..2969f1e1928 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -10,10 +10,10 @@ module IssueAvailableFeatures # EE only features are listed on EE::IssueAvailableFeatures def available_features_for_issue_types { - assignee: %w(issue incident), - confidentiality: %w(issue incident), - time_tracking: %w(issue incident), - move_and_clone: %w(issue incident) + assignee: %w[issue incident], + confidentiality: %w[issue incident objective key_result], + time_tracking: %w[issue incident], + move_and_clone: %w[issue incident] }.with_indifferent_access end end diff --git a/app/models/concerns/linkable_item.rb b/app/models/concerns/linkable_item.rb index 135252727ab..c91e3615ba7 100644 --- a/app/models/concerns/linkable_item.rb +++ b/app/models/concerns/linkable_item.rb @@ -16,6 +16,7 @@ module LinkableItem scope :for_source, ->(item) { where(source_id: item.id) } scope :for_target, ->(item) { where(target_id: item.id) } + scope :for_source_and_target, ->(source, target) { where(source: source, target: target) } scope :for_items, ->(source, target) do where(source: source, target: target).or(where(source: target, target: source)) end diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index 0b6075fbeb8..b5634ba3b6d 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -28,7 +28,7 @@ module Mentionable def self.external_pattern strong_memoize(:external_pattern) do issue_pattern = Integrations::BaseIssueTracker.base_reference_pattern - link_patterns = URI::DEFAULT_PARSER.make_regexp(%w(http https)) + link_patterns = URI::DEFAULT_PARSER.make_regexp(%w[http https]) reference_pattern(link_patterns, issue_pattern) end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 40a91c8ac94..06cee46645b 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -12,17 +12,17 @@ module Noteable class_methods do # `Noteable` class names that support replying to individual notes. def replyable_types - %w(Issue MergeRequest) + %w[Issue MergeRequest] end # `Noteable` class names that support resolvable notes. def resolvable_types - %w(Issue MergeRequest DesignManagement::Design) + %w[Issue MergeRequest DesignManagement::Design] end # `Noteable` class names that support creating/forwarding individual notes. def email_creatable_types - %w(Issue) + %w[Issue] end end @@ -164,28 +164,15 @@ module Noteable [MergeRequest, Issue].include?(self.class) end - def etag_caching_enabled? + def real_time_notes_enabled? false end - def expire_note_etag_cache + def broadcast_notes_changed return unless discussions_rendered_on_frontend? - return unless etag_caching_enabled? + return unless real_time_notes_enabled? - # TODO: We need to figure out a way to make ETag caching work for group-level work items - Gitlab::EtagCaching::Store.new.touch(note_etag_key) unless is_a?(Issue) && project.nil? - - Noteable::NotesChannel.broadcast_to(self, event: 'updated') if Feature.enabled?(:action_cable_notes, project || try(:group)) - end - - def note_etag_key - return Gitlab::Routing.url_helpers.designs_project_issue_path(project, issue, { vueroute: filename }) if self.is_a?(DesignManagement::Design) - - Gitlab::Routing.url_helpers.project_noteable_notes_path( - project, - target_type: noteable_target_type_name, - target_id: id - ) + Noteable::NotesChannel.broadcast_to(self, event: 'updated') end def after_note_created(_note) diff --git a/app/models/concerns/packages/nuget/version_normalizable.rb b/app/models/concerns/packages/nuget/version_normalizable.rb index 473e5f07811..4bcfec89570 100644 --- a/app/models/concerns/packages/nuget/version_normalizable.rb +++ b/app/models/concerns/packages/nuget/version_normalizable.rb @@ -13,7 +13,7 @@ module Packages private def set_normalized_version - return unless package && Feature.enabled?(:nuget_normalized_version, package.project) + return unless package self.normalized_version = normalize end diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb index 562c8cf23f3..b7ca6f61573 100644 --- a/app/models/concerns/pg_full_text_searchable.rb +++ b/app/models/concerns/pg_full_text_searchable.rb @@ -21,11 +21,11 @@ module PgFullTextSearchable extend ActiveSupport::Concern - LONG_WORDS_REGEX = %r([A-Za-z0-9+/@]{50,}).freeze + LONG_WORDS_REGEX = %r([A-Za-z0-9+/@]{50,}) TSVECTOR_MAX_LENGTH = 1.megabyte.freeze TEXT_SEARCH_DICTIONARY = 'english' - URL_SCHEME_REGEX = %r{(?<=\A|\W)\w+://(?=\w+)}.freeze - TSQUERY_DISALLOWED_CHARACTERS_REGEX = %r{[^a-zA-Z0-9 .@/\-_"]}.freeze + URL_SCHEME_REGEX = %r{(?<=\A|\W)\w+://(?=\w+)} + TSQUERY_DISALLOWED_CHARACTERS_REGEX = %r{[^a-zA-Z0-9 .@/\-_"]} def update_search_data! tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight| diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index a87eadb9332..ea8a1640bea 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -3,6 +3,8 @@ module ProtectedRef extend ActiveSupport::Concern + include Importable + included do belongs_to :project, touch: true @@ -32,12 +34,13 @@ module ProtectedRef # to fail. has_many :"#{type}_access_levels", inverse_of: self.model_name.singular + # Overridden in EE with `if: -> { false }` so this validation does not apply on an EE instance. validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }, - unless: -> { allow_multiple?(type) } + unless: -> { allow_multiple?(type) || importing? } accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true end diff --git a/app/models/concerns/redactable.rb b/app/models/concerns/redactable.rb index 53ae300ee2d..5ad96d6cc46 100644 --- a/app/models/concerns/redactable.rb +++ b/app/models/concerns/redactable.rb @@ -10,7 +10,7 @@ module Redactable extend ActiveSupport::Concern - UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe}.freeze + UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe} class_methods do def redact_field(field) diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb index d7182778b36..6581928f637 100644 --- a/app/models/concerns/require_email_verification.rb +++ b/app/models/concerns/require_email_verification.rb @@ -7,10 +7,7 @@ module RequireEmailVerification extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize - # This value is twice the amount we want it to be, because due to a bug in the devise-two-factor - # gem every failed login attempt increments the value of failed_attempts by 2 instead of 1. - # See: https://github.com/tinfoil/devise-two-factor/issues/127 - MAXIMUM_ATTEMPTS = 3 * 2 + MAXIMUM_ATTEMPTS = 3 UNLOCK_IN = 24.hours included do diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index e967c78154d..5c2f0aa04ac 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -116,7 +116,7 @@ module ResolvableDiscussion # Set the notes array to the updated notes @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables - noteable.expire_note_etag_cache + noteable.broadcast_notes_changed clear_memoized_values end diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb index 7f9a7faa3f5..23abc5d5c22 100644 --- a/app/models/concerns/resolvable_note.rb +++ b/app/models/concerns/resolvable_note.rb @@ -4,7 +4,7 @@ module ResolvableNote extend ActiveSupport::Concern # Names of all subclasses of `Note` that can be resolvable. - RESOLVABLE_TYPES = %w(DiffNote DiscussionNote).freeze + RESOLVABLE_TYPES = %w[DiffNote DiscussionNote].freeze included do belongs_to :resolved_by, class_name: "User" diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb index cf97be21165..6af9ede5e8b 100644 --- a/app/models/concerns/restricted_signup.rb +++ b/app/models/concerns/restricted_signup.rb @@ -84,3 +84,5 @@ module RestrictedSignup end end end + +::RestrictedSignup.prepend_mod diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index f2badfe48dd..ef14ff5fbe2 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -14,7 +14,17 @@ module Routable # Routable.find_by_full_path('groupname/projectname') # -> Project # # Returns a single object, or nil. - def self.find_by_full_path(path, follow_redirects: false, route_scope: Route, redirect_route_scope: RedirectRoute) + + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def self.find_by_full_path( + path, + follow_redirects: false, + route_scope: Route, + redirect_route_scope: RedirectRoute, + optimize_routable: Routable.optimize_routable_enabled? + ) + return unless path.present? # Convert path to string to prevent DB error: function lower(integer) does not exist @@ -25,20 +35,50 @@ module Routable # # We need to qualify the columns with the table name, to support both direct lookups on # Route/RedirectRoute, and scoped lookups through the Routable classes. - Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") do + if optimize_routable + path_condition = { path: path } + + source_type_condition = if route_scope == Route + {} + else + { source_type: route_scope.klass.base_class } + end + route = - route_scope.find_by(routes: { path: path }) || - route_scope.iwhere(Route.arel_table[:path] => path).take + Route.where(source_type_condition).find_by(path_condition) || + Route.where(source_type_condition).iwhere(path_condition).take if follow_redirects - route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take + route ||= RedirectRoute.where(source_type_condition).iwhere(path_condition).take end - next unless route + return unless route + return route.source if route_scope == Route + + route_scope.find_by(id: route.source_id) + else + Gitlab::Database.allow_cross_joins_across_databases(url: + "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") do + route = + route_scope.find_by(routes: { path: path }) || + route_scope.iwhere(Route.arel_table[:path] => path).take + + if follow_redirects + route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take + end - route.is_a?(Routable) ? route : route.source + next unless route + + route.is_a?(Routable) ? route : route.source + end end end + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity + + def self.optimize_routable_enabled? + Feature.enabled?(:optimize_routable) + end included do # Remove `inverse_of: source` when upgraded to rails 5.2 @@ -67,13 +107,22 @@ module Routable # # Returns a single object, or nil. def find_by_full_path(path, follow_redirects: false) - # TODO: Optimize these queries by avoiding joins - # https://gitlab.com/gitlab-org/gitlab/-/issues/292252 + optimize_routable = Routable.optimize_routable_enabled? + + if optimize_routable + route_scope = all + redirect_route_scope = RedirectRoute + else + route_scope = includes(:route).references(:routes) + redirect_route_scope = joins(:redirect_routes) + end + Routable.find_by_full_path( path, follow_redirects: follow_redirects, - route_scope: includes(:route).references(:routes), - redirect_route_scope: joins(:redirect_routes) + route_scope: route_scope, + redirect_route_scope: redirect_route_scope, + optimize_routable: optimize_routable ) end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index b73ed937b5d..5455a2159cd 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -17,8 +17,6 @@ module Storage Namespace.find(parent_id_before_last_save) # raise NotFound early if needed end - move_repositories - if saved_change_to_parent? former_parent_full_path = parent_was&.full_path parent_full_path = parent&.full_path diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index bf645e99b5e..96f684522d2 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -11,8 +11,8 @@ require 'task_list/filter' module Taskable COMPLETED = 'completed' INCOMPLETE = 'incomplete' - COMPLETE_PATTERN = /\[[xX]\]/.freeze - INCOMPLETE_PATTERN = /\[[[:space:]]\]/.freeze + COMPLETE_PATTERN = /\[[xX]\]/ + INCOMPLETE_PATTERN = /\[[[:space:]]\]/ ITEM_PATTERN = %r{ ^ (?:(?:>\s{0,4})*) # optional blockquote characters @@ -22,7 +22,7 @@ module Taskable #{COMPLETE_PATTERN}|#{INCOMPLETE_PATTERN} ) (\s.+) # followed by whitespace and some text. - }x.freeze + }x ITEM_PATTERN_UNTRUSTED = '^' \ diff --git a/app/models/concerns/transitionable.rb b/app/models/concerns/transitionable.rb new file mode 100644 index 00000000000..70e1fc8b78a --- /dev/null +++ b/app/models/concerns/transitionable.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Transitionable + extend ActiveSupport::Concern + + attr_accessor :transitioning + + def transitioning? + return false unless transitioning && Feature.enabled?(:skip_validations_during_transitions, project) + + true + end + + def enable_transitioning + self.transitioning = true + end + + def disable_transitioning + self.transitioning = false + end +end diff --git a/app/models/concerns/users/visitable.rb b/app/models/concerns/users/visitable.rb new file mode 100644 index 00000000000..cb8e5fdc682 --- /dev/null +++ b/app/models/concerns/users/visitable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Users + module Visitable + extend ActiveSupport::Concern + + included do + def self.visited_around?(entity_id:, user_id:, time:) + visits_around(entity_id: entity_id, user_id: user_id, time: time).any? + end + + def self.visits_around(entity_id:, user_id:, time:) + time = time.to_datetime + where(entity_id: entity_id, user_id: user_id, visited_at: (time - 15.minutes)..(time + 15.minutes)) + end + end + end +end diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index caaf2b33ef0..319509ea69a 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -22,7 +22,7 @@ module WithUploads # Currently there is no simple way how to select only not-mounted # uploads, it should be all FileUploaders so we select them by # `uploader` class - FILE_UPLOADERS = %w(PersonalFileUploader NamespaceFileUploader FileUploader).freeze + FILE_UPLOADERS = %w[PersonalFileUploader NamespaceFileUploader FileUploader].freeze included do around_destroy :ignore_uploads_table_in_transaction diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index aecb47f7a03..f643fa7730b 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -80,7 +80,9 @@ class ContainerExpirationPolicy < ApplicationRecord end def set_next_run_at - self.next_run_at = Time.zone.now + ChronicDuration.parse(cadence).seconds + cadence_seconds = ChronicDuration.parse(cadence, use_complete_matcher: true).seconds + + self.next_run_at = Time.zone.now + cadence_seconds end def disable! diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb index dd2675e17d8..9f7724c052c 100644 --- a/app/models/container_registry/event.rb +++ b/app/models/container_registry/event.rb @@ -4,25 +4,25 @@ module ContainerRegistry class Event include Gitlab::Utils::StrongMemoize - ALLOWED_ACTIONS = %w(push delete).freeze + ALLOWED_ACTIONS = %w[push delete].freeze PUSH_ACTION = 'push' DELETE_ACTION = 'delete' EVENT_TRACKING_CATEGORY = 'container_registry:notification' EVENT_PREFIX = 'i_container_registry' - ALLOWED_ACTOR_TYPES = %w( + ALLOWED_ACTOR_TYPES = %w[ personal_access_token build gitlab_or_ldap - ).freeze + ].freeze - TRACKABLE_ACTOR_EVENTS = %w( + TRACKABLE_ACTOR_EVENTS = %w[ push_tag delete_tag push_repository delete_repository create_repository - ).freeze + ].freeze attr_reader :event @@ -60,7 +60,7 @@ module ContainerRegistry def target_tag? # There is no clear indication in the event structure when we delete a top-level manifest - # except existance of "tag" key + # except existence of "tag" key event['target'].has_key?('tag') end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 625d68925c6..c704795130b 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class CustomEmoji < ApplicationRecord - NAME_REGEXP = /[a-z0-9_-]+/.freeze + NAME_REGEXP = /[a-z0-9_-]+/ belongs_to :namespace, inverse_of: :custom_emoji diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index f9fa4bd212c..de777b8ae53 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -43,7 +43,7 @@ class DeployKey < Key end def user - super || User.ghost + super || Users::Internal.ghost end def audit_details diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 498ca9c4f30..920321a1699 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -8,8 +8,8 @@ class DeployToken < ApplicationRecord add_authentication_token_field :token, encrypted: :required - AVAILABLE_SCOPES = %i(read_repository read_registry write_registry - read_package_registry write_package_registry).freeze + AVAILABLE_SCOPES = %i[read_repository read_registry write_registry + read_package_registry write_package_registry].freeze GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token' REQUIRED_DEPENDENCY_PROXY_SCOPES = %i[read_registry write_registry].freeze diff --git a/app/models/description_version.rb b/app/models/description_version.rb index fb61b7f5fde..05cca9f931f 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -9,7 +9,7 @@ class DescriptionVersion < ApplicationRecord delegate :resource_parent, to: :issuable def self.issuable_attrs - %i(issue merge_request).freeze + %i[issue merge_request].freeze end def issuable diff --git a/app/models/design_management.rb b/app/models/design_management.rb index 81e170f7e59..20ada71755b 100644 --- a/app/models/design_management.rb +++ b/app/models/design_management.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module DesignManagement - DESIGN_IMAGE_SIZES = %w(v432x230).freeze + DESIGN_IMAGE_SIZES = %w[v432x230].freeze def self.designs_directory 'designs' diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 02979d5f804..d680d0e334f 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -9,7 +9,7 @@ class DiffNote < Note include Gitlab::Utils::StrongMemoize def self.noteable_types - %w(MergeRequest Commit DesignManagement::Design) + %w[MergeRequest Commit DesignManagement::Design] end validates :original_position, presence: true diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb index 6621b30b645..a1dfa0e72ec 100644 --- a/app/models/discussion_note.rb +++ b/app/models/discussion_note.rb @@ -9,7 +9,7 @@ class DiscussionNote < Note # Names of all implementers of `Noteable` that support discussions. def self.noteable_types - %w(MergeRequest Issue Commit Snippet) + %w[MergeRequest Issue Commit Snippet] end validates :noteable_type, inclusion: { in: noteable_types } diff --git a/app/models/draft_note.rb b/app/models/draft_note.rb index ffc04f9bf90..f95eec742d8 100644 --- a/app/models/draft_note.rb +++ b/app/models/draft_note.rb @@ -5,8 +5,8 @@ class DraftNote < ApplicationRecord include Sortable include ShaAttribute - PUBLISH_ATTRS = %i(noteable_id noteable_type type note).freeze - DIFF_ATTRS = %i(position original_position change_position commit_id).freeze + PUBLISH_ATTRS = %i[noteable_id noteable_type type note].freeze + DIFF_ATTRS = %i[position original_position change_position commit_id].freeze sha_attribute :commit_id diff --git a/app/models/environment.rb b/app/models/environment.rb index 36445279b86..29394c37e2c 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -25,7 +25,6 @@ class Environment < ApplicationRecord has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_many :active_deployments, -> { active }, class_name: 'Deployment' has_many :prometheus_alerts, inverse_of: :environment - has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment # NOTE: If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader. @@ -108,11 +107,11 @@ class Environment < ApplicationRecord scope :deployed_and_updated_before, -> (project_id, before) do # this query joins deployments and filters out any environment that has recent deployments - joins = %{ + joins = %( LEFT JOIN "deployments" on "deployments".environment_id = "environments".id AND "deployments".project_id = #{project_id} AND "deployments".updated_at >= #{connection.quote(before)} - } + ) Environment.joins(joins) .where(project_id: project_id, updated_at: ...before) .group('id', 'deployments.id') @@ -193,7 +192,7 @@ class Environment < ApplicationRecord end event :stop_complete do - transition %i(available stopping) => :stopped + transition %i[available stopping] => :stopped end state :available diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 7687bc2be60..f31615f2b3b 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -79,7 +79,7 @@ class EnvironmentStatus private - PAGE_EXTENSIONS = /\A\.(s?html?|php|asp|cgi|pl)\z/i.freeze + PAGE_EXTENSIONS = /\A\.(s?html?|php|asp|cgi|pl)\z/i def deployment_metrics @deployment_metrics ||= DeploymentMetrics.new(project, deployment) @@ -102,7 +102,6 @@ class EnvironmentStatus return [] unless pipeline environments = pipeline.environments_in_self_and_project_descendants.includes(:project) - environments = environments.available if Feature.disabled?(:review_apps_redeploy_mr_widget, mr.project) environments.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index c52f8a58c00..318538be645 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -19,7 +19,7 @@ module ErrorTracking (?<project>[^/]+)/* )? \z - }x.freeze + }x self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] } self.reactive_cache_work_type = :external_dependency diff --git a/app/models/event.rb b/app/models/event.rb index 4547d7b9e60..9e4a662aaa5 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -10,6 +10,7 @@ class Event < ApplicationRecord include UsageStatistics include ShaAttribute include IgnorableColumns + include EachBatch ignore_column :target_id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22' @@ -69,7 +70,7 @@ class Event < ApplicationRecord # If the association for "target" defines an "author" association we want to # eager-load this so Banzai & friends don't end up performing N+1 queries to # get the authors of notes, issues, etc. (likewise for "noteable"). - incs = %i(author noteable work_item_type).select do |a| + incs = %i[author noteable work_item_type].select do |a| reflections['events'].active_record.reflect_on_association(a) end @@ -137,7 +138,7 @@ class Event < ApplicationRecord where( 'action IN (?) OR (target_type IN (?) AND action IN (?))', [actions[:pushed], actions[:commented]], - %w(MergeRequest Issue WorkItem), [actions[:created], actions[:closed], actions[:merged]] + %w[MergeRequest Issue WorkItem], [actions[:created], actions[:closed], actions[:merged]] ) end diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 1bf35179393..f0cae9c88ca 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -10,7 +10,7 @@ class GpgKey < ApplicationRecord sha_attribute :fingerprint belongs_to :user - has_many :gpg_signatures + has_many :gpg_signatures, class_name: 'CommitSignatures::GpgSignature' has_many :subkeys, class_name: 'GpgKeySubkey' scope :with_subkeys, -> { includes(:subkeys) } diff --git a/app/models/group.rb b/app/models/group.rb index 9df3c143e0c..9330ffef156 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -35,11 +35,12 @@ class Group < Namespace foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember' alias_method :members, :group_members - has_many :users, through: :group_members - has_many :owners, - -> { where(members: { access_level: Gitlab::Access::OWNER }) }, - through: :all_group_members, - source: :user + has_many :users, -> { allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") }, + through: :group_members + has_many :owners, -> { + where(members: { access_level: Gitlab::Access::OWNER }) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") + }, through: :all_group_members, source: :user has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :namespace_requesters, -> { where.not(requested_at: nil).unscope(where: %i[source_id source_type]) }, @@ -785,8 +786,6 @@ class Group < Namespace end def execute_integrations(data, hooks_scope) - return unless Feature.enabled?(:group_mentions, self) - integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend integration.async_execute(data) end @@ -800,7 +799,9 @@ class Group < Namespace end def first_owner - owners.first || parent&.first_owner || owner + first_owner_member = all_group_members.all_owners.order(:user_id).first + + first_owner_member&.user || parent&.first_owner || owner end def default_branch_name @@ -898,6 +899,10 @@ class Group < Namespace feature_flag_enabled_for_self_or_ancestor?(:linked_work_items) end + def supports_lock_on_merge? + feature_flag_enabled_for_self_or_ancestor?(:enforce_locked_labels_on_merge, type: :ops) + end + def usage_quotas_enabled? ::Feature.enabled?(:usage_quotas_for_all_editions, self) && root? end @@ -939,12 +944,12 @@ class Group < Namespace private - def feature_flag_enabled_for_self_or_ancestor?(feature_flag) + def feature_flag_enabled_for_self_or_ancestor?(feature_flag, type: :development) actors = [root_ancestor] actors << self if root_ancestor != self actors.any? do |actor| - ::Feature.enabled?(feature_flag, actor) + ::Feature.enabled?(feature_flag, actor, type: type) end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index d7a95363337..c0bfe31fb38 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -103,7 +103,7 @@ class WebHook < ApplicationRecord end # See app/validators/json_schemas/web_hooks_url_variables.json - VARIABLE_REFERENCE_RE = /\{([A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*)\}/.freeze + VARIABLE_REFERENCE_RE = /\{([A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*)\}/ def interpolated_url(url = self.url, url_variables = self.url_variables) return url unless url.include?('{') diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index 4c35f699468..3e0c8e7c472 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class WebHookLog < ApplicationRecord - include SafeUrl include Presentable include DeleteWithLimit include CreatedAtFilterable @@ -58,10 +57,18 @@ class WebHookLog < ApplicationRecord self[:request_headers].merge('X-Gitlab-Token' => _('[REDACTED]')) end + def url_current? + # URL hash hasn't been set, so we must assume there's no prior value to + # compare to. + return true if url_hash.nil? + + Gitlab::CryptoHelper.sha256(web_hook.interpolated_url) == url_hash + end + private def obfuscate_basic_auth - self.url = safe_url + self.url = Gitlab::UrlSanitizer.sanitize_masked_url(url) end def redact_user_emails diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index 57638356362..7b2036a9def 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -3,7 +3,7 @@ require 'resolv' class InstanceConfiguration - SSH_ALGORITHMS = %w(DSA ECDSA ED25519 RSA).freeze + SSH_ALGORITHMS = %w[DSA ECDSA ED25519 RSA].freeze SSH_ALGORITHMS_PATH = '/etc/ssh/' CACHE_KEY = 'instance_configuration' EXPIRATION_TIME = 24.hours diff --git a/app/models/integration.rb b/app/models/integration.rb index bc86b08018f..d4c76f743a3 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -63,6 +63,7 @@ class Integration < ApplicationRecord encode: false, encode_iv: false + alias_attribute :name, :title # Handle assignment of props with symbol keys. # To do this correctly, we need to call the method generated by attr_encrypted. alias_method :attr_encrypted_props=, :properties= @@ -468,11 +469,8 @@ class Integration < ApplicationRecord [] end - # TODO: Once all integrations use `Integrations::Field` we can - # use `#secret?` here. - # See: https://gitlab.com/groups/gitlab-org/-/epics/7652 def secret_fields - fields.select { |f| f[:type] == :password }.pluck(:name) + fields.select(&:secret?).pluck(:name) end # Expose a list of fields in the JSON endpoint. diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index 6f96626718f..ef12fc6bf6f 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -4,8 +4,8 @@ require 'app_store_connect' module Integrations class AppleAppStore < Integration - ISSUER_ID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/.freeze - KEY_ID_REGEX = /\A(?=.*[A-Z])(?=.*[0-9])[A-Z0-9]+\z/.freeze + ISSUER_ID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/ + KEY_ID_REGEX = /\A(?=.*[A-Z])(?=.*[0-9])[A-Z0-9]+\z/ IS_KEY_CONTENT_BASE64 = "true" SECTION_TYPE_APPLE_APP_STORE = 'apple_app_store' diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index 7436c08aa38..859522670ef 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -12,8 +12,7 @@ module Integrations help: -> { s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.') }, non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, - # Example Personal Access Token from Asana docs - placeholder: '0/68a9e79b868c6789e79a124c30b0', + placeholder: '0/68a9e79b868c6789e79a124c30b0', # Example Personal Access Token from Asana docs required: true field :restrict_to_branch, @@ -38,7 +37,7 @@ module Integrations end def self.supported_events - %w(push) + %w[push] end def client diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb index 6831fac32e6..1d3616b4c3b 100644 --- a/app/models/integrations/assembla.rb +++ b/app/models/integrations/assembla.rb @@ -28,7 +28,7 @@ module Integrations end def self.supported_events - %w(push) + %w[push] end def execute(data) diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 4d207574ca7..2c929dc2cb3 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -31,12 +31,12 @@ module Integrations # Custom serialized properties initialization prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) - boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch + boolean_accessor :notify_only_default_branch validates :webhook, presence: true, public_url: true, - if: -> (integration) { integration.activated? && integration.requires_webhook? } + if: -> (integration) { integration.activated? && integration.class.requires_webhook? } validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true, if: :activated? validate :validate_channel_limit, if: :activated? @@ -44,7 +44,7 @@ module Integrations super if properties.empty? - self.notify_only_broken_pipelines = true + self.notify_only_broken_pipelines = true if self.respond_to?(:notify_only_broken_pipelines) self.branches_to_be_notified = "default" self.labels_to_be_notified_behavior = MATCH_ANY_LABEL elsif !self.notify_only_default_branch.nil? @@ -72,48 +72,7 @@ module Integrations end def fields - default_fields + build_event_channels - end - - def default_fields - [ - { - type: :checkbox, - section: SECTION_TYPE_CONFIGURATION, - name: 'notify_only_broken_pipelines', - help: 'Do not send notifications for successful pipelines.' - }.freeze, - { - type: :select, - section: SECTION_TYPE_CONFIGURATION, - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - }.freeze, - { - type: :text, - section: SECTION_TYPE_CONFIGURATION, - name: 'labels_to_be_notified', - 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.' - }.freeze, - { - type: :select, - section: SECTION_TYPE_CONFIGURATION, - name: 'labels_to_be_notified_behavior', - choices: [ - ['Match any of the labels', MATCH_ANY_LABEL], - ['Match all of the labels', MATCH_ALL_LABELS] - ] - }.freeze - ].tap do |fields| - next unless requires_webhook? - - fields.unshift( - { type: :text, name: 'webhook', help: webhook_help, required: true }.freeze, - { type: :text, name: 'username', placeholder: 'GitLab-integration' }.freeze - ) - end.freeze + self.class.fields + build_event_channels end def execute(data) @@ -154,6 +113,15 @@ module Integrations supported_events.map { |event| event_channel_name(event) } end + override :api_field_names + def api_field_names + if mask_configurable_channels? + super - event_channel_names + else + super + end + end + def form_fields super.reject { |field| field[:name].end_with?('channel') } end @@ -166,6 +134,10 @@ module Integrations raise NotImplementedError end + def help + raise NotImplementedError + end + # With some integrations the webhook is already tied to a specific channel, # for others the channels are configurable for each event. def configurable_channels? @@ -181,7 +153,7 @@ module Integrations self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend end - def requires_webhook? + def self.requires_webhook? true end @@ -193,11 +165,32 @@ module Integrations false end + override :sections + def sections + [ + { + type: SECTION_TYPE_CONNECTION, + title: s_('Integrations|Connection details'), + description: help + }, + { + type: SECTION_TYPE_TRIGGER, + title: s_('Integrations|Trigger'), + description: s_('Integrations|An event will be triggered when one of the following items happen.') + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: s_('Integrations|Notification settings'), + description: s_('Integrations|Configure the scope of notifications.') + } + ] + end + private def should_execute?(object_kind) supported_events.include?(object_kind) && - (!requires_webhook? || webhook.present?) + (!self.class.requires_webhook? || webhook.present?) end def log_usage(_, _) @@ -264,7 +257,7 @@ module Integrations def build_event_channels event_channel_names.map do |channel_field| - { type: :text, name: channel_field, placeholder: default_channel_placeholder } + Field.new(name: channel_field, type: :text, placeholder: default_channel_placeholder, integration_class: self) end end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index 7a54d354007..b59aee6743d 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -88,7 +88,7 @@ module Integrations end def self.supported_events - %w(push) + %w[push] end def execute(data) diff --git a/app/models/integrations/base_monitoring.rb b/app/models/integrations/base_monitoring.rb index b0bebb5a859..12ea57f59a3 100644 --- a/app/models/integrations/base_monitoring.rb +++ b/app/models/integrations/base_monitoring.rb @@ -9,7 +9,7 @@ module Integrations attribute :category, default: 'monitoring' def self.supported_events - %w() + %w[] end def can_query? diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb index 29a20419809..65aec8b278f 100644 --- a/app/models/integrations/base_slack_notification.rb +++ b/app/models/integrations/base_slack_notification.rb @@ -25,20 +25,24 @@ module Integrations override :supported_events def supported_events - additional = %w[alert] - - if group_level? && Feature.enabled?(:group_mentions, group) - additional += %w[group_mention group_confidential_mention] - end + additional = group_level? ? %w[group_mention group_confidential_mention] : [] (super + additional).freeze end + def self.supported_events + super + %w[alert] + end + override :configurable_channels? def configurable_channels? true end + def help + # noop + end + private override :log_usage diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb index 7662da933ba..58821e5fb4e 100644 --- a/app/models/integrations/base_slash_commands.rb +++ b/app/models/integrations/base_slash_commands.rb @@ -13,7 +13,7 @@ module Integrations end def self.supported_events - %w() + %w[] end def testable? diff --git a/app/models/integrations/base_third_party_wiki.rb b/app/models/integrations/base_third_party_wiki.rb index 8df172e9a53..dee3706c518 100644 --- a/app/models/integrations/base_third_party_wiki.rb +++ b/app/models/integrations/base_third_party_wiki.rb @@ -9,7 +9,7 @@ module Integrations after_commit :cache_project_has_integration def self.supported_events - %w() + %w[] end private diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb index 6cd36e545a5..82a5142e8c2 100644 --- a/app/models/integrations/buildkite.rb +++ b/app/models/integrations/buildkite.rb @@ -29,7 +29,7 @@ module Integrations validates :token, presence: true, if: :activated? def self.supported_events - %w(push merge_request tag_push) + %w[push merge_request tag_push] end # This is a stub method to work with deprecated API response diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 007578e5830..8b5797a9d24 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -2,7 +2,7 @@ module Integrations class Campfire < Integration - SUBDOMAIN_REGEXP = %r{\A[a-z](?:[a-z0-9-]*[a-z0-9])?\z}i.freeze + SUBDOMAIN_REGEXP = %r{\A[a-z](?:[a-z0-9-]*[a-z0-9])?\z}i validates :token, presence: true, if: :activated? validates :room, @@ -26,12 +26,9 @@ module Integrations placeholder: '', exposes_secrets: true, help: -> do - ERB::Util.html_escape( + format(ERB::Util.html_escape( s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') - ) % { - code_open: '<code>'.html_safe, - code_close: '</code>'.html_safe - } + ), code_open: '<code>'.html_safe, code_close: '</code>'.html_safe) end field :room, @@ -48,13 +45,16 @@ module Integrations end def help - docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer' - - ERB::Util.html_escape( + docs_link = ActionController::Base.helpers.link_to( + _('Learn more.'), + Rails.application.routes.url_helpers.help_page_url('api/integrations', anchor: 'campfire'), + target: '_blank', + rel: 'noopener noreferrer' + ) + + format(ERB::Util.html_escape( s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}') - ) % { - docs_link: docs_link.html_safe - } + ), docs_link: docs_link.html_safe) end def self.to_param @@ -62,14 +62,14 @@ module Integrations end def self.supported_events - %w(push) + %w[push] end def execute(data) return unless supported_events.include?(data[:object_kind]) message = create_message(data) - speak(self.room, message, auth) + speak(room, message, auth) end private @@ -96,7 +96,7 @@ module Integrations room = rooms(auth).find { |r| r["name"] == room_name } return unless room - path = "/room/#{room["id"]}/speak.json" + path = "/room/#{room['id']}/speak.json" body = { body: { message: { diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb index 501b214a769..600f07b97f1 100644 --- a/app/models/integrations/chat_message/base_message.rb +++ b/app/models/integrations/chat_message/base_message.rb @@ -3,7 +3,7 @@ module Integrations module ChatMessage class BaseMessage - RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)}.freeze + RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)} attr_reader :markdown attr_reader :user_full_name diff --git a/app/models/integrations/chat_message/deployment_message.rb b/app/models/integrations/chat_message/deployment_message.rb index b28edeecb4d..0367459dfcb 100644 --- a/app/models/integrations/chat_message/deployment_message.rb +++ b/app/models/integrations/chat_message/deployment_message.rb @@ -26,8 +26,10 @@ module Integrations end def attachments + return description_message if markdown + [{ - text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{strip_markup(commit_title)}", + text: format(description_message), color: color }] end @@ -82,6 +84,10 @@ module Integrations def running? status == 'running' end + + def description_message + "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{strip_markup(commit_title)}" + end end end end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index 31e9a171d1b..eda8c37fc72 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -2,9 +2,9 @@ module Integrations class Confluence < BaseThirdPartyWiki - VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze - VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze - VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze + VALID_SCHEME_MATCH = %r{\Ahttps?\Z} + VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z} + VALID_PATH_MATCH = %r{\A/wiki(/|\Z)} validates :confluence_url, presence: true, if: :activated? validate :validate_confluence_url_is_cloud, if: :activated? @@ -14,6 +14,10 @@ module Integrations placeholder: 'https://example.atlassian.net/wiki', required: true + def avatar_url + ActionController::Base.helpers.image_path('confluence.svg') + end + def self.to_param 'confluence' end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index 1a56763fe57..b1f1361afcd 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -12,7 +12,7 @@ module Integrations pipeline build archive_trace ].freeze - TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze + TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x field :datadog_site, exposes_secrets: true, @@ -40,7 +40,7 @@ module Integrations ERB::Util.html_escape( s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') ) % { - linkOpen: %{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, + linkOpen: %(<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">).html_safe, linkClose: '</a>'.html_safe } end, diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 7cae3ca20f9..815e3669d78 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -4,9 +4,7 @@ require "discordrb/webhooks" module Integrations class Discord < BaseChatNotification - ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze - - undef :notify_only_broken_pipelines + ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/ field :webhook, section: SECTION_TYPE_CONNECTION, @@ -35,10 +33,6 @@ module Integrations "discord" end - def fields - self.class.fields + build_event_channels - end - def help docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } @@ -52,26 +46,6 @@ module Integrations %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] end - def sections - [ - { - type: SECTION_TYPE_CONNECTION, - title: s_('Integrations|Connection details'), - description: help - }, - { - type: SECTION_TYPE_TRIGGER, - title: s_('Integrations|Trigger'), - description: s_('Integrations|An event will be triggered when one of the following items happen.') - }, - { - type: SECTION_TYPE_CONFIGURATION, - title: s_('Integrations|Notification settings'), - description: s_('Integrations|Configure the scope of notifications.') - } - ] - end - def configurable_channels? true end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index ac464c020dd..f6a12c4bb1a 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -43,7 +43,7 @@ module Integrations end def self.supported_events - %w(push merge_request tag_push) + %w[push merge_request tag_push] end def commit_status_path(sha, ref) diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index eb893ae45d0..144d1a07b04 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -52,7 +52,7 @@ module Integrations end def self.supported_events - %w(push tag_push) + %w[push tag_push] end def initialize_properties diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index 75fe6b6f164..acacab2528e 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -47,7 +47,7 @@ module Integrations end def self.supported_events - %w() + %w[] end end end diff --git a/app/models/integrations/gitlab_slack_application.rb b/app/models/integrations/gitlab_slack_application.rb index b0f54f39e8c..2d520eaf7e7 100644 --- a/app/models/integrations/gitlab_slack_application.rb +++ b/app/models/integrations/gitlab_slack_application.rb @@ -20,6 +20,8 @@ module Integrations has_one :slack_integration, foreign_key: :integration_id, inverse_of: :integration delegate :bot_access_token, :bot_user_id, to: :slack_integration, allow_nil: true + include SlackMattermostFields + def update_active_status update(active: !!slack_integration) end @@ -66,18 +68,7 @@ module Integrations def sections return [] unless editable? - [ - { - type: SECTION_TYPE_TRIGGER, - title: s_('Integrations|Trigger'), - description: s_('Integrations|An event will be triggered when one of the following items happen.') - }, - { - type: SECTION_TYPE_CONFIGURATION, - title: s_('Integrations|Notification settings'), - description: s_('Integrations|Configure the scope of notifications.') - } - ] + super.drop(1) end override :configurable_events @@ -88,7 +79,7 @@ module Integrations end override :requires_webhook? - def requires_webhook? + def self.requires_webhook? false end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 037c689c75e..680752c3d56 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -2,8 +2,6 @@ module Integrations class HangoutsChat < BaseChatNotification - undef :notify_only_broken_pipelines - field :webhook, section: SECTION_TYPE_CONNECTION, help: 'https://chat.googleapis.com/v1/spaces…', @@ -36,10 +34,6 @@ module Integrations s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def fields - self.class.fields + build_event_channels - end - def default_channel_placeholder end diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb index 7769ea7d2dd..0683c8408bc 100644 --- a/app/models/integrations/jenkins.rb +++ b/app/models/integrations/jenkins.rb @@ -66,7 +66,7 @@ module Integrations end def self.supported_events - %w(push merge_request tag_push) + %w[push merge_request tag_push] end def title diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index faf0a378a17..d8d1f860e9a 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -126,7 +126,7 @@ module Integrations # When these are false GitLab does not create cross reference # comments on Jira except when an issue gets transitioned. def self.supported_events - %w(commit merge_request) + %w[commit merge_request] end # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index e3c5c22ad3a..7e391b11d82 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -3,6 +3,7 @@ module Integrations class Mattermost < BaseChatNotification include SlackMattermostNotifier + include SlackMattermostFields def title _('Mattermost notifications') @@ -25,7 +26,7 @@ module Integrations 'my-channel' end - def webhook_help + def self.webhook_help 'http://mattermost.example.com/hooks/' end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index 25308948d51..208172d6303 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -2,8 +2,6 @@ module Integrations class MicrosoftTeams < BaseChatNotification - undef :notify_only_broken_pipelines - field :webhook, section: SECTION_TYPE_CONNECTION, help: 'https://outlook.office.com/webhook/…', @@ -44,30 +42,6 @@ module Integrations pipeline wiki_page] end - def fields - self.class.fields + build_event_channels - end - - def sections - [ - { - type: SECTION_TYPE_CONNECTION, - title: s_('Integrations|Connection details'), - description: help - }, - { - type: SECTION_TYPE_TRIGGER, - title: s_('Integrations|Trigger'), - description: s_('Integrations|An event will be triggered when one of the following items happen.') - }, - { - type: SECTION_TYPE_CONFIGURATION, - title: s_('Integrations|Notification settings'), - description: s_('Integrations|Configure the scope of notifications.') - } - ] - end - private def notify(message, opts) diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index c9c08ec9771..c0acb6c87b4 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -42,7 +42,7 @@ module Integrations end def self.supported_events - %w(push merge_request tag_push) + %w[push merge_request tag_push] end def execute(data) diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index 0d9a3f05a86..f42a872c49e 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -38,7 +38,7 @@ module Integrations end def self.supported_events - %w(push) + %w[push] end def execute(data) diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 736318ed707..8474a5b7adf 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -41,6 +41,7 @@ module Integrations before_save :synchronize_service_state after_save :clear_reactive_cache! + after_commit :sync_http_integration! after_commit :track_events @@ -180,5 +181,16 @@ module Integrations nil end strong_memoize_attr :iap_client + + # Remove in next required stop after %16.4 + # https://gitlab.com/gitlab-org/gitlab/-/issues/338838 + def sync_http_integration! + return unless manual_configuration_changed? + + project.alert_management_http_integrations + .for_endpoint_identifier('legacy-prometheus') + .take + &.update_columns(active: manual_configuration) + end end end diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb index 8f0dddcc5c5..09e011023ed 100644 --- a/app/models/integrations/pumble.rb +++ b/app/models/integrations/pumble.rb @@ -2,8 +2,6 @@ module Integrations class Pumble < BaseChatNotification - undef :notify_only_broken_pipelines - field :webhook, section: SECTION_TYPE_CONNECTION, help: 'https://api.pumble.com/workspaces/x/...', @@ -52,10 +50,6 @@ module Integrations pipeline wiki_page] end - def fields - self.class.fields + build_event_channels - end - private def notify(message, opts) diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 006b731c6c2..e97c7e5e738 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -47,19 +47,19 @@ module Integrations [ ['Device default sound', nil], ['Pushover (default)', 'pushover'], - %w(Bike bike), - %w(Bugle bugle), + %w[Bike bike], + %w[Bugle bugle], ['Cash Register', 'cashregister'], - %w(Classical classical), - %w(Cosmic cosmic), - %w(Falling falling), - %w(Gamelan gamelan), - %w(Incoming incoming), - %w(Intermission intermission), - %w(Magic magic), - %w(Mechanical mechanical), + %w[Classical classical], + %w[Cosmic cosmic], + %w[Falling falling], + %w[Gamelan gamelan], + %w[Incoming incoming], + %w[Intermission intermission], + %w[Magic magic], + %w[Mechanical mechanical], ['Piano Bar', 'pianobar'], - %w(Siren siren), + %w[Siren siren], ['Space Alarm', 'spacealarm'], ['Tug Boat', 'tugboat'], ['Alien Alarm (long)', 'alien'], @@ -84,7 +84,7 @@ module Integrations end def self.supported_events - %w(push) + %w[push] end def execute(data) diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index f5b6595fff2..227fdca5c91 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -8,6 +8,10 @@ module Integrations title: -> { s_('Shimo|Shimo Workspace URL') }, required: true + def avatar_url + ActionController::Base.helpers.image_path('logos/shimo.svg') + end + def render? valid? && activated? end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 07d2d802915..f70376e2f0d 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -3,6 +3,7 @@ module Integrations class Slack < BaseSlackNotification include SlackMattermostNotifier + include SlackMattermostFields def title 'Slack notifications' @@ -16,8 +17,7 @@ module Integrations 'slack' end - override :webhook_help - def webhook_help + def self.webhook_help 'https://hooks.slack.com/services/…' end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index c74e0aab030..575c3b8a334 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -6,7 +6,7 @@ module Integrations include ReactivelyCached prepend EnableSslVerification - TEAMCITY_SAAS_HOSTNAME = /\A[^\.]+\.teamcity\.com\z/i.freeze + TEAMCITY_SAAS_HOSTNAME = /\A[^\.]+\.teamcity\.com\z/i field :teamcity_url, title: -> { s_('ProjectService|TeamCity server URL') }, @@ -43,7 +43,7 @@ module Integrations end def supported_events - %w(push merge_request) + %w[push merge_request] end end diff --git a/app/models/integrations/telegram.rb b/app/models/integrations/telegram.rb index 9af12c712c6..7c196720386 100644 --- a/app/models/integrations/telegram.rb +++ b/app/models/integrations/telegram.rb @@ -21,6 +21,11 @@ module Integrations placeholder: '@channelusername', required: true + field :notify_only_broken_pipelines, + type: :checkbox, + section: SECTION_TYPE_CONFIGURATION, + help: 'If selected, successful pipelines do not trigger a notification event.' + with_options if: :activated? do validates :token, :room, presence: true end @@ -51,34 +56,10 @@ module Integrations ) end - def fields - self.class.fields + build_event_channels - end - def self.supported_events super - ['deployment'] end - def sections - [ - { - type: SECTION_TYPE_CONNECTION, - title: s_('Integrations|Connection details'), - description: help - }, - { - type: SECTION_TYPE_TRIGGER, - title: s_('Integrations|Trigger'), - description: s_('Integrations|An event will be triggered when one of the following items happen.') - }, - { - type: SECTION_TYPE_CONFIGURATION, - title: s_('Integrations|Notification settings'), - description: s_('Integrations|Configure the scope of notifications.') - } - ] - end - private def set_webhook diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index 6de693b5278..3b4bcfa28d3 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -2,8 +2,6 @@ module Integrations class UnifyCircuit < BaseChatNotification - undef :notify_only_broken_pipelines - field :webhook, section: SECTION_TYPE_CONNECTION, help: 'https://yourcircuit.com/rest/v2/webhooks/incoming/…', @@ -31,10 +29,6 @@ module Integrations 'unify_circuit' end - def fields - self.class.fields + build_event_channels - end - def help docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer' s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 21c65cc2b32..3ef8ab39352 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -2,8 +2,6 @@ module Integrations class WebexTeams < BaseChatNotification - undef :notify_only_broken_pipelines - field :webhook, section: SECTION_TYPE_CONNECTION, help: 'https://api.ciscospark.com/v1/webhooks/incoming/...', @@ -31,10 +29,6 @@ module Integrations 'webex_teams' end - def fields - self.class.fields + build_event_channels - end - def help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index fd2c741bd6b..58ec4abf30c 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -34,6 +34,10 @@ module Integrations validates :api_token, presence: true, if: :activated? validates :zentao_product_xid, presence: true, if: :activated? + def avatar_url + ActionController::Base.helpers.image_path('logos/zentao.svg') + end + def self.issues_license_available?(project) project&.licensed_feature_available?(:zentao_issues_integration) end @@ -82,7 +86,7 @@ module Integrations end def self.supported_events - %w() + %w[] end private diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb index cd7e5fafb60..08984bbb723 100644 --- a/app/models/issuable_severity.rb +++ b/app/models/issuable_severity.rb @@ -11,11 +11,11 @@ class IssuableSeverity < ApplicationRecord }.freeze SEVERITY_QUICK_ACTION_PARAMS = { - unknown: %w(Unknown 0), - low: %w(Low S4 4), - medium: %w(Medium S3 3), - high: %w(High S2 2), - critical: %w(Critical S1 1) + unknown: %w[Unknown 0], + low: %w[Low S4 4], + medium: %w[Medium S3 3], + high: %w[High S2 2], + critical: %w[Critical S1 1] }.freeze belongs_to :issue diff --git a/app/models/issue.rb b/app/models/issue.rb index d227448961a..58383a6a329 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -14,7 +14,6 @@ class Issue < ApplicationRecord include TimeTrackable include ThrottledTouch include LabelEventable - include IgnorableColumns include MilestoneEventable include WhereComposite include StateEventable @@ -48,16 +47,14 @@ class Issue < ApplicationRecord # # This should be kept consistent with the enums used for the GraphQL issue list query in # https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/assets/javascripts/issues/list/constants.js#L154-158 - TYPES_FOR_LIST = %w(issue incident test_case task objective key_result).freeze + TYPES_FOR_LIST = %w[issue incident test_case task objective key_result].freeze # Types of issues that should be displayed on issue board lists - TYPES_FOR_BOARD_LIST = %w(issue incident).freeze + TYPES_FOR_BOARD_LIST = %w[issue incident].freeze # This default came from the enum `issue_type` column. Defined as default in the DB DEFAULT_ISSUE_TYPE = :issue - ignore_column :issue_type, remove_with: '16.4', remove_after: '2023-08-22' - belongs_to :project belongs_to :namespace, inverse_of: :issues @@ -112,7 +109,6 @@ class Issue < ApplicationRecord has_one :sentry_issue has_one :alert_management_alert, class_name: 'AlertManagement::Alert' has_one :incident_management_issuable_escalation_status, class_name: 'IncidentManagement::IssuableEscalationStatus' - has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :issue, validate: false has_many :prometheus_alerts, through: :prometheus_alert_events @@ -190,7 +186,6 @@ class Issue < ApplicationRecord scope :preload_awardable, -> { preload(:award_emoji) } scope :with_alert_management_alerts, -> { joins(:alert_management_alert) } scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) } - scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) } scope :with_api_entity_associations, -> { preload(:work_item_type, :timelogs, :closed_by, :assignees, :author, :labels, :issuable_severity, namespace: [{ parent: :route }, :route], milestone: { project: [:route, { namespace: :route }] }, @@ -223,8 +218,11 @@ class Issue < ApplicationRecord scope :counts_by_state, -> { reorder(nil).group(:state_id).count } - scope :service_desk, -> { where(author: ::User.support_bot) } - scope :inc_relations_for_view, -> { includes(author: :status, assignees: :status) } + scope :service_desk, -> { where(author: ::Users::Internal.support_bot) } + scope :inc_relations_for_view, -> do + includes(author: :status, assignees: :status) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422155') + end # An issue can be uniquely identified by project_id and iid # Takes one or more sets of composite IDs, expressed as hash-like records of @@ -546,18 +544,14 @@ class Issue < ApplicationRecord end def related_issues(current_user, preload: nil) - related_issues = self.class - .select(['issues.*', 'issue_links.id AS issue_link_id', - 'issue_links.link_type as issue_link_type_value', - 'issue_links.target_id as issue_link_source_id', - 'issue_links.created_at as issue_link_created_at', - 'issue_links.updated_at as issue_link_updated_at']) - .joins("INNER JOIN issue_links ON - (issue_links.source_id = issues.id AND issue_links.target_id = #{id}) - OR - (issue_links.target_id = issues.id AND issue_links.source_id = #{id})") - .preload(preload) - .reorder('issue_link_id') + related_issues = + linked_issues_select + .joins("INNER JOIN issue_links ON + (issue_links.source_id = issues.id AND issue_links.target_id = #{id}) + OR + (issue_links.target_id = issues.id AND issue_links.source_id = #{id})") + .preload(preload) + .reorder('issue_link_id') related_issues = yield related_issues if block_given? @@ -607,7 +601,7 @@ class Issue < ApplicationRecord end end - def etag_caching_enabled? + def real_time_notes_enabled? true end @@ -642,7 +636,7 @@ class Issue < ApplicationRecord end def from_service_desk? - author.id == User.support_bot.id + author.id == Users::Internal.support_bot.id end def issue_link_type @@ -716,8 +710,8 @@ class Issue < ApplicationRecord end def expire_etag_cache - # TODO: Fix this for the case when issues is created at group level - # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/395814 + # We don't expire the cache for issues that don't have a project, since they are created at the group level + # and they are only displayed in the new work item view that uses GraphQL subscriptions for real-time updates return unless project key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self) @@ -789,7 +783,7 @@ class Issue < ApplicationRecord # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/393126 return unless project - Issues::SearchData.upsert({ namespace_id: namespace_id, project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id)) + Issues::SearchData.upsert({ namespace_id: namespace_id, project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i[project_id issue_id]) end def ensure_metrics! @@ -833,6 +827,14 @@ class Issue < ApplicationRecord errors.add(:work_item_type_id, format(_('can not be changed to %{new_type}'), new_type: work_item_type&.name)) end + + def linked_issues_select + self.class.select(['issues.*', 'issue_links.id AS issue_link_id', + 'issue_links.link_type as issue_link_type_value', + 'issue_links.target_id as issue_link_source_id', + 'issue_links.created_at as issue_link_created_at', + 'issue_links.updated_at as issue_link_updated_at']) + end end Issue.prepend_mod_with('Issue') diff --git a/app/models/label_link.rb b/app/models/label_link.rb index d326b07ad31..0c2d205c641 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -24,3 +24,5 @@ class LabelLink < ApplicationRecord relation end end + +LabelLink.prepend_mod_with('LabelLink') diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb index 3df6742fbc9..046e47262dd 100644 --- a/app/models/lfs_download_object.rb +++ b/app/models/lfs_download_object.rb @@ -9,7 +9,7 @@ class LfsDownloadObject validates :oid, format: { with: /\A\h{64}\z/ } validates :size, numericality: { greater_than_or_equal_to: 0 } - validates :link, public_url: { protocols: %w(http https) } + validates :link, public_url: { protocols: %w[http https] } validate :headers_must_be_hash def initialize(oid:, size:, link:, headers: {}) diff --git a/app/models/license_template.rb b/app/models/license_template.rb index 548066107c1..bfe2a8d379e 100644 --- a/app/models/license_template.rb +++ b/app/models/license_template.rb @@ -5,12 +5,12 @@ class LicenseTemplate %r{[\<\{\[] (project|description| one\sline\s.+\swhat\sit\sdoes\.) # matching the start and end is enough here - [\>\}\]]}xi.freeze - YEAR_TEMPLATE_REGEX = /[<{\[](year|yyyy)[>}\]]/i.freeze + [\>\}\]]}xi + YEAR_TEMPLATE_REGEX = /[<{\[](year|yyyy)[>}\]]/i FULLNAME_TEMPLATE_REGEX = %r{[\<\{\[] (fullname|name\sof\s(author|copyright\sowner)) - [\>\}\]]}xi.freeze + [\>\}\]]}xi attr_reader :key, :name, :project, :category, :nickname, :url, :meta diff --git a/app/models/loose_foreign_keys/modification_tracker.rb b/app/models/loose_foreign_keys/modification_tracker.rb index 72a596d2114..eec9b8ad285 100644 --- a/app/models/loose_foreign_keys/modification_tracker.rb +++ b/app/models/loose_foreign_keys/modification_tracker.rb @@ -2,10 +2,6 @@ module LooseForeignKeys class ModificationTracker - MAX_DELETES = 100_000 - MAX_UPDATES = 50_000 - MAX_RUNTIME = 30.seconds # must be less than the scheduling frequency of the LooseForeignKeys::CleanupWorker cron worker - delegate :monotonic_time, to: :'Gitlab::Metrics::System' def initialize @@ -22,6 +18,18 @@ module LooseForeignKeys ) end + def max_runtime + 30.seconds + end + + def max_deletes + 100_000 + end + + def max_updates + 50_000 + end + def add_deletions(table, count) @delete_count_by_table[table] += count @deletes_counter.increment({ table: table }, count) @@ -33,9 +41,9 @@ module LooseForeignKeys end def over_limit? - @delete_count_by_table.values.sum >= MAX_DELETES || - @update_count_by_table.values.sum >= MAX_UPDATES || - monotonic_time - @start_time >= MAX_RUNTIME + @delete_count_by_table.values.sum >= max_deletes || + @update_count_by_table.values.sum >= max_updates || + monotonic_time - @start_time >= max_runtime end def stats diff --git a/app/models/loose_foreign_keys/turbo_modification_tracker.rb b/app/models/loose_foreign_keys/turbo_modification_tracker.rb new file mode 100644 index 00000000000..5229b17e971 --- /dev/null +++ b/app/models/loose_foreign_keys/turbo_modification_tracker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module LooseForeignKeys + # This is a modification tracker with the additional limits that can be enabled + # for some database via an OPS Feature Flag. + + class TurboModificationTracker < ModificationTracker + extend ::Gitlab::Utils::Override + + override :max_runtime + def max_runtime + 45.seconds + end + + override :max_deletes + def max_deletes + 200_000 + end + + override :max_updates + def max_updates + 150_000 + end + end +end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index ada89345a7f..52b9c3a80e3 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -5,11 +5,10 @@ class GroupMember < Member include CreatedAtFilterable SOURCE_TYPE = 'Namespace' - SOURCE_TYPE_FORMAT = /\ANamespace\z/.freeze + SOURCE_TYPE_FORMAT = /\ANamespace\z/ belongs_to :group, foreign_key: 'source_id' alias_attribute :namespace_id, :source_id - delegate :update_two_factor_requirement, to: :user, allow_nil: true # Make sure group member points only to group as it source attribute :source_type, default: SOURCE_TYPE @@ -26,6 +25,16 @@ class GroupMember < Member 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 diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index e0fecf702de..d07e4f9e298 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -2,7 +2,7 @@ class ProjectMember < Member SOURCE_TYPE = 'Project' - SOURCE_TYPE_FORMAT = /\AProject\z/.freeze + SOURCE_TYPE_FORMAT = /\AProject\z/ belongs_to :project, foreign_key: 'source_id' diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 469dba42952..6a72ed6476e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -66,7 +66,7 @@ class MergeRequest < ApplicationRecord belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff' manual_inverse_association :latest_merge_request_diff, :merge_request - # method overriden in EE + # method overridden in EE def suggested_reviewer_users User.none end @@ -162,7 +162,7 @@ class MergeRequest < ApplicationRecord # Keep states definition to be evaluated before the state_machine block to # avoid spec failures. If this gets evaluated after, the `merged` and `locked` - # states (which are overriden) can be nil. + # states (which are overridden) can be nil. # def self.available_state_names super + [:merged, :locked] @@ -279,6 +279,12 @@ class MergeRequest < ApplicationRecord def check_state?(merge_status) [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking].include?(merge_status.to_sym) end + + # rubocop: disable Style/SymbolProc + before_transition { |merge_request| merge_request.enable_transitioning } + + after_transition { |merge_request| merge_request.disable_transitioning } + # rubocop: enable Style/SymbolProc end # Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking` @@ -292,10 +298,14 @@ class MergeRequest < ApplicationRecord validates :target_project, presence: true validates :target_branch, presence: true validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? - validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] + validate :validate_branches, unless: [ + :allow_broken, + :importing_or_transitioning?, + :closed_or_merged_without_fork? + ] validate :validate_fork, unless: :closed_or_merged_without_fork? - validate :validate_target_project, on: :create, unless: :importing? - validate :validate_reviewer_size_length, unless: :importing? + validate :validate_target_project, on: :create, unless: :importing_or_transitioning? + validate :validate_reviewer_size_length, unless: :importing_or_transitioning? scope :by_source_or_target_branch, ->(branch_name) do where("source_branch = :branch OR target_branch = :branch", branch: branch_name) @@ -371,6 +381,7 @@ class MergeRequest < ApplicationRecord scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) } scope :with_jira_integration_associations, -> { preload_routables.preload(:metrics, :assignees, :author) } + scope :recently_unprepared, -> { where(prepared_at: nil).where(created_at: 2.hours.ago..).order(:created_at, :id) } # id is the tie-breaker scope :by_target_branch_wildcard, ->(wildcard_branch_name) do where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) @@ -550,13 +561,9 @@ class MergeRequest < ApplicationRecord end def merge_pipeline - return unless merged? - - # When the merge_method is :merge there will be a merge_commit_sha, however - # when it is fast-forward there is no merge commit, so we must fall back to - # either the squash commit (if the MR was squashed) or the diff head commit. - sha = merge_commit_sha || squash_commit_sha || diff_head_sha - target_project.latest_pipeline(target_branch, sha) + if sha = merged_commit_sha + target_project.latest_pipeline(target_branch, sha) + end end def head_pipeline_active? @@ -632,7 +639,7 @@ class MergeRequest < ApplicationRecord end end - DRAFT_REGEX = /\A*#{Gitlab::Regex.merge_request_draft}+\s*/i.freeze + DRAFT_REGEX = /\A*#{Gitlab::Regex.merge_request_draft}+\s*/i def self.draft?(title) !!(title =~ DRAFT_REGEX) @@ -734,6 +741,12 @@ class MergeRequest < ApplicationRecord true end + def supports_lock_on_merge? + return false unless merged? + + project.supports_lock_on_merge? + end + # Calls `MergeWorker` to proceed with the merge process and # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. @@ -1218,7 +1231,7 @@ class MergeRequest < ApplicationRecord } end - def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false) + def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false, skip_rebase_check: false) return false unless mergeable_state?( skip_ci_check: skip_ci_check, skip_discussions_check: skip_discussions_check, @@ -1227,7 +1240,7 @@ class MergeRequest < ApplicationRecord check_mergeability(sync_retry_lease: check_mergeability_retry_lease) - can_be_merged? && !should_be_rebased? + can_be_merged? && (!should_be_rebased? || skip_rebase_check) end def mergeability_checks @@ -1593,7 +1606,7 @@ class MergeRequest < ApplicationRecord # Since another process checks for matching merge request, we need # to make it possible to detect whether the query should go to the # primary. - target_project.mark_primary_write_location + target_project.sticking.stick(:project, target_project.id) end def diverged_commits_count @@ -1654,6 +1667,7 @@ class MergeRequest < ApplicationRecord 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? + variables.append(key: 'CI_MERGE_REQUEST_SQUASH_ON_MERGE', value: squash_on_merge?.to_s) variables.concat(source_project_variables) end end @@ -1831,7 +1845,7 @@ class MergeRequest < ApplicationRecord def merged_commit_sha return unless merged? - sha = merge_commit_sha || squash_commit_sha || diff_head_sha + sha = super || merge_commit_sha || squash_commit_sha || diff_head_sha sha.presence end @@ -1996,7 +2010,7 @@ class MergeRequest < ApplicationRecord all_pipelines.for_sha_or_source_sha(diff_head_sha).first end - def etag_caching_enabled? + def real_time_notes_enabled? true end @@ -2097,6 +2111,10 @@ class MergeRequest < ApplicationRecord spammable_attribute_changed? && project.public? end + def missing_required_squash? + !squash && target_project.squash_always? + end + private attr_accessor :skip_fetch_ref @@ -2141,6 +2159,7 @@ class MergeRequest < ApplicationRecord variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', value: source_project.full_path) variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', value: source_project.web_url) variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', value: source_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_PROTECTED', value: ProtectedBranch.protected?(source_project, source_branch).to_s) end end diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb deleted file mode 100644 index ac0fcb41089..00000000000 --- a/app/models/metrics/dashboard/annotation.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboard - class Annotation < ApplicationRecord - include DeleteWithLimit - - self.table_name = 'metrics_dashboard_annotations' - - validates :starting_at, presence: true - validates :description, presence: true, length: { maximum: 255 } - validates :dashboard_path, presence: true, length: { maximum: 255 } - validates :panel_xid, length: { maximum: 255 } - validate :ending_at_after_starting_at - - scope :after, ->(after) { where('starting_at >= ?', after) } - scope :before, ->(before) { where('starting_at <= ?', before) } - - scope :for_dashboard, ->(dashboard_path) { where(dashboard_path: dashboard_path) } - scope :ending_before, ->(timestamp) { where('COALESCE(ending_at, starting_at) < ?', timestamp) } - - private - - # If annotation has NULL in ending_at column that indicates, that this annotation IS TIED TO SINGLE POINT - # IN TIME designated by starting_at timestamp. It does NOT mean that annotation is ever going starting from - # stating_at timestamp - def ending_at_after_starting_at - return if ending_at.blank? || starting_at.blank? || starting_at <= ending_at - - errors.add(:ending_at, s_("MetricsDashboardAnnotation|can't be before starting_at time")) - end - end - end -end diff --git a/app/models/metrics/users_starred_dashboard.rb b/app/models/metrics/users_starred_dashboard.rb deleted file mode 100644 index 07748eb1431..00000000000 --- a/app/models/metrics/users_starred_dashboard.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Metrics - class UsersStarredDashboard < ApplicationRecord - self.table_name = 'metrics_users_starred_dashboards' - - belongs_to :user, inverse_of: :metrics_users_starred_dashboards - belongs_to :project, inverse_of: :metrics_users_starred_dashboards - - validates :user_id, presence: true - validates :project_id, presence: true - validates :dashboard_path, presence: true, length: { maximum: 255 } - validates :dashboard_path, uniqueness: { scope: %i[user_id project_id] } - - scope :for_project, ->(project) { where(project: project) } - scope :for_project_dashboard, ->(project, path) { for_project(project).where(dashboard_path: path) } - end -end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 8de717fb61d..eb0da368c7b 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -134,7 +134,9 @@ class Milestone < ApplicationRecord end def participants - User.joins(assigned_issues: :milestone).where(milestones: { id: id }).distinct + User.joins(assigned_issues: :milestone) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422155') + .where(milestones: { id: id }).distinct end def self.sort_by_attribute(method) diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 6d0e7c35865..e7fcde2cb5c 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -14,7 +14,7 @@ module Ml belongs_to :model, class_name: 'Ml::Model' belongs_to :project - belongs_to :package, class_name: 'Packages::Package', optional: true + belongs_to :package, class_name: 'Packages::MlModel::Package', optional: true delegate :name, to: :model diff --git a/app/models/namespace.rb b/app/models/namespace.rb index a7d03c3688a..ea0ea4de5b5 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -17,6 +17,9 @@ class Namespace < ApplicationRecord include BlocksUnsafeSerialization include Ci::NamespaceSettings include Referable + include CrossDatabaseIgnoredTables + + cross_database_ignore_tables %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424277' # Tells ActiveRecord not to store the full class name, in order to save some space # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69794 @@ -145,7 +148,6 @@ class Namespace < ApplicationRecord after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? } after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? } after_update :move_dir, if: :saved_change_to_path_or_parent?, unless: -> { is_a?(Namespaces::ProjectNamespace) } - after_destroy :rm_dir after_save :reload_namespace_details @@ -155,7 +157,6 @@ class Namespace < ApplicationRecord # Legacy Storage specific hooks - before_destroy(prepend: true) { prepare_for_destroy } after_commit :expire_child_caches, on: :update, if: -> { Feature.enabled?(:cached_route_lookups, self, type: :ops) && saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id? @@ -166,7 +167,9 @@ class Namespace < ApplicationRecord scope :sort_by_type, -> { order(arel_table[:type].asc.nulls_first) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } + scope :by_root_id, -> (root_id) { where('traversal_ids[1] IN (?)', root_id) } scope :filter_by_path, -> (query) { where('lower(path) = :query', query: query.downcase) } + scope :in_organization, -> (organization) { where(organization: organization) } scope :with_statistics, -> do joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id') @@ -231,16 +234,26 @@ class Namespace < ApplicationRecord # query - The search query as a String. # # Returns an ActiveRecord::Relation. - def search(query, include_parents: false, use_minimum_char_limit: true) + def search(query, include_parents: false, use_minimum_char_limit: true, exact_matches_first: false) if include_parents - without_project_namespaces + route_columns = [Route.arel_table[:path], Route.arel_table[:name]] + namespaces = without_project_namespaces .where(id: Route.for_routable_type(Namespace.name) .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") - .fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]], + .fuzzy_search(query, route_columns, use_minimum_char_limit: use_minimum_char_limit) .select(:source_id)) + + if exact_matches_first + namespaces = namespaces + .joins(:route) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + .order(exact_matches_first_sql(query, route_columns)) + end + + namespaces else - without_project_namespaces.fuzzy_search(query, [:path, :name], use_minimum_char_limit: use_minimum_char_limit) + without_project_namespaces.fuzzy_search(query, [:path, :name], use_minimum_char_limit: use_minimum_char_limit, exact_matches_first: exact_matches_first) end end @@ -465,7 +478,7 @@ class Namespace < ApplicationRecord return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil? strong_memoize(:first_auto_devops_config) do - if has_parent? + if parent.present? Rails.cache.fetch(first_auto_devops_config_cache_key_for(id), expires_in: 1.day) do parent.first_auto_devops_config end @@ -751,7 +764,7 @@ class Namespace < ApplicationRecord end def reload_namespace_details - return unless !project_namespace? && (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && namespace_details.present? + return unless !project_namespace? && (previous_changes.keys & %w[description description_html cached_markdown_version]).any? && namespace_details.present? namespace_details.reset end diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index 6c825b5364f..a65027733e9 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -3,7 +3,6 @@ class Namespace::Detail < ApplicationRecord include IgnorableColumns - ignore_column :free_user_cap_over_limt_notified_at, remove_with: '15.7', remove_after: '2022-11-22' ignore_column :dashboard_notification_at, remove_with: '16.5', remove_after: '2023-08-22' ignore_column :dashboard_enforcement_at, remove_with: '16.5', remove_after: '2023-08-22' ignore_column :next_over_limit_check_at, remove_with: '16.5', remove_after: '2023-08-22' diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 8af0cf2767c..1d11bcb574c 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -2,7 +2,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord SNIPPETS_SIZE_STAT_NAME = 'snippets_size' - STATISTICS_ATTRIBUTES = %W( + STATISTICS_ATTRIBUTES = %W[ storage_size repository_size wiki_size @@ -12,7 +12,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord #{SNIPPETS_SIZE_STAT_NAME} pipeline_artifacts_size uploads_size - ).freeze + ].freeze self.primary_key = :namespace_id @@ -36,7 +36,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord end def self.namespace_statistics_attributes - %w(storage_size dependency_proxy_size) + %w[storage_size dependency_proxy_size] end private diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb index d2de85b5dd4..86fb562f4f4 100644 --- a/app/models/namespace/traversal_hierarchy.rb +++ b/app/models/namespace/traversal_hierarchy.rb @@ -39,9 +39,16 @@ class Namespace AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids SQL - Namespace.transaction do - @root.lock!("FOR NO KEY UPDATE") - Namespace.connection.exec_query(sql) + # Hint: when a user is created, it also creates a Namespaces::UserNamespace in + # `ensure_namespace_correct`. This method is then called within the same + # transaction of the user INSERT. + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[namespaces], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424279' + ) do + Namespace.transaction do + @root.lock!("FOR NO KEY UPDATE") + Namespace.connection.exec_query(sql) + end end rescue ActiveRecord::Deadlocked db_deadlock_counter.increment(source: 'Namespace#sync_traversal_ids!') diff --git a/app/models/namespaces/randomized_suffix_path.rb b/app/models/namespaces/randomized_suffix_path.rb index 586d7bff5c3..b22ba789688 100644 --- a/app/models/namespaces/randomized_suffix_path.rb +++ b/app/models/namespaces/randomized_suffix_path.rb @@ -3,7 +3,7 @@ module Namespaces class RandomizedSuffixPath MAX_TRIES = 4 - LEADING_ZEROS = /^0+/.freeze + LEADING_ZEROS = /^0+/ def initialize(path) @path = path diff --git a/app/models/note.rb b/app/models/note.rb index f1760a8dc4a..8fc45436dc7 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -28,7 +28,7 @@ class Note < ApplicationRecord ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' - ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze + ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/ cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true @@ -74,6 +74,7 @@ class Note < ApplicationRecord attr_mentionable :note, pipeline: :note participant :author + belongs_to :namespace belongs_to :project belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :author, class_name: "User" @@ -104,6 +105,7 @@ class Note < ApplicationRecord validates :note, presence: true validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT } validates :project, presence: true, if: :for_project_noteable? + validates :namespace, presence: true # Attachments are deprecated and are handled by Markdown uploader validates :attachment, file_size: { maximum: :max_attachment_size } @@ -169,7 +171,7 @@ class Note < ApplicationRecord end end - scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) } + scope :diff_notes, -> { where(type: %w[LegacyDiffNote DiffNote]) } scope :new_diff_notes, -> { where(type: 'DiffNote') } scope :non_diff_notes, -> { where(type: NON_DIFF_NOTE_TYPES) } @@ -193,7 +195,7 @@ class Note < ApplicationRecord scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) } scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } - before_validation :nullify_blank_type, :nullify_blank_line_code + before_validation :ensure_namespace_id, :nullify_blank_type, :nullify_blank_line_code # Syncs `confidential` with `internal` as we rename the column. # https://gitlab.com/gitlab-org/gitlab/-/issues/367923 before_create :set_internal_flag @@ -205,7 +207,7 @@ class Note < ApplicationRecord after_commit :trigger_note_subscription_create, on: :create after_commit :trigger_note_subscription_update, on: :update after_commit :trigger_note_subscription_destroy, on: :destroy - after_commit :expire_etag_cache, unless: :importing? + after_commit :broadcast_noteable_notes_changed, unless: :importing? def trigger_note_subscription_create return unless trigger_note_subscription? @@ -589,8 +591,8 @@ class Note < ApplicationRecord update_columns(attributes_to_update) end - def expire_etag_cache - noteable&.expire_note_etag_cache + def broadcast_noteable_notes_changed + noteable&.broadcast_notes_changed end def touch(*args, **kwargs) @@ -825,6 +827,16 @@ class Note < ApplicationRecord project.repository.keep_around(self.commit_id) end + def ensure_namespace_id + return if namespace_id.present? && !noteable_changed? && !project_changed? + + self.namespace_id = if for_project_noteable? + project&.project_namespace_id + elsif for_personal_snippet? + noteable&.author&.namespace&.id + end + end + def nullify_blank_type self.type = nil if self.type.blank? end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index cde7b92e74a..eb4fa9ac474 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -60,7 +60,7 @@ class NotificationSetting < ApplicationRecord end def self.allowed_fields(source = nil) - NotificationSetting.email_events(source).dup + %i(level notification_email) + NotificationSetting.email_events(source).dup + %i[level notification_email] end def email_events diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index 01db0a5cf8b..b93537e0d1e 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -52,7 +52,7 @@ module Operations class << self def preload_relations - preload(strategies: :scopes) + preload(strategies: [:scopes, :user_list]) end def for_unleash_client(project, environment) diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 9f2119949fb..893b08d7872 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -10,6 +10,7 @@ module Organizations has_many :namespaces has_many :groups + has_many :projects has_one :settings, class_name: "OrganizationSetting" @@ -38,7 +39,7 @@ module Organizations end def user?(user) - users.exists?(user.id) + organization_users.exists?(user: user) end private diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb index 2b8d0a4f51e..1fe4e28146e 100644 --- a/app/models/packages/debian.rb +++ b/app/models/packages/debian.rb @@ -4,13 +4,13 @@ module Packages module Debian TEMPORARY_PACKAGE_NAME = 'debian-temporary-package' - DISTRIBUTION_REGEX = %r{[a-z0-9][a-z0-9.-]*}i.freeze + DISTRIBUTION_REGEX = %r{[a-z0-9][a-z0-9.-]*}i COMPONENT_REGEX = DISTRIBUTION_REGEX.freeze - ARCHITECTURE_REGEX = %r{[a-z0-9][-a-z0-9]*}.freeze + ARCHITECTURE_REGEX = %r{[a-z0-9][-a-z0-9]*} - LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze + LETTER_REGEX = %r{(lib)?[a-z0-9]} - EMPTY_FILE_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'.freeze + EMPTY_FILE_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' INCOMING_PACKAGE_NAME = 'incoming' diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb index 7ea0dfe8765..4ac621dcbd4 100644 --- a/app/models/packages/debian/file_entry.rb +++ b/app/models/packages/debian/file_entry.rb @@ -6,7 +6,7 @@ module Packages include ActiveModel::Model DIGESTS = %i[md5 sha1 sha256].freeze - FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze + FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z} attr_accessor :filename, :size, diff --git a/app/models/packages/dependency_link.rb b/app/models/packages/dependency_link.rb index 51018602bdc..400b4cce208 100644 --- a/app/models/packages/dependency_link.rb +++ b/app/models/packages/dependency_link.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class Packages::DependencyLink < ApplicationRecord + include EachBatch + belongs_to :package, inverse_of: :dependency_links belongs_to :dependency, inverse_of: :dependency_links, class_name: 'Packages::Dependency' has_one :nuget_metadatum, inverse_of: :dependency_link, class_name: 'Packages::Nuget::DependencyLinkMetadatum' @@ -14,6 +16,32 @@ class Packages::DependencyLink < ApplicationRecord scope :with_dependency_type, ->(dependency_type) { where(dependency_type: dependency_type) } scope :includes_dependency, -> { includes(:dependency) } scope :for_package, ->(package) { where(package_id: package.id) } + scope :for_packages, ->(packages) { where(package: packages) } scope :preload_dependency, -> { preload(:dependency) } scope :preload_nuget_metadatum, -> { preload(:nuget_metadatum) } + scope :select_dependency_id, -> { select(:dependency_id) } + + def self.dependency_ids_grouped_by_type(packages) + inner_query = where(package_id: packages) + .select(' + package_id, + dependency_type, + ARRAY_AGG(dependency_id) as dependency_ids + ') + .group(:package_id, :dependency_type) + + cte = Gitlab::SQL::CTE.new(:dependency_links_cte, inner_query) + cte_alias = cte.table.alias(table_name) + + with(cte.to_arel) + .select(' + package_id, + JSON_OBJECT_AGG( + dependency_type, + dependency_ids + ) AS dependency_ids_by_type + ') + .from(cte_alias) + .group(:package_id) + end end diff --git a/app/models/packages/ml_model/package.rb b/app/models/packages/ml_model/package.rb new file mode 100644 index 00000000000..de2b5f8f2a8 --- /dev/null +++ b/app/models/packages/ml_model/package.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Packages + module MlModel + class Package < Packages::Package + has_one :model_version, class_name: "Ml::ModelVersion", inverse_of: :package + + validates :name, + format: Gitlab::Regex.ml_model_name_regex, + presence: true, + length: { maximum: 255 } + + validates :version, + format: Gitlab::Regex.semver_regex, + presence: true, + length: { maximum: 255 } + end + end +end diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb index e7cf4528f16..1025af0fd24 100644 --- a/app/models/packages/nuget/metadatum.rb +++ b/app/models/packages/nuget/metadatum.rb @@ -15,8 +15,7 @@ class Packages::Nuget::Metadatum < ApplicationRecord validates :icon_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH } validates :authors, presence: true, length: { maximum: MAX_AUTHORS_LENGTH } validates :description, presence: true, length: { maximum: MAX_DESCRIPTION_LENGTH } - validates :normalized_version, presence: true, - if: -> { Feature.enabled?(:nuget_normalized_version, package&.project) } + validates :normalized_version, presence: true validate :ensure_nuget_package_type diff --git a/app/models/packages/nuget/symbol.rb b/app/models/packages/nuget/symbol.rb new file mode 100644 index 00000000000..643b5552d84 --- /dev/null +++ b/app/models/packages/nuget/symbol.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class Symbol < ApplicationRecord + include FileStoreMounter + + belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_symbols + + delegate :project_id, to: :package + + validates :package, :file, :file_path, :signature, :object_storage_key, :size, presence: true + validates :signature, uniqueness: { scope: :file_path } + validates :object_storage_key, uniqueness: true + + mount_file_store_uploader SymbolUploader + + before_validation :set_object_storage_key, on: :create + + private + + def set_object_storage_key + return unless project_id && signature + + self.object_storage_key = Gitlab::HashedPath.new( + 'packages', 'nuget', package_id, 'symbols', OpenSSL::Digest::SHA256.hexdigest(signature), + root_hash: project_id + ).to_s + end + end + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index b09911f4216..02e3908b3bf 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -7,6 +7,7 @@ class Packages::Package < ApplicationRecord include Gitlab::Utils::StrongMemoize include Packages::Installable include Packages::Downloadable + include EnumInheritance DISPLAYABLE_STATUSES = [:default, :error].freeze INSTALLABLE_STATUSES = [:default, :hidden].freeze @@ -48,6 +49,7 @@ class Packages::Package < ApplicationRecord has_one :pypi_metadatum, inverse_of: :package, class_name: 'Packages::Pypi::Metadatum' has_one :maven_metadatum, inverse_of: :package, class_name: 'Packages::Maven::Metadatum' has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum' + has_many :nuget_symbols, inverse_of: :package, class_name: 'Packages::Nuget::Symbol' has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum' has_one :rubygems_metadatum, inverse_of: :package, class_name: 'Packages::Rubygems::Metadatum' has_one :rpm_metadatum, inverse_of: :package, class_name: 'Packages::Rpm::Metadatum' @@ -179,11 +181,7 @@ class Packages::Package < ApplicationRecord scope :preload_conan_metadatum, -> { preload(:conan_metadatum) } scope :with_npm_scope, ->(scope) do - if Feature.enabled?(:npm_package_registry_fix_group_path_validation) - npm.where("position('/' in packages_packages.name) > 0 AND split_part(packages_packages.name, '/', 1) = :package_scope", package_scope: "@#{sanitize_sql_like(scope)}") - else - npm.where("name ILIKE :package_name", package_name: "@#{sanitize_sql_like(scope)}/%") - end + npm.where("position('/' in packages_packages.name) > 0 AND split_part(packages_packages.name, '/', 1) = :package_scope", package_scope: "@#{sanitize_sql_like(scope)}") end scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } @@ -220,6 +218,12 @@ class Packages::Package < ApplicationRecord joins(:project).reorder(keyset_order) end + def self.inheritance_column = 'package_type' + + def self.inheritance_column_to_class_map = { + ml_model: 'Packages::MlModel::Package' + }.freeze + def self.only_maven_packages_with_path(path, use_cte: false) if use_cte # This is an optimization fence which assumes that looking up the Metadatum record by path (globally) diff --git a/app/models/packages/protection.rb b/app/models/packages/protection.rb new file mode 100644 index 00000000000..ebaecf89992 --- /dev/null +++ b/app/models/packages/protection.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Packages + module Protection + def self.table_name_prefix + 'packages_protection_' + end + end +end diff --git a/app/models/packages/protection/rule.rb b/app/models/packages/protection/rule.rb new file mode 100644 index 00000000000..bb65be92b90 --- /dev/null +++ b/app/models/packages/protection/rule.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Packages + module Protection + class Rule < ApplicationRecord + enum package_type: Packages::Package.package_types.slice(:npm) + + belongs_to :project, inverse_of: :package_protection_rules + + validates :package_name_pattern, presence: true, uniqueness: { scope: [:project_id, :package_type] }, + length: { maximum: 255 } + validates :package_type, presence: true + validates :push_protected_up_to_access_level, presence: true, + inclusion: { in: [ + Gitlab::Access::DEVELOPER, + Gitlab::Access::MAINTAINER, + Gitlab::Access::OWNER + ] } + end + end +end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 2ffb2e84cbf..e8becc833ca 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -35,7 +35,7 @@ module Pages { type: 'zip', path: deployment.file.url_or_file_path( - expire_at: ::Gitlab::Pages::CacheControl::DEPLOYMENT_EXPIRATION.from_now + expire_at: ::Gitlab::Pages::DEPLOYMENT_EXPIRATION.from_now ), global_id: global_id, sha256: deployment.file_sha256, diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index fafbe449c8c..0a64e91bf60 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -2,9 +2,8 @@ module Pages class VirtualDomain - def initialize(projects:, cache: nil, trim_prefix: nil, domain: nil) + def initialize(projects:, trim_prefix: nil, domain: nil) @projects = projects - @cache = cache @trim_prefix = trim_prefix @domain = domain end @@ -18,23 +17,19 @@ module Pages end def lookup_paths - paths = projects.map do |project| - project.pages_lookup_path(trim_prefix: trim_prefix, domain: domain) - end - - # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/328715 - paths = paths.select(&:source) - - paths.sort_by(&:prefix).reverse - end - - # cache_key is required by #present_cached in ::API::Internal::Pages - def cache_key - @cache_key ||= cache&.cache_key + projects + .map { |project| lookup_paths_for(project) } + .select(&:source) # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/328715 + .sort_by(&:prefix) + .reverse end private - attr_reader :projects, :trim_prefix, :domain, :cache + attr_reader :projects, :trim_prefix, :domain + + def lookup_paths_for(project) + Pages::LookupPath.new(project, trim_prefix: trim_prefix, domain: domain) + end end end diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index ec2293fa032..de7b2416258 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -11,13 +11,16 @@ class PagesDeployment < ApplicationRecord attribute :file_store, :integer, default: -> { ::Pages::DeploymentUploader.default_store } belongs_to :project, optional: false + + # ci_build is optional, because PagesDeployment must live even if its build/pipeline is removed. belongs_to :ci_build, class_name: 'Ci::Build', optional: true - scope :older_than, -> (id) { where('id < ?', id) } + scope :older_than, ->(id) { where('id < ?', id) } scope :migrated_from_legacy_storage, -> { where(file: MIGRATED_FILE_NAME) } scope :with_files_stored_locally, -> { where(file_store: ::ObjectStorage::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: ::ObjectStorage::Store::REMOTE) } scope :project_id_in, ->(ids) { where(project_id: ids) } + scope :active, -> { where(deleted_at: nil) } validates :file, presence: true validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES } @@ -32,6 +35,14 @@ class PagesDeployment < ApplicationRecord skip_callback :save, :after, :store_file! after_commit :store_file_after_commit!, on: [:create, :update] + def self.deactivate_deployments_older_than(deployment, time: nil) + now = Time.now.utc + active + .older_than(deployment.id) + .where(project_id: deployment.project_id, path_prefix: deployment.path_prefix) + .update_all(updated_at: now, deleted_at: time || now) + end + def migrated? file.filename == MIGRATED_FILE_NAME end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 88d7f0f972a..b86bc761cc1 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -9,6 +9,8 @@ class PagesDomain < ApplicationRecord VERIFICATION_THRESHOLD = 3.days.freeze SSL_RENEWAL_THRESHOLD = 30.days.freeze + MAX_CERTIFICATE_KEY_LENGTH = 8192 + enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate enum scope: { instance: 0, group: 1, project: 2 }, _prefix: :scope, _default: :project enum usage: { pages: 0, serverless: 1 }, _prefix: :usage, _default: :pages @@ -34,6 +36,7 @@ class PagesDomain < ApplicationRecord validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } validate :validate_custom_domain_count_per_project, on: :create + validate :max_certificate_key_length, if: ->(domain) { domain.key.present? } attribute :auto_ssl_enabled, default: -> { ::Gitlab::LetsEncrypt.enabled? } attribute :wildcard, default: false @@ -234,6 +237,16 @@ class PagesDomain < ApplicationRecord private + def max_certificate_key_length + return unless pkey.is_a?(OpenSSL::PKey::RSA) + return if pkey.to_s.bytesize <= MAX_CERTIFICATE_KEY_LENGTH + + errors.add( + :key, + s_("PagesDomain|Certificate Key is too long. (Max %d bytes)") % MAX_CERTIFICATE_KEY_LENGTH + ) + end + def set_verification_code return if self.verification_code.present? diff --git a/app/models/performance_monitoring/prometheus_metric.rb b/app/models/performance_monitoring/prometheus_metric.rb deleted file mode 100644 index d67b1809d93..00000000000 --- a/app/models/performance_monitoring/prometheus_metric.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module PerformanceMonitoring - class PrometheusMetric - include ActiveModel::Model - - attr_accessor :id, :unit, :label, :query, :query_range - - validates :unit, presence: true - validates :query, presence: true, unless: :query_range - validates :query_range, presence: true, unless: :query - - class << self - def from_json(json_content) - build_from_hash(json_content).tap(&:validate!) - end - - private - - def build_from_hash(attributes) - return new unless attributes.is_a?(Hash) - - new( - id: attributes['id'], - unit: attributes['unit'], - label: attributes['label'], - query: attributes['query'], - query_range: attributes['query_range'] - ) - end - end - end -end diff --git a/app/models/performance_monitoring/prometheus_panel.rb b/app/models/performance_monitoring/prometheus_panel.rb deleted file mode 100644 index b33c09001ae..00000000000 --- a/app/models/performance_monitoring/prometheus_panel.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module PerformanceMonitoring - class PrometheusPanel - include ActiveModel::Model - - attr_accessor :type, :title, :y_label, :weight, :metrics, :y_axis, :max_value - - validates :title, presence: true - validates :metrics, array_members: { member_class: PerformanceMonitoring::PrometheusMetric } - - class << self - def from_json(json_content) - build_from_hash(json_content).tap(&:validate!) - end - - private - - def build_from_hash(attributes) - return new unless attributes.is_a?(Hash) - - new( - type: attributes['type'], - title: attributes['title'], - y_label: attributes['y_label'], - weight: attributes['weight'], - metrics: initialize_children_collection(attributes['metrics']) - ) - end - - def initialize_children_collection(children) - return unless children.is_a?(Array) - - children.map { |metrics| PerformanceMonitoring::PrometheusMetric.from_json(metrics) } - end - end - - def id(group_title) - Digest::SHA2.hexdigest([group_title, type, title].join) - end - end -end diff --git a/app/models/performance_monitoring/prometheus_panel_group.rb b/app/models/performance_monitoring/prometheus_panel_group.rb deleted file mode 100644 index 7f3d2a1b8f4..00000000000 --- a/app/models/performance_monitoring/prometheus_panel_group.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module PerformanceMonitoring - class PrometheusPanelGroup - include ActiveModel::Model - - attr_accessor :group, :priority, :panels - - validates :group, presence: true - validates :panels, array_members: { member_class: PerformanceMonitoring::PrometheusPanel } - - class << self - def from_json(json_content) - build_from_hash(json_content).tap(&:validate!) - end - - private - - def build_from_hash(attributes) - return new unless attributes.is_a?(Hash) - - new( - group: attributes['group'], - priority: attributes['priority'], - panels: initialize_children_collection(attributes['panels']) - ) - end - - def initialize_children_collection(children) - return unless children.is_a?(Array) - - children.map { |panels| PerformanceMonitoring::PrometheusPanel.from_json(panels) } - end - end - end -end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 08f725de980..4dfe7252a0c 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -14,7 +14,7 @@ class PersonalAccessToken < ApplicationRecord format_with_prefix: :prefix_from_application_current_settings # PATs are 20 characters + optional configurable settings prefix (0..20) - TOKEN_LENGTH_RANGE = (20..40).freeze + TOKEN_LENGTH_RANGE = (20..40) MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS = 365 serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/plan.rb b/app/models/plan.rb index 22c1201421c..9ab22bc045a 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -5,6 +5,8 @@ class Plan < MainClusterwide::ApplicationRecord has_one :limits, class_name: 'PlanLimits' + scope :by_name, ->(name) { where(name: name) } + ALL_PLANS = [DEFAULT].freeze DEFAULT_PLANS = [DEFAULT].freeze private_constant :ALL_PLANS, :DEFAULT_PLANS diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index bc3898fafe7..7d043bae91c 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -8,15 +8,15 @@ class PoolRepository < ApplicationRecord include AfterCommitQueue belongs_to :source_project, class_name: 'Project' - validates :source_project, presence: true has_many :member_projects, class_name: 'Project' after_create :set_disk_path scope :by_source_project, ->(project) { where(source_project: project) } - scope :by_source_project_and_shard_name, ->(project, shard_name) do - by_source_project(project) + scope :by_disk_path, ->(disk_path) { where(disk_path: disk_path) } + scope :by_disk_path_and_shard_name, ->(disk_path, shard_name) do + by_disk_path(disk_path) .for_repository_storage(shard_name) end @@ -101,8 +101,8 @@ class PoolRepository < ApplicationRecord @object_pool ||= Gitlab::Git::ObjectPool.new( shard.name, disk_path + '.git', - source_project.repository.raw, - source_project.full_path + source_project&.repository&.raw, + source_project&.full_path ) end diff --git a/app/models/project.rb b/app/models/project.rb index ad8757880fd..68196f0a757 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -44,9 +44,12 @@ class Project < ApplicationRecord include IssueParent include UpdatedAtFilterable include IgnorableColumns + include CrossDatabaseIgnoredTables ignore_column :emails_disabled, remove_with: '16.3', remove_after: '2023-08-22' + cross_database_ignore_tables %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424277' + extend Gitlab::Cache::RequestCache extend Gitlab::Utils::Override @@ -68,10 +71,10 @@ class Project < ApplicationRecord }.freeze VALID_IMPORT_PORTS = [80, 443].freeze - VALID_IMPORT_PROTOCOLS = %w(http https git).freeze + VALID_IMPORT_PROTOCOLS = %w[http https git].freeze VALID_MIRROR_PORTS = [22, 80, 443].freeze - VALID_MIRROR_PROTOCOLS = %w(http https ssh git).freeze + VALID_MIRROR_PROTOCOLS = %w[http https ssh git].freeze SORTING_PREFERENCE_FIELD = :projects_sort MAX_BUILD_TIMEOUT = 1.month @@ -81,6 +84,8 @@ class Project < ApplicationRecord MAX_SUGGESTIONS_TEMPLATE_LENGTH = 255 MAX_COMMIT_TEMPLATE_LENGTH = 500 + INSTANCE_RUNNER_RUNNING_JOBS_MAX_BUCKET = 5 + DEFAULT_MERGE_COMMIT_TEMPLATE = <<~MSG.rstrip.freeze Merge branch '%{source_branch}' into '%{target_branch}' @@ -163,6 +168,7 @@ class Project < ApplicationRecord # Relations belongs_to :pool_repository belongs_to :creator, class_name: 'User' + belongs_to :organization, class_name: 'Organizations::Organization' belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id' belongs_to :namespace # Sync deletion via DB Trigger to ensure we do not have @@ -265,6 +271,9 @@ class Project < ApplicationRecord dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :npm_metadata_caches, class_name: 'Packages::Npm::MetadataCache' has_one :packages_cleanup_policy, class_name: 'Packages::Cleanup::Policy', inverse_of: :project + has_many :package_protection_rules, + class_name: 'Packages::Protection::Rule', + inverse_of: :project has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -273,7 +282,6 @@ class Project < ApplicationRecord has_one :project_repository, inverse_of: :project has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' - has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' has_one :grafana_integration, inverse_of: :project has_one :project_setting, inverse_of: :project, autosave: true has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting' @@ -336,7 +344,15 @@ class Project < ApplicationRecord primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, class_name: 'ProjectMember' - has_many :users, through: :project_members + has_many :users, -> { allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") }, + through: :project_members + has_many :maintainers, + -> do + allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") + .where(members: { access_level: Gitlab::Access::MAINTAINER }) + end, + through: :project_members, + source: :user has_many :project_callouts, class_name: 'Users::ProjectCallout', foreign_key: :project_id @@ -370,8 +386,6 @@ class Project < ApplicationRecord has_many :prometheus_metrics has_many :prometheus_alerts, inverse_of: :project has_many :prometheus_alert_events, inverse_of: :project - has_many :self_managed_prometheus_alert_events, inverse_of: :project - has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project @@ -476,7 +490,6 @@ class Project < ApplicationRecord accepts_nested_attributes_for :incident_management_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true - accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true accepts_nested_attributes_for :prometheus_integration, update_only: true accepts_nested_attributes_for :alerting_setting, update_only: true @@ -492,11 +505,6 @@ class Project < ApplicationRecord delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role end - with_options to: :metrics_setting, allow_nil: true, prefix: true do - delegate :external_dashboard_url - delegate :dashboard_timezone - end - with_options to: :namespace do delegate :actual_limits, :actual_plan_name, :actual_plan, :root_ancestor, allow_nil: true delegate :maven_package_requests_forwarding, :pypi_package_requests_forwarding, :npm_package_requests_forwarding @@ -1282,7 +1290,7 @@ class Project < ApplicationRecord def design_repository strong_memoize(:design_repository) do - Gitlab::GlRepository::DESIGN.repository_for(self) + find_or_create_design_management_repository.repository end end @@ -1665,7 +1673,7 @@ class Project < ApplicationRecord return unless Gitlab::Email::IncomingEmail.supports_issue_creation? && author # check since this can come from a request parameter - return unless %w(issue merge_request).include?(address_type) + return unless %w[issue merge_request].include?(address_type) author.ensure_incoming_email_token! @@ -2757,10 +2765,6 @@ class Project < ApplicationRecord [] end - def mark_primary_write_location - self.class.sticking.mark_primary_write_location(:project, self.id) - end - def toggle_ci_cd_settings!(settings_attribute) ci_cd_settings.toggle!(settings_attribute) end @@ -2842,7 +2846,7 @@ class Project < ApplicationRecord return if old_pool_repository.blank? return if pool_repository_shard_matches_repository?(old_pool_repository) - new_pool_repository = PoolRepository.by_source_project_and_shard_name(old_pool_repository.source_project, repository_storage).take! + new_pool_repository = PoolRepository.by_disk_path_and_shard_name(old_pool_repository.disk_path, repository_storage).take! update!(pool_repository: new_pool_repository) old_pool_repository.unlink_repository(repository, disconnect: !pending_delete?) @@ -2871,10 +2875,6 @@ class Project < ApplicationRecord recipients end - def pages_lookup_path(trim_prefix: nil, domain: nil) - Pages::LookupPath.new(self, trim_prefix: trim_prefix, domain: domain) - end - def closest_setting(name) setting = read_attribute(name) setting = closest_namespace_setting(name) if setting.nil? @@ -2954,10 +2954,6 @@ class Project < ApplicationRecord jira_imports.last end - def metrics_setting - super || build_metrics_setting - end - def service_desk_enabled Gitlab::ServiceDesk.enabled?(project: self) end @@ -2965,7 +2961,11 @@ class Project < ApplicationRecord alias_method :service_desk_enabled?, :service_desk_enabled def service_desk_address - service_desk_custom_address || service_desk_incoming_address + service_desk_custom_address || service_desk_system_address + end + + def service_desk_system_address + service_desk_alias_address || service_desk_incoming_address end def service_desk_incoming_address @@ -2977,7 +2977,7 @@ class Project < ApplicationRecord config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}") end - def service_desk_custom_address + def service_desk_alias_address return unless Gitlab::Email::ServiceDeskEmail.enabled? key = service_desk_setting&.project_key || default_service_desk_suffix @@ -2985,6 +2985,13 @@ class Project < ApplicationRecord Gitlab::Email::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") end + def service_desk_custom_address + return unless Feature.enabled?(:service_desk_custom_email, self) + return unless service_desk_setting&.custom_email_enabled? + + service_desk_setting.custom_email + end + def default_service_desk_suffix "#{id}-issue-" end @@ -3261,6 +3268,10 @@ class Project < ApplicationRecord group.crm_enabled? end + def supports_lock_on_merge? + group&.supports_lock_on_merge? || ::Feature.enabled?(:enforce_locked_labels_on_merge, self, type: :ops) + end + def path_availability base, _, host = path.partition('.') @@ -3270,6 +3281,13 @@ class Project < ApplicationRecord errors.add(:path, s_('Project|already in use')) end + def instance_runner_running_jobs_count + # excluding currently started job + ::Ci::RunningBuild.instance_type.where(project_id: self.id) + .limit(INSTANCE_RUNNER_RUNNING_JOBS_MAX_BUCKET + 1).count - 1 + end + strong_memoize_attr :instance_runner_running_jobs_count + private # overridden in EE @@ -3483,11 +3501,11 @@ class Project < ApplicationRecord end def sync_project_namespace? - (changes.keys & %w(name path namespace_id namespace visibility_level shared_runners_enabled)).any? && project_namespace.present? + (changes.keys & %w[name path namespace_id namespace visibility_level shared_runners_enabled]).any? && project_namespace.present? end def reload_project_namespace_details - return unless (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && project_namespace.namespace_details.present? + return unless (previous_changes.keys & %w[description description_html cached_markdown_version]).any? && project_namespace.namespace_details.present? project_namespace.namespace_details.reset end diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 99128d3cddf..c328e7d37c8 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -11,6 +11,11 @@ class ProjectAuthorization < ApplicationRecord validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true validates :user, uniqueness: { scope: :project }, presence: true + scope :non_guests, -> { where('access_level > ?', ::Gitlab::Access::GUEST) } + + # TODO: To be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/418205 + before_create :assign_is_unique + def self.select_from_union(relations) from_union(relations) .select(['project_id', 'MAX(access_level) AS access_level']) @@ -25,6 +30,12 @@ class ProjectAuthorization < ApplicationRecord def self.insert_all(attributes) super(attributes, unique_by: connection.schema_cache.primary_keys(table_name)) end + + private + + def assign_is_unique + self.is_unique = true + end end ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb index 1d717950c1c..1f0cec1a50c 100644 --- a/app/models/project_authorizations/changes.rb +++ b/app/models/project_authorizations/changes.rb @@ -90,6 +90,8 @@ module ProjectAuthorizations log_details(entire_size: attributes.size, batch_size: BATCH_SIZE) if add_delay attributes.each_slice(BATCH_SIZE) do |attributes_batch| + attributes_batch.each { |attrs| attrs[:is_unique] = true } + ProjectAuthorization.insert_all(attributes_batch) perform_delay if add_delay end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index cc9003423be..8d049b8d1b1 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -19,6 +19,7 @@ class ProjectCiCdSetting < ApplicationRecord attribute :forward_deployment_enabled, default: true attribute :separated_caches, default: true + validates :merge_trains_skip_train_allowed, inclusion: { in: [true, false] } chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 92ba02ec777..36f1e09b2ba 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -173,6 +173,10 @@ class ProjectFeature < ApplicationRecord package_registry_access_level == PUBLIC || project.public? end + def private?(feature) + access_level(feature) == PRIVATE + end + private def set_pages_access_level @@ -201,11 +205,11 @@ class ProjectFeature < ApplicationRecord self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed end - %i(merge_requests_access_level builds_access_level).each(&validator) + %i[merge_requests_access_level builds_access_level].each(&validator) end def feature_validation_exclusion - %i(pages package_registry) + %i[pages package_registry] end override :resource_member? diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index f16d661d4bb..a7b2c40557a 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -132,10 +132,17 @@ class ProjectImportState < ApplicationRecord alias_method :no_import?, :none? + # This method is coupled to the repository mirror domain. + # Use with caution in the importers domain. As an alternative, use the `#completed?` method. + # See EE-override and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/4697 def in_progress? scheduled? || started? end + def completed? + finished? || failed? || canceled? + end + def started? # import? does SQL work so only run it if it looks like there's an import running status == 'started' && project.import? diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb deleted file mode 100644 index c66d0f52f4c..00000000000 --- a/app/models/project_metrics_setting.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class ProjectMetricsSetting < ApplicationRecord - belongs_to :project - - validates :external_dashboard_url, - allow_nil: true, - length: { maximum: 255 }, - addressable_url: { enforce_sanitization: true, ascii_only: true } - - enum dashboard_timezone: { local: 0, utc: 1 } - - def dashboard_timezone=(val) - super(val&.downcase) - end -end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index fec951eb7fe..69d1a9f4aeb 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -3,28 +3,25 @@ class ProjectSetting < ApplicationRecord include ::Gitlab::Utils::StrongMemoize include EachBatch + include IgnorableColumns - ALLOWED_TARGET_PLATFORMS = %w(ios osx tvos watchos android).freeze + ALLOWED_TARGET_PLATFORMS = %w[ios osx tvos watchos android].freeze belongs_to :project, inverse_of: :project_setting scope :for_projects, ->(projects) { where(project_id: projects) } - attr_encrypted :cube_api_key, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm', - encode: false, - encode_iv: false + ignore_columns %i[ + encrypted_product_analytics_clickhouse_connection_string + encrypted_product_analytics_clickhouse_connection_string_iv + encrypted_jitsu_administrator_password + encrypted_jitsu_administrator_password_iv + jitsu_host + jitsu_project_xid + jitsu_administrator_email + ], remove_with: '16.5', remove_after: '2023-09-22' - attr_encrypted :jitsu_administrator_password, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm', - encode: false, - encode_iv: false - - attr_encrypted :product_analytics_clickhouse_connection_string, + attr_encrypted :cube_api_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 3b9b82ee094..34754f4fc95 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -80,6 +80,7 @@ class ProjectTeam # so we filter out only members of project or project's group def members_in_project_and_ancestors members.where(id: member_user_ids) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422405') end def members_with_access_levels(access_levels = []) diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index 67d765a15c0..e088fe81f6e 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -8,10 +8,10 @@ module Releases # See https://gitlab.com/gitlab-org/gitlab/-/issues/218753 # Regex modified to prevent catastrophic backtracking - FILEPATH_REGEX = %r{\A\/[^\/](?!.*\/\/.*)[\-\.\w\/]+[\da-zA-Z]+\z}.freeze + FILEPATH_REGEX = %r{\A\/[^\/](?!.*\/\/.*)[\-\.\w\/]+[\da-zA-Z]+\z} FILEPATH_MAX_LENGTH = 128 - validates :url, presence: true, addressable_url: { schemes: %w(http https ftp) }, uniqueness: { scope: :release } + validates :url, presence: true, addressable_url: { schemes: %w[http https ftp] }, uniqueness: { scope: :release } validates :name, presence: true, uniqueness: { scope: :release } validates :filepath, uniqueness: { scope: :release }, allow_blank: true validate :filepath_format_valid? diff --git a/app/models/repository.rb b/app/models/repository.rb index b8a46f80bc7..1c27a7a64cf 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -47,27 +47,26 @@ class Repository # # For example, for entry `:commit_count` there's a method called `commit_count` which # stores its data in the `commit_count` cache key. - CACHED_METHODS = %i(size recent_objects_size commit_count readme_path contribution_guide + CACHED_METHODS = %i[size recent_objects_size commit_count readme_path contribution_guide changelog license_blob license_gitaly gitignore gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref merged_branch_names has_visible_content? issue_template_names_hash merge_request_template_names_hash - user_defined_metrics_dashboard_paths xcode_project? has_ambiguous_refs?).freeze + xcode_project? has_ambiguous_refs?].freeze # Certain method caches should be refreshed when certain types of files are # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to # the corresponding methods to call for refreshing caches. METHOD_CACHES_FOR_FILE_TYPES = { - readme: %i(readme_path), + readme: %i[readme_path], changelog: :changelog, - license: %i(license_blob license_gitaly), + license: %i[license_blob license_gitaly], contributing: :contribution_guide, gitignore: :gitignore, gitlab_ci: :gitlab_ci_yml, avatar: :avatar, issue_template: :issue_template_names_hash, merge_request_template: :merge_request_template_names_hash, - metrics_dashboard: :user_defined_metrics_dashboard_paths, xcode_config: :xcode_project? }.freeze @@ -344,13 +343,13 @@ class Repository end def expire_tags_cache - expire_method_caches(%i(tag_names tag_count has_ambiguous_refs?)) + expire_method_caches(%i[tag_names tag_count has_ambiguous_refs?]) @tags = nil @tag_names_include = nil end def expire_branches_cache - expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content? has_ambiguous_refs?)) + expire_method_caches(%i[branch_names merged_branch_names branch_count has_visible_content? has_ambiguous_refs?]) expire_protected_branches_cache @local_branches = nil @@ -363,7 +362,7 @@ class Repository end def expire_statistics_caches - expire_method_caches(%i(size recent_objects_size commit_count)) + expire_method_caches(%i[size recent_objects_size commit_count]) end def expire_all_method_caches @@ -371,7 +370,7 @@ class Repository end def expire_avatar_cache - expire_method_caches(%i(avatar)) + expire_method_caches(%i[avatar]) end # Refreshes the method caches of this repository. @@ -412,19 +411,19 @@ class Repository end def expire_root_ref_cache - expire_method_caches(%i(root_ref)) + expire_method_caches(%i[root_ref]) end # Expires the cache(s) used to determine if a repository is empty or not. def expire_emptiness_caches return unless empty? - expire_method_caches(%i(has_visible_content?)) + expire_method_caches(%i[has_visible_content?]) raw_repository.expire_has_local_branches_cache end def expire_exists_cache - expire_method_caches(%i(exists?)) + expire_method_caches(%i[exists?]) end # expire cache that doesn't depend on repository data (when expiring) @@ -628,11 +627,6 @@ class Repository end cache_method :merge_request_template_names_hash, fallback: {} - def user_defined_metrics_dashboard_paths - Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project) - end - cache_method :user_defined_metrics_dashboard_paths, fallback: [] - def readme head_tree&.readme end @@ -1250,6 +1244,8 @@ class Repository def get_patch_id(old_revision, new_revision) raw_repository.get_patch_id(old_revision, new_revision) + rescue Gitlab::Git::CommandError + nil end def object_pool diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 13610d37a74..d5c839724d4 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -13,8 +13,7 @@ class ResourceLabelEvent < ResourceEvent validates :label, presence: { unless: :importing? }, on: :create validate :exactly_one_issuable, unless: :importing? - after_destroy :expire_etag_cache - after_save :expire_etag_cache + after_commit :broadcast_notes_changed, unless: :importing? enum action: { add: 1, @@ -22,7 +21,7 @@ class ResourceLabelEvent < ResourceEvent } def self.issuable_attrs - %i(issue merge_request).freeze + %i[issue merge_request].freeze end def self.preload_label_subjects(events) @@ -97,8 +96,8 @@ class ResourceLabelEvent < ResourceEvent issuable.is_a?(MergeRequest) ? :project_merge_requests_url : :project_issues_url end - def expire_etag_cache - issuable.expire_note_etag_cache + def broadcast_notes_changed + issuable.broadcast_notes_changed end def local_label? diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 134f71e35ad..88a86258b0a 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -14,7 +14,7 @@ class ResourceStateEvent < ResourceEvent after_create :issue_usage_metrics def self.issuable_attrs - %i(issue merge_request).freeze + %i[issue merge_request].freeze end def issuable diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index 1cc77501d8d..644ffae5749 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -16,7 +16,7 @@ class ResourceTimeboxEvent < ResourceEvent after_create :issue_usage_metrics def self.issuable_attrs - %i(issue merge_request).freeze + %i[issue merge_request].freeze end def issuable diff --git a/app/models/review.rb b/app/models/review.rb index d47aaf027ce..98e9a314df7 100644 --- a/app/models/review.rb +++ b/app/models/review.rb @@ -31,6 +31,10 @@ class Review < ApplicationRecord def user_mentions merge_request.user_mentions.where.not(note_id: nil) end + + def from_merge_request_author? + merge_request.author_id == author_id + end end Review.prepend_mod diff --git a/app/models/self_managed_prometheus_alert_event.rb b/app/models/self_managed_prometheus_alert_event.rb deleted file mode 100644 index cf26563e92d..00000000000 --- a/app/models/self_managed_prometheus_alert_event.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class SelfManagedPrometheusAlertEvent < ApplicationRecord - include AlertEventLifecycle - - belongs_to :project, validate: true, inverse_of: :self_managed_prometheus_alert_events - belongs_to :environment, validate: true, inverse_of: :self_managed_prometheus_alert_events - has_and_belongs_to_many :related_issues, class_name: 'Issue', join_table: :issues_self_managed_prometheus_alert_events # rubocop:disable Rails/HasAndBelongsToMany - - validates :started_at, presence: true - validates :payload_key, uniqueness: { scope: :project_id } - - def self.find_or_initialize_by_payload_key(project, payload_key) - find_or_initialize_by(project: project, payload_key: payload_key) do |event| - yield event if block_given? - end - end -end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index f3a0479d3b7..30c53b978f8 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class SentNotification < ApplicationRecord + include IgnorableColumns + + ignore_column %i[id_convert_to_bigint], remove_with: '16.5', remove_after: '2023-09-22' + belongs_to :project belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :recipient, class_name: "User" diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 9139dc22a94..a262802c8af 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -5,7 +5,7 @@ class SnippetRepository < ApplicationRecord include Shardable DEFAULT_EMPTY_FILE_NAME = 'snippetfile' - EMPTY_FILE_PATTERN = /^#{DEFAULT_EMPTY_FILE_NAME}(\d+)\.txt$/.freeze + EMPTY_FILE_PATTERN = /^#{DEFAULT_EMPTY_FILE_NAME}(\d+)\.txt$/ CommitError = Class.new(StandardError) InvalidPathError = Class.new(CommitError) diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index ecd3e27a9c4..7caf3a1040b 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -5,7 +5,7 @@ module Terraform include UsageStatistics include AfterCommitQueue - HEX_REGEXP = %r{\A\h+\z}.freeze + HEX_REGEXP = %r{\A\h+\z} UUID_LENGTH = 32 self.locking_column = :activerecord_lock_version diff --git a/app/models/user.rb b/app/models/user.rb index 9f85d41b133..c4e867ab571 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -22,7 +22,6 @@ class User < MainClusterwide::ApplicationRecord include FromUnion include BatchDestroyDependentAssociations include BatchNullifyDependentAssociations - include HasUniqueInternalUsers include IgnorableColumns include UpdateHighestRole include HasUserType @@ -31,7 +30,28 @@ class User < MainClusterwide::ApplicationRecord include RestrictedSignup include StripAttribute include EachBatch - include SafelyChangeColumnDefault + include CrossDatabaseIgnoredTables + include IgnorableColumns + + ignore_column %i[ + email_opted_in + email_opted_in_ip + email_opted_in_source_id + email_opted_in_at + ], remove_with: '16.6', remove_after: '2023-10-22' + + # `ensure_namespace_correct` needs to be moved to an after_commit (?) + cross_database_ignore_tables %w[namespaces namespace_settings], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424279' + + # `notification_settings_for` is called, and elsewhere `save` is then called. + cross_database_ignore_tables %w[notification_settings], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424284' + + # Associations with dependent: option + cross_database_ignore_tables( + %w[namespaces projects project_authorizations issues merge_requests merge_requests issues issues merge_requests], + url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424285', + on: :destroy + ) DEFAULT_NOTIFICATION_LEVEL = :participating @@ -55,13 +75,11 @@ class User < MainClusterwide::ApplicationRecord :public_email ].freeze - FORBIDDEN_SEARCH_STATES = %w(blocked banned ldap_blocked).freeze + FORBIDDEN_SEARCH_STATES = %w[blocked banned ldap_blocked].freeze INCOMING_MAIL_TOKEN_PREFIX = 'glimt-' FEED_TOKEN_PREFIX = 'glft-' - columns_changing_default :project_view - # lib/tasks/tokens.rake needs to be updated when changing mail and feed tokens add_authentication_token_field :incoming_email_token, token_generator: -> { self.generate_incoming_mail_token } add_authentication_token_field :feed_token, format_with_prefix: :prefix_for_feed_token @@ -262,8 +280,6 @@ class User < MainClusterwide::ApplicationRecord 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 :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user - has_one :status, class_name: 'UserStatus' has_one :user_preference has_one :user_detail @@ -346,7 +362,9 @@ class User < MainClusterwide::ApplicationRecord email_to_confirm.confirm end else - add_primary_email_to_emails! + ignore_cross_database_tables_if_factory_bot(%w[emails]) do + add_primary_email_to_emails! + end end end after_commit(on: :update) do @@ -378,6 +396,7 @@ class User < MainClusterwide::ApplicationRecord :gitpod_enabled, :gitpod_enabled=, :setup_for_company, :setup_for_company=, :project_shortcut_buttons, :project_shortcut_buttons=, + :keyboard_shortcuts_enabled, :keyboard_shortcuts_enabled=, :render_whitespace_in_code, :render_whitespace_in_code=, :markdown_surround_selection, :markdown_surround_selection=, :markdown_automatic_lists, :markdown_automatic_lists=, @@ -501,11 +520,19 @@ class User < MainClusterwide::ApplicationRecord end after_transition any => :active do |user| - user.starred_projects.update_counters(star_count: 1) + user.class.temporary_ignore_cross_database_tables( + %w[projects], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424278' + ) do + user.starred_projects.update_counters(star_count: 1) + end end after_transition active: any do |user| - user.starred_projects.update_counters(star_count: -1) + user.class.temporary_ignore_cross_database_tables( + %w[projects], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424278' + ) do + user.starred_projects.update_counters(star_count: -1) + end end end @@ -884,92 +911,6 @@ class User < MainClusterwide::ApplicationRecord }x end - # Return (create if necessary) the ghost user. The ghost user - # owns records previously belonging to deleted users. - def ghost - email = 'ghost%s@example.com' - unique_internal(where(user_type: :ghost), 'ghost', email) do |u| - u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.') - u.name = 'Ghost User' - end - end - - def alert_bot - email_pattern = "alert%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u| - u.bio = 'The GitLab alert bot' - u.name = 'GitLab Alert Bot' - u.avatar = bot_avatar(image: 'alert-bot.png') - end - end - - def migration_bot - email_pattern = "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u| - u.bio = 'The GitLab migration bot' - u.name = 'GitLab Migration Bot' - u.confirmed_at = Time.zone.now - end - end - - def security_bot - email_pattern = "security-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u| - u.bio = 'System bot that monitors detected vulnerabilities for solutions and creates merge requests with the fixes.' - u.name = 'GitLab Security Bot' - u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md') - u.avatar = bot_avatar(image: 'security-bot.png') - u.confirmed_at = Time.zone.now - end - end - - def support_bot - email_pattern = "support%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :support_bot), 'support-bot', email_pattern) do |u| - u.bio = 'The GitLab support bot used for Service Desk' - u.name = 'GitLab Support Bot' - u.avatar = bot_avatar(image: 'support-bot.png') - u.confirmed_at = Time.zone.now - end - end - - def automation_bot - email_pattern = "automation%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u| - u.bio = 'The GitLab automation bot used for automated workflows and tasks' - u.name = 'GitLab Automation Bot' - u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot - end - end - - def llm_bot - email_pattern = "llm-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u| - u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content' - u.name = 'GitLab LLM Bot' - u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot - u.confirmed_at = Time.zone.now - end - end - - def admin_bot - email_pattern = "admin-bot%s@#{Settings.gitlab.host}" - - unique_internal(where(user_type: :admin_bot), 'GitLab-Admin-Bot', email_pattern) do |u| - u.bio = 'Admin bot used for tasks that require admin privileges' - u.name = 'GitLab Admin Bot' - u.avatar = bot_avatar(image: 'admin-bot.png') - u.admin = true - u.confirmed_at = Time.zone.now - end - end - # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -2009,7 +1950,7 @@ class User < MainClusterwide::ApplicationRecord def access_level=(new_level) new_level = new_level.to_s - return unless %w(admin regular).include?(new_level) + return unless %w[admin regular].include?(new_level) self.admin = (new_level == 'admin') end @@ -2175,16 +2116,6 @@ class User < MainClusterwide::ApplicationRecord [last_activity, last_sign_in].compact.max end - REQUIRES_ROLE_VALUE = 99 - - def role_required? - role_before_type_cast == REQUIRES_ROLE_VALUE - end - - def set_role_required! - update_column(:role, REQUIRES_ROLE_VALUE) - end - def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil) callout = callouts_by_feature_name[feature_name] @@ -2354,7 +2285,7 @@ class User < MainClusterwide::ApplicationRecord def ban_and_report msg = 'Potential spammer account deletion' - attrs = { user_id: id, reporter: User.security_bot, category: 'spam' } + attrs = { user_id: id, reporter: Users::Internal.security_bot, category: 'spam' } abuse_report = AbuseReport.find_by(attrs) if abuse_report.nil? @@ -2519,7 +2450,7 @@ class User < MainClusterwide::ApplicationRecord def update_highest_role? return false unless persisted? - (previous_changes.keys & %w(state user_type)).any? + (previous_changes.keys & %w[state user_type]).any? end def update_highest_role_attribute diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 425f2cc062b..15d50071bf6 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -15,6 +15,7 @@ class UserCustomAttribute < ApplicationRecord UNBLOCKED_BY = 'unblocked_by' ARKOSE_RISK_BAND = 'arkose_risk_band' AUTO_BANNED_BY_ABUSE_REPORT_ID = 'auto_banned_by_abuse_report_id' + AUTO_BANNED_BY_SPAM_LOG_ID = 'auto_banned_by_spam_log_id' ALLOW_POSSIBLE_SPAM = 'allow_possible_spam' IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt' @@ -45,6 +46,14 @@ class UserCustomAttribute < ApplicationRecord upsert_custom_attributes([custom_attribute]) end + def set_banned_by_spam_log(spam_log) + return unless spam_log + + custom_attribute = { user_id: spam_log.user_id, key: AUTO_BANNED_BY_SPAM_LOG_ID, value: spam_log.id } + + upsert_custom_attributes([custom_attribute]) + end + private def blocked_users diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb index 1c7515894fe..73bca362960 100644 --- a/app/models/user_interacted_project.rb +++ b/app/models/user_interacted_project.rb @@ -24,7 +24,7 @@ class UserInteractedProject < ApplicationRecord } cached_exists?(**attributes) do - where(attributes).exists? || UserInteractedProject.insert_all([attributes], unique_by: %w(project_id user_id)) + where(attributes).exists? || UserInteractedProject.insert_all([attributes], unique_by: %w[project_id user_id]) true end end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index eac66905d0c..8fc9f4617d0 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -35,6 +35,7 @@ class UserPreference < MainClusterwide::ApplicationRecord attribute :time_display_relative, default: true attribute :render_whitespace_in_code, default: false attribute :project_shortcut_buttons, default: true + attribute :keyboard_shortcuts_enabled, default: true enum visibility_pipeline_id_type: { id: 0, iid: 1 } diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 0d3262b2474..def0765560e 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -62,7 +62,7 @@ module Users project_quality_summary_feedback: 59, # EE-only merge_request_settings_moved_callout: 60, new_top_level_group_alert: 61, - artifacts_management_page_feedback_banner: 62, + # 62, removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131314 # 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233 branch_rules_info_callout: 65, create_runner_workflow_banner: 66, @@ -71,9 +71,11 @@ module Users project_repository_limit_alert_alert_threshold: 69, # EE-only project_repository_limit_alert_error_threshold: 70, # EE-only new_navigation_callout: 71, - code_suggestions_third_party_callout: 72, # EE-only + # 72 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129022 namespace_over_storage_users_combined_alert: 73, # EE-only - rich_text_editor: 74 + rich_text_editor: 74, + vsd_feedback_banner: 75, # EE-only + security_policy_protected_branch_modification: 76 # EE-only } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 1b0fd8682db..086943884a5 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -16,6 +16,12 @@ module Users greater_than_or_equal_to: 0, less_than_or_equal_to: 9999 } + validates :last_digits_hash, length: { maximum: 44 } + validates :holder_name_hash, length: { maximum: 44 } + validates :expiration_date_hash, length: { maximum: 44 } + validates :network_hash, length: { maximum: 44 } + + scope :find_or_initialize_by_user, ->(user_id) { where(user_id: user_id).first_or_initialize } scope :by_banned_user, -> { joins(:banned_user) } scope :similar_by_holder_name, ->(holder_name) do if holder_name.present? @@ -32,6 +38,11 @@ module Users ) end + before_save :set_last_digits_hash, if: -> { last_digits.present? } + before_save :set_holder_name_hash, if: -> { holder_name.present? } + before_save :set_network_hash, if: -> { network.present? } + before_save :set_expiration_date_hash, if: -> { expiration_date.present? } + def similar_records self.class.similar_to(self).order(credit_card_validated_at: :desc).includes(:user) end @@ -43,5 +54,21 @@ module Users def used_by_banned_user? self.class.by_banned_user.similar_to(self).similar_by_holder_name(holder_name).exists? end + + def set_last_digits_hash + self.last_digits_hash = Gitlab::CryptoHelper.sha256(last_digits) + end + + def set_holder_name_hash + self.holder_name_hash = Gitlab::CryptoHelper.sha256(holder_name.downcase) + end + + def set_network_hash + self.network_hash = Gitlab::CryptoHelper.sha256(network.downcase) + end + + def set_expiration_date_hash + self.expiration_date_hash = Gitlab::CryptoHelper.sha256(expiration_date.to_s) + end end end diff --git a/app/models/users/group_visit.rb b/app/models/users/group_visit.rb new file mode 100644 index 00000000000..0bcfda049fc --- /dev/null +++ b/app/models/users/group_visit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + class GroupVisit < ApplicationRecord + include Users::Visitable + include PartitionedTable + + self.table_name = "groups_visits" + self.primary_key = :id + + partitioned_by :visited_at, strategy: :monthly, retain_for: 3.months + + validates :entity_id, presence: true + validates :user_id, presence: true + validates :visited_at, presence: true + end +end diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb index 3964f202be6..6affe5b5030 100644 --- a/app/models/users/project_callout.rb +++ b/app/models/users/project_callout.rb @@ -11,10 +11,10 @@ module Users enum feature_name: { awaiting_members_banner: 1, # EE-only web_hook_disabled: 2, - ultimate_feature_removal_banner: 3, + # 3 was removed https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129703, + # and cleaned up https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129924, it can be replaced namespace_storage_pre_enforcement_banner: 4, # EE-only - # 5,6,7 were unused and removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118330, - # they can be replaced. + # 5,6,7 were removed https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118330, they can be replaced license_check_deprecation_alert: 8 # EE-only } diff --git a/app/models/users/project_visit.rb b/app/models/users/project_visit.rb new file mode 100644 index 00000000000..1d076e0be56 --- /dev/null +++ b/app/models/users/project_visit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + class ProjectVisit < ApplicationRecord + include Users::Visitable + include PartitionedTable + + self.table_name = "projects_visits" + self.primary_key = :id + + partitioned_by :visited_at, strategy: :monthly, retain_for: 3.months + + validates :entity_id, presence: true + validates :user_id, presence: true + validates :visited_at, presence: true + end +end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 73156b2f040..62b837eeeb6 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -4,7 +4,8 @@ class WorkItem < Issue include Gitlab::Utils::StrongMemoize COMMON_QUICK_ACTIONS_COMMANDS = [ - :title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to + :title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to, :checkin_reminder, + :subscribe, :unsubscribe, :confidential, :award ].freeze self.table_name = 'issues' @@ -146,6 +147,18 @@ class WorkItem < Issue { common: common_params, widgets: widget_params } end + def linked_work_items(current_user = nil, authorize: true, preload: nil, link_type: nil) + linked_work_items = linked_work_items_query(link_type).preload(preload).reorder('issue_link_id') + return linked_work_items unless authorize + + cross_project_filter = ->(work_items) { work_items.where(project: project) } + Ability.work_items_readable_by_user( + linked_work_items, + current_user, + filters: { read_cross_project: cross_project_filter } + ) + end + private override :parent_link_confidentiality @@ -241,6 +254,21 @@ class WorkItem < Issue errors.add(:work_item_type_id, _('reached maximum depth')) end end + + def linked_work_items_query(link_type) + type_condition = + if link_type == WorkItems::RelatedWorkItemLink::TYPE_RELATES_TO + " AND issue_links.link_type = #{WorkItems::RelatedWorkItemLink.link_types[link_type]}" + else + "" + end + + linked_issues_select + .joins("INNER JOIN issue_links ON + (issue_links.source_id = issues.id AND issue_links.target_id = #{id}#{type_condition}) + OR + (issue_links.target_id = issues.id AND issue_links.source_id = #{id}#{type_condition})") + end end WorkItem.prepend_mod diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index d9e3690b6fc..ea7755b03b4 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -7,7 +7,6 @@ module WorkItems self.table_name = 'work_item_parent_links' MAX_CHILDREN = 100 - PARENT_TYPES = [:issue, :incident].freeze belongs_to :work_item belongs_to :work_item_parent, class_name: 'WorkItem' @@ -122,3 +121,5 @@ module WorkItems end end end + +WorkItems::ParentLink.prepend_mod diff --git a/app/models/work_items/related_work_item_link.rb b/app/models/work_items/related_work_item_link.rb index 4de197d3d35..a911ef5f05d 100644 --- a/app/models/work_items/related_work_item_link.rb +++ b/app/models/work_items/related_work_item_link.rb @@ -6,9 +6,13 @@ module WorkItems self.table_name = 'issue_links' + MAX_LINKS_COUNT = 100 + belongs_to :source, class_name: 'WorkItem' belongs_to :target, class_name: 'WorkItem' + validate :validate_max_number_of_links, on: :create + class << self extend ::Gitlab::Utils::Override @@ -23,5 +27,15 @@ module WorkItems 'work item' end end + + def validate_max_number_of_links + if source && source.linked_work_items(authorize: false).size >= MAX_LINKS_COUNT + errors.add :source, s_('WorkItems|This work item would exceed the maximum number of linked items.') + end + + return unless target && target.linked_work_items(authorize: false).size >= MAX_LINKS_COUNT + + errors.add :target, s_('WorkItems|This work item would exceed the maximum number of linked items.') + end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 369ffc660aa..b7ceeecbc7f 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -44,8 +44,6 @@ module WorkItems # where it's possible to switch between issue and incident. CHANGEABLE_BASE_TYPES = %w[issue incident test_case].freeze - WI_TYPES_WITH_CREATED_HEADER = %w[issue incident ticket].freeze - cache_markdown_field :description, pipeline: :single_line enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] } diff --git a/app/models/work_items/widgets/linked_items.rb b/app/models/work_items/widgets/linked_items.rb index 06a0f6db964..b405555c038 100644 --- a/app/models/work_items/widgets/linked_items.rb +++ b/app/models/work_items/widgets/linked_items.rb @@ -3,7 +3,7 @@ module WorkItems module Widgets class LinkedItems < Base - delegate :related_issues, to: :work_item + delegate :linked_work_items, to: :work_item end end end diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb index 7c2581b8bb2..90f3bd69c47 100644 --- a/app/models/x509_certificate.rb +++ b/app/models/x509_certificate.rb @@ -17,8 +17,6 @@ class X509Certificate < ApplicationRecord # rfc 5280 - 4.2.1.2 Subject Key Identifier validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex } - # rfc 5280 - 4.1.2.6 Subject - validates :subject, presence: true # rfc 5280 - 4.1.2.6 Subject (subjectAltName contains the email address) validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } # rfc 5280 - 4.1.2.2 Serial number diff --git a/app/models/x509_issuer.rb b/app/models/x509_issuer.rb index 81491d8e507..769d56a9838 100644 --- a/app/models/x509_issuer.rb +++ b/app/models/x509_issuer.rb @@ -6,13 +6,16 @@ class X509Issuer < ApplicationRecord # rfc 5280 - 4.2.1.1 Authority Key Identifier validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex } # rfc 5280 - 4.1.2.4 Issuer - validates :subject, presence: true # rfc 5280 - 4.2.1.13 CRL Distribution Points # cRLDistributionPoints extension using URI:http - validates :crl_url, presence: true, public_url: true + validates :crl_url, allow_nil: true, public_url: true def self.safe_create!(attributes) create_with(attributes) .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier]) end + + def self.with_crl_url + where.not(crl_url: nil) + end end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index d6aaa3e983d..1ec2495a661 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -57,6 +57,12 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:can_create_group) { @user&.can_create_group } + # TODO: update to check application setting + # https://gitlab.com/gitlab-org/gitlab/-/issues/423302 + desc 'User can create an organization' + with_options scope: :user, score: 0 + condition(:can_create_organization) { true } + desc "The application is restricted from public visibility" condition(:restricted_public_level, scope: :global) do Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) diff --git a/app/policies/ci/bridge_policy.rb b/app/policies/ci/bridge_policy.rb index 5f9e8eab08a..9cf3a017b39 100644 --- a/app/policies/ci/bridge_policy.rb +++ b/app/policies/ci/bridge_policy.rb @@ -5,8 +5,12 @@ module Ci include Ci::DeployablePolicy condition(:can_update_downstream_branch) do - ::Gitlab::UserAccess.new(@user, container: @subject.downstream_project) - .can_update_branch?(@subject.target_revision_ref) + # `bridge.downstream_project` could be `nil` if the downstream project was removed after the pipeline creation, + # which raises an error in `UserAccess` class because `container` arg must be present. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/424145 for more information. + @subject.downstream_project.present? && + ::Gitlab::UserAccess.new(@user, container: @subject.downstream_project) + .can_update_branch?(@subject.target_revision_ref) end rule { can_update_downstream_branch }.enable :play_job diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 4d21da0226b..1d60b1e79de 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -18,6 +18,10 @@ module Ci @subject.triggered_by?(@user) end + condition(:project_allows_read_dependency) do + can?(:read_dependency, @subject.project) + end + # Disallow users without permissions from accessing internal pipelines rule { ~can?(:read_build) & ~external_pipeline }.policy do prevent :read_pipeline @@ -41,6 +45,10 @@ module Ci enable :read_pipeline_variable end + rule { project_allows_read_dependency }.policy do + enable :read_dependency + end + def ref_protected?(user, project, tag, ref) access = ::Gitlab::UserAccess.new(user, container: project) diff --git a/app/policies/design_management/repository_policy.rb b/app/policies/design_management/repository_policy.rb new file mode 100644 index 00000000000..404f363a03a --- /dev/null +++ b/app/policies/design_management/repository_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module DesignManagement + class RepositoryPolicy < ::BasePolicy + delegate { @subject.project } + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index bf7bfe36254..7594360a91c 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -29,6 +29,7 @@ class GlobalPolicy < BasePolicy prevent :receive_notifications prevent :use_quick_actions prevent :create_group + prevent :create_organization prevent :execute_graphql_mutation end @@ -93,6 +94,10 @@ class GlobalPolicy < BasePolicy enable :create_group end + rule { can_create_organization }.policy do + enable :create_organization + end + rule { can?(:create_group) }.policy do enable :create_group_with_default_branch_protection end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index c50f74f2b35..faa83019bda 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -217,6 +217,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :update_cluster enable :admin_cluster enable :read_deploy_token + enable :read_group_runners enable :create_jira_connect_subscription enable :maintainer_access enable :read_upload diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb index 1c0d996c7d4..401aa45786d 100644 --- a/app/policies/organizations/organization_policy.rb +++ b/app/policies/organizations/organization_policy.rb @@ -14,10 +14,12 @@ module Organizations rule { admin }.policy do enable :admin_organization enable :read_organization + enable :read_organization_user end rule { organization_user }.policy do enable :read_organization + enable :read_organization_user end end end diff --git a/app/policies/organizations/organization_user_policy.rb b/app/policies/organizations/organization_user_policy.rb new file mode 100644 index 00000000000..91d542378c8 --- /dev/null +++ b/app/policies/organizations/organization_user_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationUserPolicy < BasePolicy + delegate :organization + end +end diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb index ace74dca448..9d6a8c22e6d 100644 --- a/app/policies/project_member_policy.rb +++ b/app/policies/project_member_policy.rb @@ -37,3 +37,5 @@ class ProjectMemberPolicy < BasePolicy enable :withdraw_member_access_request end end + +ProjectMemberPolicy.prepend_mod_with('ProjectMemberPolicy') diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 25495bb0221..38e6360f81d 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -862,7 +862,11 @@ class ProjectPolicy < BasePolicy enable :set_pipeline_variables end - rule { ~security_and_compliance_disabled & can?(:developer_access) }.policy do + rule { security_and_compliance_disabled }.policy do + prevent :access_security_and_compliance + end + + rule { can?(:developer_access) }.policy do enable :access_security_and_compliance end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index bc12d210334..6f32f4de62c 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -76,7 +76,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def pipeline_editor_path - project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default + project_ci_pipeline_editor_path(project, branch_name: commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default end def gitpod_blob_url diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 79c1946f3d2..838196e96ac 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -61,6 +61,16 @@ module Ci end # rubocop: enable CodeReuse/ActiveRecord + def project_jobs_running_on_instance_runners_count + # if not instance runner we don't care about that value and present `+Inf` as a placeholder for Prometheus + return '+Inf' unless runner.instance_type? + + return project.instance_runner_running_jobs_count.to_s if + project.instance_runner_running_jobs_count < Project::INSTANCE_RUNNER_RUNNING_JOBS_MAX_BUCKET + + "#{Project::INSTANCE_RUNNER_RUNNING_JOBS_MAX_BUCKET}+" + end + private def create_archive(artifacts) diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb index 1a5b12fa408..75dbbb63f76 100644 --- a/app/presenters/dev_ops_report/metric_presenter.rb +++ b/app/presenters/dev_ops_report/metric_presenter.rb @@ -93,52 +93,52 @@ module DevOpsReport IdeaToProductionStep.new( metric: metric, title: 'Idea', - features: %w(issues) + features: %w[issues] ), IdeaToProductionStep.new( metric: metric, title: 'Issue', - features: %w(issues notes) + features: %w[issues notes] ), IdeaToProductionStep.new( metric: metric, title: 'Plan', - features: %w(milestones boards) + features: %w[milestones boards] ), IdeaToProductionStep.new( metric: metric, title: 'Code', - features: %w(merge_requests) + features: %w[merge_requests] ), IdeaToProductionStep.new( metric: metric, title: 'Commit', - features: %w(merge_requests) + features: %w[merge_requests] ), IdeaToProductionStep.new( metric: metric, title: 'Test', - features: %w(ci_pipelines) + features: %w[ci_pipelines] ), IdeaToProductionStep.new( metric: metric, title: 'Review', - features: %w(ci_pipelines environments) + features: %w[ci_pipelines environments] ), IdeaToProductionStep.new( metric: metric, title: 'Staging', - features: %w(environments deployments) + features: %w[environments deployments] ), IdeaToProductionStep.new( metric: metric, title: 'Production', - features: %w(deployments) + features: %w[deployments] ), IdeaToProductionStep.new( metric: metric, title: 'Feedback', - features: %w(projects_prometheus_active service_desk_issues) + features: %w[projects_prometheus_active service_desk_issues] ) ] end diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index a098db7fbbc..8f7b2d5868e 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -54,4 +54,29 @@ class EventPresenter < Gitlab::View::Presenter::Delegated target.noteable_type.titleize end.downcase end + + def push_activity_description + return unless push_action? + + if batch_push? + "#{action_name} #{ref_count} #{ref_type.pluralize(ref_count)}" + else + "#{action_name} #{ref_type}" + end + end + + def batch_push? + push_action? && ref_count.to_i > 0 + end + + def linked_to_reference? + return false unless push_action? + return false if event.project.nil? + + if tag? + project.repository.tag_exists?(ref_name) + else + project.repository.branch_exists?(ref_name) + end + end end diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb index 6230e61d2be..0a2738bea5f 100644 --- a/app/presenters/gitlab/blame_presenter.rb +++ b/app/presenters/gitlab/blame_presenter.rb @@ -40,6 +40,10 @@ module Gitlab @commits[commit.id] ||= get_commit_data(commit, previous_path) end + def groups_commit_data + groups.each { |group| group[:commit_data] = commit_data(group[:commit]) } + end + private # Huge source files with a high churn rate (e.g. 'locale/gitlab.pot') could have @@ -86,5 +90,17 @@ module Gitlab def mail_to(*args, &block) ActionController::Base.helpers.mail_to(*args, &block) end + + def project + return super.project if defined?(super.project) + + blame.commit.repository.project + end + + def page + return super.page if defined?(super.page) + + nil + end end end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index 42ecbc9988e..9403dd0814b 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -28,6 +28,9 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated Gitlab::Utils::Email.obfuscated_email(super, deform: true) end + delegator_override :external_author + alias_method :external_author, :service_desk_reply_to + delegator_override :issue_email_participants def issue_email_participants issue.issue_email_participants.present(current_user: current_user) diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb index 8a6569e7bf3..f248652befc 100644 --- a/app/presenters/projects/security/configuration_presenter.rb +++ b/app/presenters/projects/security/configuration_presenter.rb @@ -25,7 +25,8 @@ module Projects auto_fix_enabled: autofix_enabled, can_toggle_auto_fix_settings: can_toggle_autofix, auto_fix_user_path: auto_fix_user_path, - security_training_enabled: project.security_training_available? + security_training_enabled: project.security_training_available?, + continuous_vulnerability_scans_enabled: continuous_vulnerability_scans_enabled } end @@ -83,7 +84,8 @@ module Projects configuration_path: scan.configuration_path, available: scan.available?, can_enable_by_merge_request: scan.can_enable_by_merge_request?, - meta_info_path: scan.meta_info_path + meta_info_path: scan.meta_info_path, + on_demand_available: scan.on_demand_available? } end @@ -94,6 +96,8 @@ module Projects def project_settings project.security_setting end + + def continuous_vulnerability_scans_enabled; end end end end diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb index 91e67c379c4..d79736a6e52 100644 --- a/app/presenters/search_service_presenter.rb +++ b/app/presenters/search_service_presenter.rb @@ -17,7 +17,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated blobs: :with_web_entity_associations }.freeze - SORT_ENABLED_SCOPES = %w(issues merge_requests epics).freeze + SORT_ENABLED_SCOPES = %w[issues merge_requests epics].freeze delegator_override :search_objects def search_objects diff --git a/app/serializers/activity_pub/activity_streams_serializer.rb b/app/serializers/activity_pub/activity_streams_serializer.rb new file mode 100644 index 00000000000..39caa4a6d10 --- /dev/null +++ b/app/serializers/activity_pub/activity_streams_serializer.rb @@ -0,0 +1,90 @@ +# 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/project_entity.rb b/app/serializers/activity_pub/project_entity.rb new file mode 100644 index 00000000000..02ed0cdc047 --- /dev/null +++ b/app/serializers/activity_pub/project_entity.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ActivityPub + class ProjectEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |project| + project_url(project) + end + + expose :type do |*| + "Application" + end + + expose :name + + expose :description, as: :summary + + expose :url do |project| + project_url(project) + end + end +end diff --git a/app/serializers/activity_pub/release_entity.rb b/app/serializers/activity_pub/release_entity.rb new file mode 100644 index 00000000000..9e3e5397034 --- /dev/null +++ b/app/serializers/activity_pub/release_entity.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleaseEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |release, opts| + "#{opts[:url]}##{release.tag}" + end + + expose :type do |*| + "Create" + end + + expose :to do |*| + 'https://www.w3.org/ns/activitystreams#Public' + end + + expose :author, as: :actor, using: UserEntity + + expose :object do + expose :id do |release| + project_release_url(release.project, release) + end + + expose :type do |*| + "Application" + end + + expose :name + + expose :url do |release| + project_release_url(release.project, release) + end + + expose :description, as: :content + expose :project, as: :context, using: ProjectEntity + end + end +end diff --git a/app/serializers/activity_pub/releases_actor_entity.rb b/app/serializers/activity_pub/releases_actor_entity.rb new file mode 100644 index 00000000000..c52741c73a5 --- /dev/null +++ b/app/serializers/activity_pub/releases_actor_entity.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesActorEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |project| + project_releases_url(project) + end + + expose :type do |*| + "Application" + end + + expose :path, as: :preferredUsername do |project| + "#{project.path}-releases" + end + + expose :name do |project| + "#{_('Releases')} - #{project.name}" + end + + expose :description, as: :content + + expose nil, using: ProjectEntity, as: :context do |project| + project + end + end +end diff --git a/app/serializers/activity_pub/releases_actor_serializer.rb b/app/serializers/activity_pub/releases_actor_serializer.rb new file mode 100644 index 00000000000..5bae83f2dc7 --- /dev/null +++ b/app/serializers/activity_pub/releases_actor_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesActorSerializer < ActivityStreamsSerializer + entity ReleasesActorEntity + end +end diff --git a/app/serializers/activity_pub/releases_outbox_serializer.rb b/app/serializers/activity_pub/releases_outbox_serializer.rb new file mode 100644 index 00000000000..b6d4e633fb0 --- /dev/null +++ b/app/serializers/activity_pub/releases_outbox_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesOutboxSerializer < ActivityStreamsSerializer + include WithPagination + + entity ReleaseEntity + end +end diff --git a/app/serializers/activity_pub/user_entity.rb b/app/serializers/activity_pub/user_entity.rb new file mode 100644 index 00000000000..bd0886db5b2 --- /dev/null +++ b/app/serializers/activity_pub/user_entity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActivityPub + class UserEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |user| + user_url(user) + end + + expose :type do |*| + 'Person' + end + + expose :name + expose :username, as: :preferredUsername + + expose :url do |user| + user_url(user) + end + end +end diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index 3efb8508e5e..8a67aabda9e 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -8,17 +8,21 @@ module Admin expose :details, merge: true do |report| UserEntity.represent(report.user, only: [:name, :username, :avatar_url, :email, :created_at, :last_activity_on]) end + expose :path do |report| user_path(report.user) end + expose :admin_path do |report| 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? @@ -30,6 +34,7 @@ module Admin report.user.credit_card_validation.present? end end + expose :credit_card, if: ->(report) { report.user.credit_card_validation&.holder_name } do expose :name do |report| report.user.credit_card_validation.holder_name @@ -41,55 +46,38 @@ module Admin card_match_admin_user_path(report.user) if Gitlab.ee? end end - expose :other_reports do |report| - AbuseReportEntity.represent(report.other_reports_for_user, only: [:created_at, :category, :report_path]) + + expose :past_closed_reports do |report| + AbuseReportEntity.represent(report.past_closed_reports_for_user, only: [:created_at, :category, :report_path]) + end + + expose :similar_open_reports, if: ->(report) { report.open? } do |report| + ReportedContentEntity.represent(report.similar_open_reports_for_user) end + expose :most_used_ip do |report| AuthenticationEvent.most_used_ip_address_for_user(report.user) end + expose :last_sign_in_ip do |report| report.user.last_sign_in_ip end + expose :snippets_count do |report| report.user.snippets.count end + expose :groups_count do |report| report.user.groups.count end + expose :notes_count do |report| report.user.notes.count end end - expose :reporter, if: ->(report) { report.reporter } do - expose :details, merge: true do |report| - UserEntity.represent(report.reporter, only: [:name, :username, :avatar_url]) - end - expose :path do |report| - user_path(report.reporter) - end - end - - expose :report do - expose :status - expose :message - expose :created_at, as: :reported_at - expose :category - expose :report_type, as: :type - expose :reported_content, as: :content - expose :reported_from_url, as: :url - expose :screenshot_path, as: :screenshot - - # Kept for backwards compatibility. - # TODO: See https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/167?work_item_iid=443 - # In 16.4 remove or re-use this field after frontend has migrated to using moderate_user_path - expose :update_path do |report| - admin_abuse_report_path(report) - end - - expose :moderate_user_path do |report| - moderate_user_admin_abuse_report_path(report) - end + expose :report do |report| + ReportedContentEntity.represent(report) end end end diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb index 22395a2fe91..f8bd851cd1e 100644 --- a/app/serializers/admin/abuse_report_entity.rb +++ b/app/serializers/admin/abuse_report_entity.rb @@ -8,6 +8,7 @@ module Admin expose :created_at expose :updated_at expose :count + expose :labels, using: LabelEntity, if: ->(*) { Feature.enabled?(:abuse_report_labels) } expose :reported_user do |report| UserEntity.represent(report.user, only: [:name]) diff --git a/app/serializers/admin/reported_content_entity.rb b/app/serializers/admin/reported_content_entity.rb new file mode 100644 index 00000000000..bf690647672 --- /dev/null +++ b/app/serializers/admin/reported_content_entity.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Admin + class ReportedContentEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :global_id do |report| + Gitlab::GlobalId.build(report, id: report.id).to_s + end + expose :status + expose :message + expose :created_at, as: :reported_at + expose :category + expose :report_type, as: :type + expose :reported_content, as: :content + expose :reported_from_url, as: :url + expose :screenshot_path, as: :screenshot + + expose :reporter, if: ->(report) { report.reporter } do + expose :details, merge: true do |report| + UserEntity.represent(report.reporter, only: [:name, :username, :avatar_url]) + end + + expose :path do |report| + user_path(report.reporter) + end + end + + expose :update_path do |report| + admin_abuse_report_path(report) + end + + expose :moderate_user_path do |report| + moderate_user_admin_abuse_report_path(report) + end + end +end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index a34f329e9ec..741643f7989 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -57,6 +57,10 @@ class BuildDetailsEntity < Ci::JobEntity using: JobArtifactReportEntity, if: -> (*) { can?(current_user, :read_build, build) } + expose :job_annotations, + as: :annotations, + using: Ci::JobAnnotationEntity + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build| erase_project_job_path(project, build) diff --git a/app/serializers/ci/job_annotation_entity.rb b/app/serializers/ci/job_annotation_entity.rb new file mode 100644 index 00000000000..8d7b2e21460 --- /dev/null +++ b/app/serializers/ci/job_annotation_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Ci + class JobAnnotationEntity < Grape::Entity + expose :name + expose :data + end +end diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb index 15a26739c51..9f90a30bd2d 100644 --- a/app/serializers/codequality_degradation_entity.rb +++ b/app/serializers/codequality_degradation_entity.rb @@ -2,6 +2,9 @@ class CodequalityDegradationEntity < Grape::Entity expose :description + expose :fingerprint, if: ->(_, options) do + Feature.enabled?(:sast_reports_in_inline_diff, options[:request]&.project) + end expose :severity do |degradation| degradation.dig(:severity)&.downcase end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 0fa76f098cd..b459997cc69 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -5,18 +5,23 @@ class IssueSerializer < BaseSerializer # to serialize the `issue` based on `serializer` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(issue, opts = {}) - entity = - case opts[:serializer] - when 'sidebar' - IssueSidebarBasicEntity - when 'sidebar_extras' - IssueSidebarExtrasEntity - when 'board' - IssueBoardEntity - else - IssueEntity - end + entity = choose_entity(opts) super(issue, opts, entity) end + + def choose_entity(opts) + case opts[:serializer] + when 'sidebar' + IssueSidebarBasicEntity + when 'sidebar_extras' + IssueSidebarExtrasEntity + when 'board' + IssueBoardEntity + else + IssueEntity + end + end end + +IssueSerializer.prepend_mod_with('IssueSerializer') diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index b738438a78f..a3e842d348e 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -50,7 +50,7 @@ class PipelineSerializer < BaseSerializer { manual_actions: :metadata, scheduled_actions: :metadata, - failed_builds: %i(project metadata), + failed_builds: %i[project metadata], merge_request: { source_project: [:route, { namespace: :route }], target_project: [:route, { namespace: :route }] diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb index f3c1a927084..93c5be32de3 100644 --- a/app/serializers/profile/event_entity.rb +++ b/app/serializers/profile/event_entity.rb @@ -47,23 +47,26 @@ module Profile end end - expose :target, if: ->(event) { event.target && event.visible_to_user?(current_user) } do - expose(:id) { |event| event.target.id } + expose :target, if: ->(event) { event.visible_to_user?(current_user) } do expose(:target_type, as: :type) - expose(:target_title, as: :title) - expose(:issue_type, if: ->(event) { event.work_item? || event.issue? }) do |event| - event.target.issue_type - end - expose :reference_link_text, if: ->(event) { event.target.respond_to?(:reference_link_text) } do |event| - event.target.reference_link_text - end + with_options if: ->(event) { event.target } do + expose(:id) { |event| event.target.id } + expose(:target_title, as: :title) + expose(:issue_type, if: ->(event) { event.work_item? || event.issue? }) do |event| + event.target.issue_type + end + + expose :reference_link_text, if: ->(event) { event.target.respond_to?(:reference_link_text) } do |event| + event.target.reference_link_text + end - expose :web_url do |event| - if event.wiki_page? - event_wiki_page_target_url(event) - else - Gitlab::UrlBuilder.build(event.target) + expose :web_url do |event| + if event.wiki_page? + event_wiki_page_target_url(event) + else + Gitlab::UrlBuilder.build(event.target) + end end end end diff --git a/app/services/admin/abuse_report_labels/create_service.rb b/app/services/admin/abuse_report_labels/create_service.rb new file mode 100644 index 00000000000..937890a9f51 --- /dev/null +++ b/app/services/admin/abuse_report_labels/create_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Admin + module AbuseReportLabels + class CreateService < Labels::BaseService + def initialize(params = {}) + @params = params + end + + def execute + params[:color] = convert_color_name_to_hex if params[:color].present? + + ::Admin::AbuseReportLabel.create(params) + end + end + end +end diff --git a/app/services/admin/abuse_reports/moderate_user_service.rb b/app/services/admin/abuse_reports/moderate_user_service.rb index da61a4dc8f6..823568d9db8 100644 --- a/app/services/admin/abuse_reports/moderate_user_service.rb +++ b/app/services/admin/abuse_reports/moderate_user_service.rb @@ -61,10 +61,17 @@ module Admin def close_report return error('Report already closed') if abuse_report.closed? + close_similar_open_reports abuse_report.closed! success end + def close_similar_open_reports + # admins see the abuse report and other open reports for the same user in one page + # hence, if the request is to close the report, close other open reports for the same user too + abuse_report.similar_open_reports_for_user.update_all(status: 'closed') + end + def close_report_and_record_event event = action diff --git a/app/services/admin/abuse_reports/update_service.rb b/app/services/admin/abuse_reports/update_service.rb new file mode 100644 index 00000000000..36992e1aa25 --- /dev/null +++ b/app/services/admin/abuse_reports/update_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Admin + module AbuseReports + class UpdateService < BaseService + attr_reader :abuse_report, :params, :current_user + + def initialize(abuse_report, current_user, params) + @abuse_report = abuse_report + @current_user = current_user + @params = params + end + + def execute + return ServiceResponse.error(message: 'Admin is required') unless current_user&.can_admin_all_resources? + + abuse_report.label_ids = label_ids + + ServiceResponse.success + end + + private + + def label_ids + params[:label_ids].filter_map do |id| + GitlabSchema.parse_gid(id, expected_type: ::Admin::AbuseReportLabel).model_id + rescue Gitlab::Graphql::Errors::ArgumentError + end + end + end + end +end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 6d484c4fa22..a46ecc3eee6 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -6,7 +6,7 @@ module ApplicationSettings attr_reader :params, :application_setting - MARKDOWN_CACHE_INVALIDATING_PARAMS = %w(asset_proxy_enabled asset_proxy_url asset_proxy_secret_key asset_proxy_whitelist).freeze + MARKDOWN_CACHE_INVALIDATING_PARAMS = %w[asset_proxy_enabled asset_proxy_url asset_proxy_secret_key asset_proxy_whitelist].freeze def execute result = update_settings diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index eaee5ce70fc..9b010272995 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -39,11 +39,11 @@ module Auth end def self.full_access_token(*names) - access_token(%w(*), names) + access_token(%w[*], names) end def self.import_access_token - access_token(%w(*), ['import'], 'registry') + access_token(%w[*], ['import'], 'registry') end def self.pull_access_token(*names) diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 1660ddb934f..77ed0369624 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -20,6 +20,10 @@ module AutoMerge :failed end + def process(_) + raise NotImplementedError + end + def update(merge_request) assign_allowed_merge_params(merge_request, params.merge(auto_merge_strategy: strategy)) @@ -87,16 +91,21 @@ module AutoMerge merge_request.auto_merge_enabled = false merge_request.merge_user = nil - merge_request.merge_params&.except!( - 'should_remove_source_branch', - 'commit_message', - 'squash_commit_message', - 'auto_merge_strategy' - ) + merge_request.merge_params&.except!(*clearable_auto_merge_parameters) merge_request.save! end + # Overridden in EE child classes + def clearable_auto_merge_parameters + %w[ + should_remove_source_branch + commit_message + squash_commit_message + auto_merge_strategy + ] + end + def track_exception(error, merge_request) Gitlab::ErrorTracking.track_exception(error, merge_request_id: merge_request&.id) end diff --git a/app/services/boards/update_service.rb b/app/services/boards/update_service.rb index 6ba8f68a4cb..c702398e89e 100644 --- a/app/services/boards/update_service.rb +++ b/app/services/boards/update_service.rb @@ -2,7 +2,7 @@ module Boards class UpdateService < Boards::BaseService - PERMITTED_PARAMS = %i(name hide_backlog_list hide_closed_list).freeze + PERMITTED_PARAMS = %i[name hide_backlog_list hide_closed_list].freeze def execute(board) filter_params diff --git a/app/services/bulk_imports/create_pipeline_trackers_service.rb b/app/services/bulk_imports/create_pipeline_trackers_service.rb deleted file mode 100644 index 7fa62e0ce8a..00000000000 --- a/app/services/bulk_imports/create_pipeline_trackers_service.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module BulkImports - class CreatePipelineTrackersService - def initialize(entity) - @entity = entity - end - - def execute! - entity.class.transaction do - entity.pipelines.each do |pipeline| - status = skip_pipeline?(pipeline) ? -2 : 0 - - entity.trackers.create!( - stage: pipeline[:stage], - pipeline_name: pipeline[:pipeline], - status: status - ) - end - end - end - - private - - attr_reader :entity - - def skip_pipeline?(pipeline) - return false unless source_version.valid? - - minimum_version, maximum_version = pipeline.values_at(:minimum_source_version, :maximum_source_version) - - if minimum_version && non_patch_source_version < Gitlab::VersionInfo.parse(minimum_version) - log_skipped_pipeline(pipeline, minimum_version, maximum_version) - return true - end - - if maximum_version && non_patch_source_version > Gitlab::VersionInfo.parse(maximum_version) - log_skipped_pipeline(pipeline, minimum_version, maximum_version) - return true - end - - false - end - - def source_version - @source_version ||= entity.bulk_import.source_version_info - end - - def non_patch_source_version - source_version.without_patch - end - - def log_skipped_pipeline(pipeline, minimum_version, maximum_version) - logger.info( - message: 'Pipeline skipped as source instance version not compatible with pipeline', - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_name: pipeline[:pipeline], - minimum_source_version: minimum_version, - maximum_source_version: maximum_version, - source_version: source_version.to_s, - importer: 'gitlab_migration' - ) - end - - def logger - @logger ||= Gitlab::Import::Logger.build - end - end -end diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index 7fc3511a253..d58620eb089 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -118,11 +118,6 @@ module BulkImports end client.get("/#{entity_type}/#{source_entity_identifier}/export_relations/status") - rescue BulkImports::NetworkError => e - # the source instance will return a 404 if the feature is disabled as the endpoint won't be available - return if e.cause.is_a?(Gitlab::HTTP::BlockedUrlError) - - raise ::BulkImports::Error.setting_not_enabled end def track_access_level(entity_params) diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index 48adb90fb4c..1f2437d783d 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -15,7 +15,7 @@ module BulkImports ServiceError = Class.new(StandardError) - DEFAULT_ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze + DEFAULT_ALLOWED_CONTENT_TYPES = %w[application/gzip application/octet-stream].freeze def initialize( configuration:, @@ -83,6 +83,8 @@ module BulkImports end def raise_error(message) + logger.warn(message: message, response_headers: response_headers, importer: 'gitlab_migration') + raise ServiceError, message end @@ -109,12 +111,16 @@ module BulkImports @filename.presence || remote_filename end + def logger + @logger ||= Gitlab::Import::Logger.build + end + def validate_url ::Gitlab::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] ) end diff --git a/app/services/ci/create_commit_status_service.rb b/app/services/ci/create_commit_status_service.rb new file mode 100644 index 00000000000..e5b446a07e2 --- /dev/null +++ b/app/services/ci/create_commit_status_service.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module Ci + class CreateCommitStatusService < BaseService + include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::Utils::StrongMemoize + include ::Services::ReturnServiceResponses + + delegate :sha, to: :commit + + def execute(optional_commit_status_params:) + in_lock(pipeline_lock_key, **pipeline_lock_params) do + @optional_commit_status_params = optional_commit_status_params + unsafe_execute + end + end + + private + + attr_reader :pipeline, :stage, :commit_status, :optional_commit_status_params + + def unsafe_execute + return not_found('Commit') if commit.blank? + return bad_request('State is required') if params[:state].blank? + return not_found('References for commit') if ref.blank? + + @pipeline = first_matching_pipeline || create_pipeline + return forbidden unless ::Ability.allowed?(current_user, :update_pipeline, pipeline) + + @stage = find_or_create_external_stage + @commit_status = find_or_build_external_commit_status + + return bad_request(commit_status.errors.messages) if commit_status.invalid? + + response = add_or_update_external_job + + return bad_request(response.message) if response.error? + + update_merge_request_head_pipeline + response + end + + def ref + params[:ref] || first_matching_pipeline&.ref || + repository.branch_names_contains(sha).first + end + strong_memoize_attr :ref + + def commit + project.commit(params[:sha]) + end + strong_memoize_attr :commit + + def first_matching_pipeline + pipelines = project.ci_pipelines.newest_first(sha: sha) + pipelines = pipelines.for_ref(params[:ref]) if params[:ref] + pipelines = pipelines.id_in(params[:pipeline_id]) if params[:pipeline_id] + pipelines.first + end + strong_memoize_attr :first_matching_pipeline + + def name + params[:name] || params[:context] || 'default' + end + + def create_pipeline + project.ci_pipelines.build( + source: :external, + sha: sha, + ref: ref, + user: current_user, + protected: project.protected_for?(ref) + ).tap do |new_pipeline| + new_pipeline.ensure_project_iid! + new_pipeline.save! + end + end + + def find_or_create_external_stage + pipeline.stages.safe_find_or_create_by!(name: 'external') do |stage| # rubocop:disable Performance/ActiveRecordSubtransactionMethods + stage.position = ::GenericCommitStatus::EXTERNAL_STAGE_IDX + stage.project = project + end + end + + def find_or_build_external_commit_status + ::GenericCommitStatus.running_or_pending.find_or_initialize_by( # rubocop:disable CodeReuse/ActiveRecord + project: project, + pipeline: pipeline, + name: name, + ref: ref, + user: current_user, + protected: project.protected_for?(ref), + ci_stage: stage, + stage_idx: stage.position, + stage: 'external' + ).tap do |new_commit_status| + new_commit_status.assign_attributes(optional_commit_status_params) + end + end + + def add_or_update_external_job + ::Ci::Pipelines::AddJobService.new(pipeline).execute!(commit_status) do |job| + apply_job_state!(job) + end + end + + def update_merge_request_head_pipeline + return unless pipeline.latest? + + ::MergeRequest + .from_project(project).from_source_branches(ref) + .update_all(head_pipeline_id: pipeline.id) + end + + def apply_job_state!(job) + case params[:state] + when 'pending' + job.enqueue! + when 'running' + job.enqueue + job.run! + when 'success' + job.success! + when 'failed' + job.drop!(:api_failure) + when 'canceled' + job.cancel! + else + raise('invalid state') + end + end + + def pipeline_lock_key + "api:commit_statuses:project:#{project.id}:sha:#{params[:sha]}" + end + + def pipeline_lock_params + { + ttl: 5.seconds, + sleep_sec: 0.1.seconds, + retries: 20 + } + end + + def not_found(message) + error("404 #{message} Not Found", :not_found) + end + + def bad_request(message) + error(message, :bad_request) + end + + def forbidden + error("403 Forbidden", :forbidden) + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index fe0e842f542..2231b1dd6bd 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -154,13 +154,6 @@ module Ci duration >= LOG_MAX_CREATION_THRESHOLD end - - l.log_when do |observations| - pipeline_includes_count = observations['pipeline_includes_count'] - next false unless pipeline_includes_count - - pipeline_includes_count.to_i > Gitlab::Ci::Config::External::Context::TEMP_MAX_INCLUDES - end end end end diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb index 953432a9dd3..05cd20a152b 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -105,11 +105,7 @@ module Ci end def pipelines_created_after - if Feature.enabled?(:lower_interval_for_canceling_redundant_pipelines, project) - 3.days.ago - else - 1.week.ago - end + 3.days.ago end # Finding the pipelines to cancel is an expensive task that is not well diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 68ebb376ccd..470a1d3951b 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -10,7 +10,7 @@ module Ci TEMPORARY_LOCK_TIMEOUT = 3.seconds - Result = Struct.new(:build, :build_json, :valid?) + Result = Struct.new(:build, :build_json, :build_presented, :valid?) ## # The queue depth limit number has been determined by observing 95 @@ -25,8 +25,8 @@ module Ci end def execute(params = {}) - db_all_caught_up = - ::Ci::Runner.sticking.all_caught_up?(:runner, runner.id) + replica_caught_up = + ::Ci::Runner.sticking.find_caught_up_replica(:runner, runner.id, use_primary_on_failure: false) @metrics.increment_queue_operation(:queue_attempt) @@ -40,10 +40,10 @@ module Ci # we might still have some CI builds to be picked. Instead we should say to runner: # "Hi, we don't have any more builds now, but not everything is right anyway, so try again". # Runner will retry, but again, against replica, and again will check if replication lag did catch-up. - if !db_all_caught_up && !result.build + if !replica_caught_up && !result.build metrics.increment_queue_operation(:queue_replication_lag) - ::Ci::RegisterJobService::Result.new(nil, nil, false) # rubocop:disable Cop/AvoidReturnFromBlocks + ::Ci::RegisterJobService::Result.new(nil, nil, nil, false) # rubocop:disable Cop/AvoidReturnFromBlocks else result end @@ -86,7 +86,7 @@ module Ci next unless result if result.valid? - @metrics.register_success(result.build) + @metrics.register_success(result.build_presented) @metrics.observe_queue_depth(:found, depth) return result # rubocop:disable Cop/AvoidReturnFromBlocks @@ -102,7 +102,7 @@ module Ci @metrics.observe_queue_depth(:not_found, depth) if valid @metrics.register_failure - Result.new(nil, nil, valid) + Result.new(nil, nil, nil, valid) end # rubocop: disable CodeReuse/ActiveRecord @@ -159,7 +159,7 @@ module Ci # this operation. # if ::Ci::UpdateBuildQueueService.new.remove!(build) - return Result.new(nil, nil, false) + return Result.new(nil, nil, nil, false) end return @@ -190,11 +190,11 @@ module Ci # to make sure that this is properly handled by runner. @metrics.increment_queue_operation(:build_conflict_lock) - Result.new(nil, nil, false) + Result.new(nil, nil, nil, false) rescue StateMachines::InvalidTransition @metrics.increment_queue_operation(:build_conflict_transition) - Result.new(nil, nil, false) + Result.new(nil, nil, nil, false) rescue StandardError => ex @metrics.increment_queue_operation(:build_conflict_exception) @@ -221,7 +221,7 @@ module Ci log_build_dependencies_size(presented_build) build_json = Gitlab::Json.dump(::API::Entities::Ci::JobRequest::Response.new(presented_build)) - Result.new(build, build_json, true) + Result.new(build, build_json, presented_build, true) end def log_build_dependencies_size(presented_build) diff --git a/app/services/ci/update_instance_variables_service.rb b/app/services/ci/update_instance_variables_service.rb index ee513647d08..2f941118a1c 100644 --- a/app/services/ci/update_instance_variables_service.rb +++ b/app/services/ci/update_instance_variables_service.rb @@ -5,7 +5,7 @@ module Ci class UpdateInstanceVariablesService - UNASSIGNABLE_KEYS = %w(id _destroy).freeze + UNASSIGNABLE_KEYS = %w[id _destroy].freeze def initialize(params) @params = params[:variables_attributes] diff --git a/app/services/clusters/agent_tokens/track_usage_service.rb b/app/services/clusters/agent_tokens/track_usage_service.rb index fdc79ac0f8b..18fe236c44d 100644 --- a/app/services/clusters/agent_tokens/track_usage_service.rb +++ b/app/services/clusters/agent_tokens/track_usage_service.rb @@ -4,7 +4,7 @@ module Clusters module AgentTokens class TrackUsageService # The `UPDATE_USED_COLUMN_EVERY` defines how often the token DB entry can be updated - UPDATE_USED_COLUMN_EVERY = (40.minutes..55.minutes).freeze + UPDATE_USED_COLUMN_EVERY = (40.minutes..55.minutes) delegate :agent, to: :token diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb index e87640f4c76..94781422686 100644 --- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb @@ -137,9 +137,9 @@ module Clusters name: Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_NAME, namespace: service_account_namespace, rules: [{ - apiGroups: %w(serving.knative.dev), - resources: %w(configurations configurationgenerations routes revisions revisionuids autoscalers services), - verbs: %w(get list create update delete patch watch) + apiGroups: %w[serving.knative.dev], + resources: %w[configurations configurationgenerations routes revisions revisionuids autoscalers services], + verbs: %w[get list create update delete patch watch] }] ).generate end @@ -159,9 +159,9 @@ module Clusters name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: service_account_namespace, rules: [{ - apiGroups: %w(database.crossplane.io), - resources: %w(postgresqlinstances), - verbs: %w(get list create watch) + apiGroups: %w[database.crossplane.io], + resources: %w[postgresqlinstances], + verbs: %w[get list create watch] }] ).generate end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index a498d39d34e..89370bd8abb 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -39,7 +39,12 @@ module Commits Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => ex Gitlab::ErrorTracking.log_exception(ex) - error(ex.message) + + if Feature.enabled?(:errors_utf_8_encoding) + error(Gitlab::EncodingHelper.encode_utf8_no_detect(ex.message)) + else + error(ex.message) + end end private diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index 569b91de73e..b02cfea151d 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -17,9 +17,6 @@ class CompareService return unless raw_compare && raw_compare.base && raw_compare.head - Compare.new(raw_compare, - start_project, - base_sha: base_sha, - straight: straight) + Compare.new(raw_compare, start_project, base_sha: base_sha, straight: straight) end end diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb index 9fe82507edd..5d35658a2b3 100644 --- a/app/services/concerns/alert_management/alert_processing.rb +++ b/app/services/concerns/alert_management/alert_processing.rb @@ -36,7 +36,7 @@ module AlertManagement SystemNoteService.log_resolving_alert(alert, alert_source) if alert.resolve(incoming_payload.ends_at) - SystemNoteService.change_alert_status(alert, User.alert_bot) + SystemNoteService.change_alert_status(alert, Users::Internal.alert_bot) close_issue(alert.issue_id) if auto_close_incident? end diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb index fa366c1ccd0..79be952ac14 100644 --- a/app/services/concerns/rate_limited_service.rb +++ b/app/services/concerns/rate_limited_service.rb @@ -61,9 +61,11 @@ module RateLimitedService cattr_accessor :rate_limiter_scoped_and_keyed def self.rate_limit(key:, opts:, rate_limiter: ::Gitlab::ApplicationRateLimiter) - self.rate_limiter_scoped_and_keyed = RateLimiterScopedAndKeyed.new(key: key, - opts: opts, - rate_limiter: rate_limiter) + self.rate_limiter_scoped_and_keyed = RateLimiterScopedAndKeyed.new( + key: key, + opts: opts, + rate_limiter: rate_limiter + ) end end diff --git a/app/services/concerns/service_desk/custom_emails/logger.rb b/app/services/concerns/service_desk/custom_emails/logger.rb new file mode 100644 index 00000000000..1817933c3d0 --- /dev/null +++ b/app/services/concerns/service_desk/custom_emails/logger.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmails + module Logger + private + + def log_warning(error_message: nil) + with_context do + Gitlab::AppLogger.warn(build_log_message(error_message: error_message)) + end + end + + def log_info(error_message: nil, project: nil) + with_context(project: project) do + Gitlab::AppLogger.info(build_log_message(error_message: error_message)) + end + end + + def with_context(project: nil, &block) + Gitlab::ApplicationContext.with_context( + related_class: self.class.to_s, + user: current_user, + project: project || self.project, + &block + ) + end + + def log_category + 'custom_email' + end + + def build_log_message(error_message: nil) + { + category: log_category, + error_message: error_message + }.compact + end + end + end +end diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index dca38abf7af..f14c79ecd7e 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -69,7 +69,17 @@ module UpdateRepositoryStorageMethods raise Error, s_('UpdateRepositoryStorage|Timeout waiting for %{type} repository pushes') % { type: type.name } end - repository = type.repository_for(container) + # `Projects::UpdateRepositoryStorageService`` expects the repository it is + # moving to have a `Project` as a container. + # This hack allows design repos to also be moved as part of a project move + # as before. + # The alternative to this hack is to setup a service like + # `Snippets::UpdateRepositoryStorageService' and a corresponding worker like + # `Snippets::UpdateRepositoryStorageWorker` for snippets. + # + # Gitlab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/423429 + + repository = type.repository_for(type.design? ? container.design_management_repository : container) full_path = repository.full_path raw_repository = repository.raw checksum = repository.checksum diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb index 8074a193bbf..d391c13696f 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -58,8 +58,8 @@ module DesignManagement private attr_reader :designs, :event_enum_map, :git_user, :sha_attribute, :shas, - :temporary_branch, :target_design_collection, :target_issue, - :target_repository, :target_project, :versions + :temporary_branch, :target_design_collection, :target_issue, + :target_repository, :target_project, :versions alias_method :merge_branch, :target_branch diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb index 921c904d8de..a6a0f5e0252 100644 --- a/app/services/design_management/delete_designs_service.rb +++ b/app/services/design_management/delete_designs_service.rb @@ -16,8 +16,10 @@ module DesignManagement version = delete_designs! EventCreateService.new.destroy_designs(designs, current_user) - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user, - project: project) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action( + author: current_user, + project: project + ) TodosDestroyer::DestroyedDesignsWorker.perform_async(designs.map(&:id)) success(version: version) diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb index 267ed6bf29f..62db7824592 100644 --- a/app/services/design_management/runs_design_actions.rb +++ b/app/services/design_management/runs_design_actions.rb @@ -15,10 +15,12 @@ module DesignManagement def run_actions(actions, skip_system_notes: false) raise NoActions if actions.empty? - sha = repository.commit_files(current_user, - branch_name: target_branch, - message: commit_message, - actions: actions.map(&:gitaly_action)) + sha = repository.commit_files( + current_user, + branch_name: target_branch, + message: commit_message, + actions: actions.map(&:gitaly_action) + ) ::DesignManagement::Version .create_for_designs(actions, sha, current_user) diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb index ea5675c6ddd..4c4e34862e8 100644 --- a/app/services/design_management/save_designs_service.rb +++ b/app/services/design_management/save_designs_service.rb @@ -131,11 +131,11 @@ module DesignManagement def track_usage_metrics(action) if action == :update - ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_modified_action(author: current_user, - project: project) + ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter + .track_issue_designs_modified_action(author: current_user, project: project) else - ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_added_action(author: current_user, - project: project) + ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter + .track_issue_designs_added_action(author: current_user, project: project) end ::Gitlab::UsageDataCounters::DesignsCounter.count(action) diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb index 8458eb1f3b8..e95d4eec3c8 100644 --- a/app/services/error_tracking/base_service.rb +++ b/app/services/error_tracking/base_service.rb @@ -17,8 +17,7 @@ module ErrorTracking private def perform - raise NotImplementedError, - "#{self.class} does not implement #{__method__}" + raise NotImplementedError, "#{self.class} does not implement #{__method__}" end def compose_response(response, &block) @@ -33,8 +32,7 @@ module ErrorTracking end def parse_response(response) - raise NotImplementedError, - "#{self.class} does not implement #{__method__}" + raise NotImplementedError, "#{self.class} does not implement #{__method__}" end def unauthorized diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 1893cfcfcff..b755f512772 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -190,10 +190,14 @@ class EventCreateService private def create_record_event(record, current_user, status, fingerprint = nil) - create_event(record.resource_parent, current_user, status, - fingerprint: fingerprint, - target_id: record.id, - target_type: record.class.name) + create_event( + record.resource_parent, + current_user, + status, + fingerprint: fingerprint, + target_id: record.id, + target_type: record.class.name + ) end # If creating several events, this method will insert them all in a single diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb index 834409bf3c4..961e8d54efa 100644 --- a/app/services/feature_flags/base_service.rb +++ b/app/services/feature_flags/base_service.rb @@ -4,7 +4,7 @@ module FeatureFlags class BaseService < ::BaseService include Gitlab::Utils::StrongMemoize - AUDITABLE_ATTRIBUTES = %w(name description active).freeze + AUDITABLE_ATTRIBUTES = %w[name description active].freeze def success(**args) sync_to_jira(args[:feature_flag]) diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index dd09ecafb4f..a7be73aa04c 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -2,7 +2,7 @@ module Files class MultiService < Files::BaseService - UPDATE_FILE_ACTIONS = %w(update move delete chmod).freeze + UPDATE_FILE_ACTIONS = %w[update move delete chmod].freeze def create_commit! transformer = Lfs::FileTransformer.new(project, repository, @branch_name) diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index 9fa966bb8a8..c11917b92ec 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -3,15 +3,19 @@ module Files class UpdateService < Files::BaseService def create_commit! - repository.update_file(current_user, @file_path, @file_content, - message: @commit_message, - branch_name: @branch_name, - previous_path: @previous_path, - author_email: @author_email, - author_name: @author_name, - start_project: @start_project, - start_branch_name: @start_branch, - execute_filemode: @execute_filemode) + repository.update_file( + current_user, + @file_path, + @file_content, + message: @commit_message, + branch_name: @branch_name, + previous_path: @previous_path, + author_email: @author_email, + author_name: @author_name, + start_project: @start_project, + start_branch_name: @start_branch, + execute_filemode: @execute_filemode + ) end private diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb index 8d040c6c908..9a1263f0796 100644 --- a/app/services/google_cloud/create_cloudsql_instance_service.rb +++ b/app/services/google_cloud/create_cloudsql_instance_service.rb @@ -17,26 +17,30 @@ module GoogleCloud private def create_cloud_instance - google_api_client.create_cloudsql_instance(gcp_project_id, - instance_name, - root_password, - database_version, - region, - tier) + 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? - }) + 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? diff --git a/app/services/google_cloud/fetch_google_ip_list_service.rb b/app/services/google_cloud/fetch_google_ip_list_service.rb index f7739971603..54af841d002 100644 --- a/app/services/google_cloud/fetch_google_ip_list_service.rb +++ b/app/services/google_cloud/fetch_google_ip_list_service.rb @@ -18,9 +18,11 @@ module GoogleCloud subnets = fetch_and_update_cache! - Gitlab::AppJsonLogger.info(class: self.class.name, - message: 'Successfully retrieved Google IP list', - subnet_count: subnets.count) + Gitlab::AppJsonLogger.info( + class: self.class.name, + message: 'Successfully retrieved Google IP list', + subnet_count: subnets.count + ) success({ subnets: subnets }) rescue IpListNotRetrievedError => err diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb index 95de1fa21b7..30c358687aa 100644 --- a/app/services/google_cloud/generate_pipeline_service.rb +++ b/app/services/google_cloud/generate_pipeline_service.rb @@ -71,8 +71,12 @@ module GoogleCloud end def pipeline_content(include_path) - gitlab_ci_yml = ::Gitlab::Ci::Config::Yaml.load!(default_branch_gitlab_ci_yml || '{}') - append_remote_include(gitlab_ci_yml, "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{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) diff --git a/app/services/gpg_keys/destroy_service.rb b/app/services/gpg_keys/destroy_service.rb index 2e82509897e..c8ee90db9f6 100644 --- a/app/services/gpg_keys/destroy_service.rb +++ b/app/services/gpg_keys/destroy_service.rb @@ -2,9 +2,24 @@ module GpgKeys class DestroyService < Keys::BaseService + BATCH_SIZE = 1000 + def execute(key) + nullify_signatures(key) key.destroy end + + private + + # When a GPG key is deleted, the related signatures have their gpg_key_id column nullified + # However, when the number of signatures is large, then a timeout may happen + # The signatures are processed in batches before GPG key delete is attempted in order to + # avoid timeouts + def nullify_signatures(key) + key.gpg_signatures.each_batch(of: BATCH_SIZE) do |batch| + batch.update_all(gpg_key_id: nil) + end + end end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 0f74b2d9349..21d3c6499a0 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -35,10 +35,14 @@ module Groups @group.build_chat_team(name: response['name'], team_id: response['id']) end - Group.transaction do - if @group.save - @group.add_owner(current_user) - Integration.create_from_active_default_integrations(@group, :group_id) + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424281' + ) do + Group.transaction do + if @group.save + @group.add_owner(current_user) + Integration.create_from_active_default_integrations(@group, :group_id) + end end end diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index 45e8972213e..a896ca5cabc 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -14,8 +14,6 @@ module Groups # TODO - add a policy check here https://gitlab.com/gitlab-org/gitlab/-/issues/353082 raise DestroyError, "You can't delete this group because you're blocked." if current_user.blocked? - group.prepare_for_destroy - group.projects.includes(:project_feature).each do |project| # Execute the destruction of the models immediately to ensure atomic cleanup. success = ::Projects::DestroyService.new(project, current_user).execute @@ -83,7 +81,10 @@ module Groups # rubocop:disable CodeReuse/ActiveRecord def destroy_group_bots - bot_ids = group.members_and_requesters.joins(:user).merge(User.project_bot).pluck(:user_id) + bot_ids = group.members_and_requesters.joins(:user) + .merge(User.project_bot) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422405') + .pluck(:user_id) current_user_id = current_user.id group.run_after_commit do diff --git a/app/services/groups/ssh_certificates/create_service.rb b/app/services/groups/ssh_certificates/create_service.rb new file mode 100644 index 00000000000..6890901c306 --- /dev/null +++ b/app/services/groups/ssh_certificates/create_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Groups + module SshCertificates + class CreateService + def initialize(group, params) + @group = group + @params = params + end + + def execute + key = params[:key] + fingerprint = generate_fingerprint(key) + + return ServiceResponse.error(message: 'Group', reason: :forbidden) if group.has_parent? + + # return a key error instead of fingerprint error, as the user has no knowledge of fingerprint. + unless fingerprint + return ServiceResponse.error(message: 'Validation failed: Invalid key', + reason: :unprocessable_entity) + end + + result = group.ssh_certificates.create!( + key: key, + title: params[:title], + fingerprint: fingerprint + ) + + # title and key attributes are returned as [FILTERED] + # by config/application.rb#L181-233 + # make attributes unfiltered by running find + ssh_certificate = group.ssh_certificates.find(result.id) + ServiceResponse.success(payload: ssh_certificate) + + rescue ActiveRecord::RecordInvalid, ArgumentError => e + ServiceResponse.error( + message: e.message, + reason: :unprocessable_entity + ) + end + + private + + attr_reader :group, :params + + def generate_fingerprint(key) + Gitlab::SSHPublicKey.new(key).fingerprint_sha256&.delete_prefix('SHA256:') + end + end + end +end diff --git a/app/services/groups/ssh_certificates/destroy_service.rb b/app/services/groups/ssh_certificates/destroy_service.rb new file mode 100644 index 00000000000..7a450d5bee6 --- /dev/null +++ b/app/services/groups/ssh_certificates/destroy_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Groups + module SshCertificates + class DestroyService + def initialize(group, params) + @group = group + @params = params + end + + def execute + ssh_certificate = group.ssh_certificates.find(params[:ssh_certificates_id]) + + ssh_certificate.destroy! + ServiceResponse.success + + rescue ActiveRecord::RecordNotFound + ServiceResponse.error( + message: 'SSH Certificate not found', + reason: :not_found + ) + + rescue ActiveRecord::RecordNotDestroyed + ServiceResponse.error( + message: 'SSH Certificate could not be deleted', + reason: :method_not_allowed + ) + end + + private + + attr_reader :group, :params + end + end +end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 64256e43ce3..6b979308d26 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -60,13 +60,17 @@ module Groups old_root_ancestor_id = @group.root_ancestor.id was_root_group = @group.root? - Group.transaction do - update_group_attributes - ensure_ownership - update_integrations - remove_issue_contacts(old_root_ancestor_id, was_root_group) - update_crm_objects(was_root_group) - remove_namespace_commit_emails(was_root_group) + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424280' + ) do + Group.transaction do + update_group_attributes + ensure_ownership + update_integrations + remove_issue_contacts(old_root_ancestor_id, was_root_group) + update_crm_objects(was_root_group) + remove_namespace_commit_emails(was_root_group) + end end post_update_hooks(@updated_project_ids, old_root_ancestor_id) diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 7d0142fc067..d91e09d212a 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -47,10 +47,6 @@ module Groups private def valid_path_change? - unless Feature.enabled?(:npm_package_registry_fix_group_path_validation) - return valid_path_change_with_npm_packages? - end - return true unless group.packages_feature_enabled? return true if params[:path].blank? return true if group.has_parent? @@ -68,21 +64,6 @@ module Groups false end - # TODO: delete this function along with npm_package_registry_fix_group_path_validation - def valid_path_change_with_npm_packages? - return true unless group.packages_feature_enabled? - return true if params[:path].blank? - return true if !group.has_parent? && group.path == params[:path] - - npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm).execute - if npm_packages.exists? - group.errors.add(:path, s_('GroupSettings|cannot change when group contains projects with NPM packages')) - return - end - - true - end - def before_assignment_hook(group, params) @full_path_before = group.full_path @path_before = group.path diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb index 5d496dc7cc3..3d961780889 100644 --- a/app/services/import/bitbucket_server_service.rb +++ b/app/services/import/bitbucket_server_service.rb @@ -83,7 +83,7 @@ module Import url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w(http https) + schemes: %w[http https] ) end diff --git a/app/services/import/fogbugz_service.rb b/app/services/import/fogbugz_service.rb index 9a8def43312..2f63e4e6fb7 100644 --- a/app/services/import/fogbugz_service.rb +++ b/app/services/import/fogbugz_service.rb @@ -88,7 +88,7 @@ module Import url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w(http https) + schemes: %w[http https] ) end diff --git a/app/services/import/github/cancel_project_import_service.rb b/app/services/import/github/cancel_project_import_service.rb index 62cd0c95eaf..740b9e5c2e7 100644 --- a/app/services/import/github/cancel_project_import_service.rb +++ b/app/services/import/github/cancel_project_import_service.rb @@ -7,13 +7,13 @@ module Import return error('Not Found', :not_found) unless authorized_to_read? return error('Unauthorized access', :forbidden) unless authorized_to_cancel? - if project.import_in_progress? + if project.import_state.completed? + error(cannot_cancel_error_message, :bad_request) + else project.import_state.cancel metrics.track_canceled_import success(project: project) - else - error(cannot_cancel_error_message, :bad_request) end end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index df255a7ae24..73e0c229a9c 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -91,7 +91,7 @@ module Import url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w(http https) + schemes: %w[http https] ) end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb index e30818cc5d2..ed99d20d67f 100644 --- a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb @@ -11,7 +11,7 @@ module Import end validates :file_url, addressable_url: { - schemes: %w(https), + schemes: %w[https], allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, dns_rebind_protection: true diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb index 7599343d4e1..57ed717b966 100644 --- a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb @@ -13,7 +13,7 @@ module Import validates_presence_of :region, :bucket_name, :file_key, :access_key_id, :secret_access_key validates :file_url, addressable_url: { - schemes: %w(https), + schemes: %w[https], allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, dns_rebind_protection: true diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb index 2886bd5c9b7..a994072c4aa 100644 --- a/app/services/import/validate_remote_git_endpoint_service.rb +++ b/app/services/import/validate_remote_git_endpoint_service.rb @@ -8,7 +8,7 @@ module Import GIT_SERVICE_NAME = "git-upload-pack" GIT_EXPECTED_FIRST_PACKET_LINE = "# service=#{GIT_SERVICE_NAME}" - GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/.freeze + GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/ # https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L56-L59 GIT_PROTOCOL_PKT_LEN = 4 GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb index 567ac065cf7..5ee2f70ec4c 100644 --- a/app/services/import_export_clean_up_service.rb +++ b/app/services/import_export_clean_up_service.rb @@ -60,7 +60,7 @@ class ImportExportCleanUpService end def directories_cmd - %W(find #{path} -mindepth #{DIR_DEPTH} -maxdepth #{DIR_DEPTH} -type d -not -path #{path} -mmin +#{mmin}) + %W[find #{path} -mindepth #{DIR_DEPTH} -maxdepth #{DIR_DEPTH} -type d -not -path #{path} -mmin +#{mmin}] end def logger diff --git a/app/services/incident_management/pager_duty/create_incident_issue_service.rb b/app/services/incident_management/pager_duty/create_incident_issue_service.rb index 0c9ca2c0add..58c3a062910 100644 --- a/app/services/incident_management/pager_duty/create_incident_issue_service.rb +++ b/app/services/incident_management/pager_duty/create_incident_issue_service.rb @@ -6,7 +6,7 @@ module IncidentManagement include IncidentManagement::Settings def initialize(project, incident_payload) - super(project, User.alert_bot, incident_payload) + super(project, Users::Internal.alert_bot, incident_payload) end def execute diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb index 3ce2674616e..6f779bfdb18 100644 --- a/app/services/incident_management/pager_duty/process_webhook_service.rb +++ b/app/services/incident_management/pager_duty/process_webhook_service.rb @@ -10,7 +10,7 @@ module IncidentManagement PAGER_DUTY_PAYLOAD_SIZE_LIMIT = 55.kilobytes # https://developer.pagerduty.com/docs/db0fa8c8984fc-overview#event-types - PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.triggered).freeze + PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w[incident.triggered].freeze def initialize(project, payload) super(project: project) diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 166452968f4..e996aecdf97 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -43,7 +43,7 @@ module Issuable end def permitted_attrs(type) - attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event) + attrs = %i[state_event milestone_id add_label_ids remove_label_ids subscription_event] if type == 'issue' attrs.push(:assignee_ids, :confidential) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 3b007d4dba7..27cfaef2db2 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -169,7 +169,7 @@ class IssuableBaseService < ::BaseContainerService params[:incident_management_issuable_escalation_status_attributes] = result[:escalation_status] end - def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: []) + def process_label_ids(attributes, issuable:, existing_label_ids: nil, extra_label_ids: []) # rubocop:disable Lint/UnusedMethodArgument label_ids = attributes.delete(:label_ids) add_label_ids = attributes.delete(:add_label_ids) remove_label_ids = attributes.delete(:remove_label_ids) @@ -180,15 +180,29 @@ class IssuableBaseService < ::BaseContainerService new_label_ids |= add_label_ids if add_label_ids new_label_ids -= remove_label_ids if remove_label_ids - new_label_ids.uniq + filter_locked_labels(issuable, new_label_ids.uniq, existing_label_ids) + end + + # Filter out any locked labels that are attempting to be removed + def filter_locked_labels(issuable, ids, existing_label_ids) + return ids unless issuable.supports_lock_on_merge? + return ids unless existing_label_ids.present? + + removed_label_ids = existing_label_ids - ids + removed_locked_label_ids = labels_service.filter_locked_label_ids(removed_label_ids) + + ids + removed_locked_label_ids end def process_assignee_ids(attributes, existing_assignee_ids: nil, extra_assignee_ids: []) - process = Issuable::ProcessAssignees.new(assignee_ids: attributes.delete(:assignee_ids), - add_assignee_ids: attributes.delete(:add_assignee_ids), - remove_assignee_ids: attributes.delete(:remove_assignee_ids), - existing_assignee_ids: existing_assignee_ids, - extra_assignee_ids: extra_assignee_ids) + process = Issuable::ProcessAssignees.new( + assignee_ids: attributes.delete(:assignee_ids), + add_assignee_ids: attributes.delete(:add_assignee_ids), + remove_assignee_ids: attributes.delete(:remove_assignee_ids), + existing_assignee_ids: existing_assignee_ids, + extra_assignee_ids: extra_assignee_ids + ) + process.execute end @@ -221,7 +235,7 @@ class IssuableBaseService < ::BaseContainerService params.delete(:state_event) params[:author] ||= current_user - params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a) + params[:label_ids] = process_label_ids(params, issuable: issuable, extra_label_ids: issuable.label_ids.to_a) if issuable.respond_to?(:assignee_ids) params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: issuable.assignee_ids.to_a) @@ -373,9 +387,11 @@ class IssuableBaseService < ::BaseContainerService filter_params(issuable) if issuable.changed? || params.present? - issuable.assign_attributes(params.merge(updated_by: current_user, - last_edited_at: Time.current, - last_edited_by: current_user)) + issuable.assign_attributes(params.merge( + updated_by: current_user, + last_edited_at: Time.current, + last_edited_by: current_user + )) before_update(issuable, skip_spam_check: true) @@ -404,10 +420,13 @@ class IssuableBaseService < ::BaseContainerService update_task_params = params.delete(:update_task) return unless update_task_params - tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html, - line_source: update_task_params[:line_source], - line_number: update_task_params[:line_number].to_i, - toggle_as_checked: update_task_params[:checked]) + tasklist_toggler = TaskListToggleService.new( + issuable.description, + issuable.description_html, + line_source: update_task_params[:line_source], + line_number: update_task_params[:line_number].to_i, + toggle_as_checked: update_task_params[:checked] + ) unless tasklist_toggler.execute # if we make it here, the data is much newer than we thought it was - fail fast @@ -469,7 +488,7 @@ class IssuableBaseService < ::BaseContainerService # rubocop: enable CodeReuse/ActiveRecord def assign_requested_labels(issuable) - label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) + label_ids = process_label_ids(params, issuable: issuable, existing_label_ids: issuable.label_ids) return unless ids_changing?(issuable.label_ids, label_ids) params[:label_ids] = label_ids diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index 533e92f6225..761ba3f74aa 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -76,13 +76,13 @@ module IssuableLinks target_issuables.map do |referenced_object| link = relate_issuables(referenced_object) - if link.valid? - after_create_for(link) - else + if link.errors.any? @errors << _("%{ref} cannot be added: %{error}") % { ref: referenced_object.to_reference, error: link.errors.messages.values.flatten.to_sentence } + else + after_create_for(link) end link diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index b9b7cd08b68..a5ae5854e33 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -21,7 +21,7 @@ module Issues Issues::CloseService end - NO_REBALANCING_NEEDED = ((RelativePositioning::MIN_POSITION * 0.9999)..(RelativePositioning::MAX_POSITION * 0.9999)).freeze + NO_REBALANCING_NEEDED = ((RelativePositioning::MIN_POSITION * 0.9999)..(RelativePositioning::MAX_POSITION * 0.9999)) def rebalance_if_needed(issue) return unless issue @@ -111,9 +111,6 @@ module Issues issue.namespace.execute_integrations(issue_data, hooks_scope) execute_incident_hooks(issue, issue_data) if issue.work_item_type&.incident? - - return unless Feature.enabled?(:group_mentions, issue.project) - execute_group_mention_hooks(issue, issue_data) if action == 'open' end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index f848a8db12a..ef43e707a21 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -6,10 +6,12 @@ module Issues def execute(issue, commit: nil, notifications: true, system_note: true, skip_authorization: false) return issue unless can_close?(issue, skip_authorization: skip_authorization) - close_issue(issue, - closed_via: commit, - notifications: notifications, - system_note: system_note) + close_issue( + issue, + closed_via: commit, + notifications: notifications, + system_note: system_note + ) end # Closes the supplied issue without checking if the user is authorized to @@ -86,7 +88,7 @@ module Issues issue = alert.issue if alert.resolve - SystemNoteService.change_alert_status(alert, User.alert_bot, " because #{current_user.to_reference} closed incident #{issue.to_reference(project)}") + SystemNoteService.change_alert_status(alert, Users::Internal.alert_bot, " because #{current_user.to_reference} closed incident #{issue.to_reference(project)}") else Gitlab::AppLogger.warn( message: 'Cannot resolve an associated Alert Management alert', diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index e1ddfe47439..c828c156d50 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -7,7 +7,7 @@ module Issues include ::Services::ReturnServiceResponses rate_limit key: :issues_create, - opts: { scope: [:project, :current_user, :external_author] } + opts: { scope: [:project, :current_user, :external_author] } def initialize(container:, current_user: nil, params: {}, build_service: nil, perform_spam_check: true) @extra_params = params.delete(:extra_params) || {} @@ -90,9 +90,12 @@ module Issues def resolve_discussions_with_issue(issue) return if discussions_to_resolve.empty? - Discussions::ResolveService.new(project, current_user, - one_or_more_discussions: discussions_to_resolve, - follow_up_issue: issue).execute + Discussions::ResolveService.new( + project, + current_user, + one_or_more_discussions: discussions_to_resolve, + follow_up_issue: issue + ).execute end private diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index e26e3d0835b..c3ddf7b6709 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -141,15 +141,23 @@ module Issues end def add_note_from - SystemNoteService.noteable_moved(new_entity, target_project, - original_entity, current_user, - direction: :from) + SystemNoteService.noteable_moved( + new_entity, + target_project, + original_entity, + current_user, + direction: :from + ) end def add_note_to - SystemNoteService.noteable_moved(original_entity, old_project, - new_entity, current_user, - direction: :to) + SystemNoteService.noteable_moved( + original_entity, + old_project, + new_entity, + current_user, + direction: :to + ) end end end diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb index a8d0ae01176..e165cb36634 100644 --- a/app/services/issues/relative_position_rebalancing_service.rb +++ b/app/services/issues/relative_position_rebalancing_service.rb @@ -141,7 +141,7 @@ module Issues def run_update_query(values, query_name) Issue.connection.exec_query(<<~SQL, query_name) - WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} ( + WITH cte(cte_id, new_pos) AS MATERIALIZED ( SELECT * FROM (VALUES #{values}) as t (id, pos) ) diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb index 21f92eeaf09..7809f263eb6 100644 --- a/app/services/labels/available_labels_service.rb +++ b/app/services/labels/available_labels_service.rb @@ -40,19 +40,8 @@ module Labels ids.map(&:to_i) & existing_ids end - def filter_locked_labels_ids_in_param(key) - ids = Array.wrap(params[key]) - return [] if ids.empty? - - params = finder_params - params[:locked_labels] = true - existing_labels = LabelsFinder.new(current_user, params).execute - - # rubocop:disable CodeReuse/ActiveRecord - existing_ids = existing_labels.id_in(ids).pluck(:id) - # rubocop:enable CodeReuse/ActiveRecord - - ids.map(&:to_i) & existing_ids + def filter_locked_label_ids(ids) + available_labels.with_lock_on_merge.id_in(ids).pluck(:id) # rubocop:disable CodeReuse/ActiveRecord end def available_labels diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb index 675439b2f64..c69b9bd8de7 100644 --- a/app/services/labels/create_service.rb +++ b/app/services/labels/create_service.rb @@ -13,9 +13,7 @@ module Labels project_or_group = target_params[:project] || target_params[:group] if project_or_group.present? - if Feature.disabled?(:enforce_locked_labels_on_merge, project_or_group, type: :ops) - params.delete(:lock_on_merge) - end + params.delete(:lock_on_merge) unless project_or_group.supports_lock_on_merge? project_or_group.labels.create(params) elsif target_params[:template] diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb index 4ac54959e84..0ffb0fabf21 100644 --- a/app/services/labels/update_service.rb +++ b/app/services/labels/update_service.rb @@ -21,8 +21,12 @@ module Labels def allow_lock_on_merge?(label) return if label.template? return unless label.respond_to?(:parent_container) + return unless label.parent_container.supports_lock_on_merge? - Feature.enabled?(:enforce_locked_labels_on_merge, label.parent_container, type: :ops) + # If we've made it here, then we're allowed to turn it on. However, we do _not_ + # want to allow it to be turned off. So if it's already set, then don't allow the possibility + # that it could be turned off. + !label.lock_on_merge end end end diff --git a/app/services/loose_foreign_keys/process_deleted_records_service.rb b/app/services/loose_foreign_keys/process_deleted_records_service.rb index 8700276c982..e0c9c19f5b9 100644 --- a/app/services/loose_foreign_keys/process_deleted_records_service.rb +++ b/app/services/loose_foreign_keys/process_deleted_records_service.rb @@ -4,13 +4,13 @@ module LooseForeignKeys class ProcessDeletedRecordsService BATCH_SIZE = 1000 - def initialize(connection:) + def initialize(connection:, modification_tracker: LooseForeignKeys::ModificationTracker.new) @connection = connection + @modification_tracker = modification_tracker end def execute raised_error = false - modification_tracker = ModificationTracker.new tracked_tables.cycle do |table| records = load_batch_for_table(table) @@ -54,7 +54,7 @@ module LooseForeignKeys private - attr_reader :connection + attr_reader :connection, :modification_tracker def db_config_name ::Gitlab::Database.db_config_name(connection) diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index a6fff3003ac..cc18aae7446 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -39,31 +39,34 @@ module Members sources = Array.wrap(sources) if sources.is_a?(ApplicationRecord) # For single source - Member.transaction do - sources.flat_map do |source| - # If this user is attempting to manage Owner members and doesn't have permission, do not allow - if managing_owners?(args[:current_user], access_level) && cannot_manage_owners?(source, args[:current_user]) - next [] + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[users user_preferences user_details emails identities], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424276' + ) do + Member.transaction do + sources.flat_map do |source| + # If this user is attempting to manage Owner members and doesn't have permission, do not allow + current_user = args[:current_user] + next [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user) + + emails, users, existing_members = parse_users_list(source, invitees) + + common_arguments = { + source: source, + access_level: access_level, + existing_members: existing_members, + tasks_to_be_done: args[:tasks_to_be_done] || [] + }.merge(parsed_args(args)) + + members = emails.map do |email| + new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute + end + + members += users.map do |user| + new(invitee: user, **common_arguments).execute + end + + members end - - emails, users, existing_members = parse_users_list(source, invitees) - - common_arguments = { - source: source, - access_level: access_level, - existing_members: existing_members, - tasks_to_be_done: args[:tasks_to_be_done] || [] - }.merge(parsed_args(args)) - - members = emails.map do |email| - new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute - end - - members += users.map do |user| - new(invitee: user, **common_arguments).execute - end - - members end end end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index e432016795d..d4cc60c6de0 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -47,6 +47,10 @@ module Members def enqueue_jobs_that_needs_to_be_run_only_once_per_hierarchy(member, unassign_issuables) return if recursive_call? + enqueue_cleanup_jobs_once_per_heirarchy(member, unassign_issuables) + end + + def enqueue_cleanup_jobs_once_per_heirarchy(member, unassign_issuables) enqueue_delete_todos(member) enqueue_unassign_issuables(member) if unassign_issuables end diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 8560a15b7c4..dbe5567cbc5 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -5,12 +5,17 @@ module MergeRequests def execute(merge_request) return unless eligible_for_approval?(merge_request) - approval = merge_request.approvals.new(user: current_user) + approval = merge_request.approvals.new( + user: current_user, + patch_id_sha: fetch_patch_id_sha(merge_request) + ) return success unless save_approval(approval) reset_approvals_cache(merge_request) + merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request) + trigger_merge_request_merge_status_updated(merge_request) trigger_merge_request_reviewers_updated(merge_request) trigger_merge_request_approval_state_updated(merge_request) @@ -31,6 +36,17 @@ module MergeRequests private + def fetch_patch_id_sha(merge_request) + diff_refs = merge_request.diff_refs + base_sha = diff_refs&.base_sha + head_sha = diff_refs&.head_sha + + return unless base_sha && head_sha + return if base_sha == head_sha + + merge_request.project.repository.get_patch_id(base_sha, head_sha) + end + def eligible_for_approval?(merge_request) merge_request.eligible_for_approval_by?(current_user) end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 0fc85675e49..f36cad7139a 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -36,10 +36,7 @@ module MergeRequests merge_request.project.execute_integrations(merge_data, :merge_request_hooks) execute_external_hooks(merge_request, merge_data) - - if action == 'open' && Feature.enabled?(:group_mentions, merge_request.project) - execute_group_mention_hooks(merge_request, merge_data) - end + execute_group_mention_hooks(merge_request, merge_data) if action == 'open' enqueue_jira_connect_messages_for(merge_request) end @@ -113,7 +110,7 @@ module MergeRequests # Don't try to print expensive instance variables. def inspect - return "#<#{self.class}>" unless respond_to?(:merge_request) + return "#<#{self.class}>" unless respond_to?(:merge_request) && merge_request "#<#{self.class} #{merge_request.to_reference(full: true)}>" end @@ -176,21 +173,10 @@ module MergeRequests params.delete(:allow_collaboration) end - filter_locked_labels(merge_request) filter_reviewer(merge_request) filter_suggested_reviewers end - # Filter out any locked labels that are requested to be removed. - # Only supported for merged MRs. - def filter_locked_labels(merge_request) - return unless params[:remove_label_ids].present? - return unless merge_request.merged? - return unless Feature.enabled?(:enforce_locked_labels_on_merge, merge_request.project, type: :ops) - - params[:remove_label_ids] -= labels_service.filter_locked_labels_ids_in_param(:remove_label_ids) - end - def filter_reviewer(merge_request) return if params[:reviewer_ids].blank? diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index b8853e8bcbc..bb347096274 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -74,7 +74,7 @@ module MergeRequests # IssuableBaseService#process_label_ids and # IssuableBaseService#process_assignee_ids take care # of the removal. - params[:label_ids] = process_label_ids(params, extra_label_ids: merge_request.label_ids.to_a) + params[:label_ids] = process_label_ids(params, issuable: merge_request, extra_label_ids: merge_request.label_ids.to_a) params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: merge_request.assignee_ids.to_a) @@ -130,10 +130,14 @@ module MergeRequests if source_branch_default? && !target_branch_specified? merge_request.target_branch = nil else - merge_request.target_branch ||= target_project.default_branch + merge_request.target_branch ||= get_target_branch end end + def get_target_branch + target_project.default_branch + end + def source_branch_specified? params[:source_branch].present? end diff --git a/app/services/merge_requests/create_ref_service.rb b/app/services/merge_requests/create_ref_service.rb index e0f10183bac..eae6845335a 100644 --- a/app/services/merge_requests/create_ref_service.rb +++ b/app/services/merge_requests/create_ref_service.rb @@ -9,101 +9,117 @@ module MergeRequests CreateRefError = Class.new(StandardError) def initialize( - current_user:, merge_request:, target_ref:, first_parent_ref:, - source_sha: nil, merge_commit_message: nil) - + current_user:, merge_request:, target_ref:, first_parent_ref:, source_sha: nil + ) @current_user = current_user @merge_request = merge_request - @initial_source_sha = source_sha + @source_sha = source_sha @target_ref = target_ref - @merge_commit_message = merge_commit_message + @first_parent_ref = first_parent_ref @first_parent_sha = target_project.commit(first_parent_ref)&.sha end def execute - commit_sha = initial_source_sha # the SHA to be at HEAD of target_ref - source_sha = initial_source_sha # the SHA to be the merged result of the source (minus the merge commit) - expected_old_oid = "" # the SHA we expect target_ref to be at prior to an update (an optimistic lock) - # TODO: Update this message with the removal of FF merge_trains_create_ref_service and update tests # This is for compatibility with MergeToRefService during the rollout. return ServiceResponse.error(message: '3:Invalid merge source') unless first_parent_sha.present? - commit_sha, source_sha, expected_old_oid = maybe_squash!(commit_sha, source_sha, expected_old_oid) - commit_sha, source_sha, expected_old_oid = maybe_rebase!(commit_sha, source_sha, expected_old_oid) - commit_sha, source_sha = maybe_merge!(commit_sha, source_sha, expected_old_oid) - - ServiceResponse.success( - payload: { - commit_sha: commit_sha, - target_sha: first_parent_sha, - source_sha: source_sha - } - ) + result = { + commit_sha: source_sha, # the SHA to be at HEAD of target_ref + expected_old_oid: "", # the SHA we expect target_ref to be at prior to an update (an optimistic lock) + source_sha: source_sha, # for pipeline.source_sha + target_sha: first_parent_sha # for pipeline.target_sha + } + + result = maybe_squash!(**result) + result = maybe_rebase!(**result) + result = maybe_merge!(**result) + + update_merge_request!(merge_request, result) + + ServiceResponse.success(payload: result) rescue CreateRefError => error ServiceResponse.error(message: error.message) end private - attr_reader :current_user, :merge_request, :target_ref, :first_parent_sha, :initial_source_sha + attr_reader :current_user, :merge_request, :target_ref, :first_parent_ref, :first_parent_sha, :source_sha delegate :target_project, to: :merge_request delegate :repository, to: :target_project - def maybe_squash!(commit_sha, source_sha, expected_old_oid) + def maybe_squash!(commit_sha:, **rest) if merge_request.squash_on_merge? squash_result = MergeRequests::SquashService.new( merge_request: merge_request, current_user: current_user, commit_message: squash_commit_message ).execute + raise CreateRefError, squash_result[:message] if squash_result[:status] == :error commit_sha = squash_result[:squash_sha] - source_sha = commit_sha + squash_commit_sha = commit_sha end # squash does not overwrite target_ref, so expected_old_oid remains the same - [commit_sha, source_sha, expected_old_oid] + rest.merge( + commit_sha: commit_sha, + squash_commit_sha: squash_commit_sha + ).compact end - def maybe_rebase!(commit_sha, source_sha, expected_old_oid) + def maybe_rebase!(commit_sha:, expected_old_oid:, squash_commit_sha: nil, **rest) if target_project.ff_merge_must_be_possible? commit_sha = safe_gitaly_operation do repository.rebase_to_ref( current_user, - source_sha: source_sha, + source_sha: commit_sha, target_ref: target_ref, - first_parent_ref: first_parent_sha + first_parent_ref: first_parent_sha, + expected_old_oid: expected_old_oid || "" ) end - source_sha = commit_sha + squash_commit_sha = commit_sha if squash_commit_sha # rebase rewrites commit SHAs after first_parent_sha expected_old_oid = commit_sha end - [commit_sha, source_sha, expected_old_oid] + rest.merge( + commit_sha: commit_sha, + squash_commit_sha: squash_commit_sha, + expected_old_oid: expected_old_oid + ).compact end - def maybe_merge!(commit_sha, source_sha, expected_old_oid) + def maybe_merge!(commit_sha:, expected_old_oid:, **rest) unless target_project.merge_requests_ff_only_enabled commit_sha = safe_gitaly_operation do repository.merge_to_ref( current_user, - source_sha: source_sha, + source_sha: commit_sha, target_ref: target_ref, message: merge_commit_message, first_parent_ref: first_parent_sha, branch: nil, - expected_old_oid: expected_old_oid + expected_old_oid: expected_old_oid || "" ) end - commit = target_project.commit(commit_sha) - _, source_sha = commit.parent_ids + + expected_old_oid = commit_sha + merge_commit_sha = commit_sha end - [commit_sha, source_sha] + rest.merge( + commit_sha: commit_sha, + merge_commit_sha: merge_commit_sha, + expected_old_oid: expected_old_oid + ).compact + end + + def update_merge_request!(merge_request, result) + # overridden in EE end def safe_gitaly_operation @@ -119,12 +135,10 @@ module MergeRequests strong_memoize_attr :squash_commit_message def merge_commit_message - return @merge_commit_message if @merge_commit_message.present? - - @merge_commit_message = ( - merge_request.merge_params['commit_message'].presence || + merge_request.merge_params['commit_message'].presence || merge_request.default_merge_commit_message(user: current_user) - ) end end end + +MergeRequests::CreateRefService.prepend_mod_with('MergeRequests::CreateRefService') diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb deleted file mode 100644 index 1a83bbf9de6..00000000000 --- a/app/services/merge_requests/ff_merge_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - # MergeService class - # - # Do git fast-forward merge and in case of success - # mark merge request as merged and execute all hooks and notifications - # Executed when you do fast-forward merge via GitLab UI - # - class FfMergeService < MergeRequests::MergeService - extend ::Gitlab::Utils::Override - - private - - override :execute_git_merge - def execute_git_merge - repository.ff_merge( - current_user, - source, - merge_request.target_branch, - merge_request: merge_request - ) - end - - override :merge_success_data - def merge_success_data(commit_id) - # There is no merge commit to update, so this is just blank. - {} - end - end -end diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index fa0a4f808e2..0c8795cfd61 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -18,24 +18,8 @@ module MergeRequests # No-op end - def source - strong_memoize(:source) do - if merge_request.squash_on_merge? - squash_sha! - else - merge_request.diff_head_sha - end - end - end - private - def check_source - unless source - raise_error('No source for merge') - end - end - # Overridden in EE. def check_size_limit # No-op @@ -53,26 +37,6 @@ module MergeRequests def handle_merge_error(*args) # No-op end - - def commit_message - params[:commit_message] || - merge_request.default_merge_commit_message(user: current_user) - end - - def squash_sha! - squash_result = ::MergeRequests::SquashService.new( - merge_request: merge_request, - current_user: current_user, - commit_message: params[:squash_commit_message] - ).execute - - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end - end end end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 1398a6dbb67..29aba3c8679 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -16,38 +16,6 @@ module MergeRequests delegate :merge_jid, :state, to: :@merge_request def execute(merge_request, options = {}) - return execute_v2(merge_request, options) if Feature.enabled?(:refactor_merge_service, project) - - if project.merge_requests_ff_only_enabled && !self.is_a?(FfMergeService) - FfMergeService.new(project: project, current_user: current_user, params: params).execute(merge_request) - return - end - - return if merge_request.merged? - return unless exclusive_lease(merge_request.id).try_obtain - - @merge_request = merge_request - @options = options - jid = merge_jid - - validate! - - merge_request.in_locked_state do - if commit - after_merge - clean_merge_jid - success - end - end - - log_info("Merge process finished on JID #{jid} with state #{state}") - rescue MergeError => e - handle_merge_error(log_message: e.message, save_message_on_model: true) - ensure - exclusive_lease(merge_request.id).cancel - end - - def execute_v2(merge_request, options = {}) return if merge_request.merged? return unless exclusive_lease(merge_request.id).try_obtain @@ -61,7 +29,7 @@ module MergeRequests validate! merge_request.in_locked_state do - if commit_v2 + if commit after_merge clean_merge_jid success @@ -90,28 +58,8 @@ module MergeRequests end end - # Can remove this entire method when :refactor_merge_service is enabled - def error_check! - super - - return if Feature.enabled?(:refactor_merge_service, project) - - check_source - - error = - if @merge_request.should_be_rebased? - 'Only fast-forward merge is allowed for your project. Please update your source branch' - elsif !@merge_request.mergeable?(skip_discussions_check: @options[:skip_discussions_check], check_mergeability_retry_lease: @options[:check_mergeability_retry_lease]) - 'Merge request is not mergeable' - elsif !@merge_request.squash && project.squash_always? - 'This project requires squashing commits when merge requests are accepted.' - end - - raise_error(error) if error - end - def validate_strategy! - @merge_strategy.validate! if Feature.enabled?(:refactor_merge_service, project) + @merge_strategy.validate! end def updated_check! @@ -121,7 +69,7 @@ module MergeRequests end end - def commit_v2 + def commit log_info("Git merge started on JID #{merge_jid}") merge_result = try_merge { @merge_strategy.execute_git_merge! } @@ -131,7 +79,11 @@ module MergeRequests log_info("Git merge finished on JID #{merge_jid} commit #{commit_sha}") - new_merge_request_attributes = merge_result.slice(:merge_commit_sha, :squash_commit_sha) + new_merge_request_attributes = { + merged_commit_sha: commit_sha, + merge_commit_sha: merge_result[:merge_commit_sha], + squash_commit_sha: merge_result[:squash_commit_sha] + }.compact merge_request.update!(new_merge_request_attributes) if new_merge_request_attributes.present? commit_sha @@ -140,35 +92,6 @@ module MergeRequests log_info("Merge request marked in progress") end - def commit - log_info("Git merge started on JID #{merge_jid}") - commit_id = try_merge { execute_git_merge } - - if commit_id - log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}") - else - raise_error(GENERIC_ERROR_MESSAGE) - end - - update_merge_sha_metadata(commit_id) - - commit_id - ensure - merge_request.update_and_mark_in_progress_merge_commit_sha(nil) - log_info("Merge request marked in progress") - end - - def update_merge_sha_metadata(commit_id) - data_to_update = merge_success_data(commit_id) - data_to_update[:squash_commit_sha] = source if merge_request.squash_on_merge? - - merge_request.update!(**data_to_update) if data_to_update.present? - end - - def merge_success_data(commit_id) - { merge_commit_sha: commit_id } - end - def try_merge yield rescue Gitlab::Git::PreReceiveError => e @@ -178,10 +101,6 @@ module MergeRequests raise_error(GENERIC_ERROR_MESSAGE) end - def execute_git_merge - repository.merge(current_user, source, merge_request, commit_message) - end - def after_merge log_info("Post merge started on JID #{merge_jid} with state #{state}") MergeRequests::PostMergeService.new(project: project, current_user: current_user).execute(merge_request) diff --git a/app/services/merge_requests/merge_strategies/from_source_branch.rb b/app/services/merge_requests/merge_strategies/from_source_branch.rb index 9fe5fc5160b..fe0e4d8a90c 100644 --- a/app/services/merge_requests/merge_strategies/from_source_branch.rb +++ b/app/services/merge_requests/merge_strategies/from_source_branch.rb @@ -28,7 +28,7 @@ module MergeRequests check_mergeability_retry_lease: @options[:check_mergeability_retry_lease] ) 'Merge request is not mergeable' - elsif !merge_request.squash && project.squash_always? + elsif merge_request.missing_required_squash? 'This project requires squashing commits when merge requests are accepted.' end @@ -110,3 +110,5 @@ module MergeRequests end end end + +::MergeRequests::MergeStrategies::FromSourceBranch.prepend_mod diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index 8b79feb5e0f..6e1b56d9651 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -31,14 +31,13 @@ module MergeRequests private - override :source def source merge_request.diff_head_sha end override :error_check! def error_check! - check_source + raise_error('No source for merge') unless source end ## @@ -55,6 +54,11 @@ module MergeRequests params[:first_parent_ref] || merge_request.target_branch_ref end + def commit_message + params[:commit_message] || + merge_request.default_merge_commit_message(user: current_user) + end + def extracted_merge_to_ref repository.merge_to_ref(current_user, source_sha: source, diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 447f4f9428c..7a7d0dbfef2 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -94,7 +94,9 @@ module MergeRequests ) merge_requests.each do |merge_request| - merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha) + sha = analyzer.get_merge_commit(merge_request.diff_head_sha) + merge_request.merge_commit_sha = sha + merge_request.merged_commit_sha = sha MergeRequests::PostMergeService .new(project: merge_request.target_project, current_user: @current_user) diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 598dbaf93a9..c435048e343 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -94,7 +94,7 @@ module MergeRequests end def track_title_and_desc_edits(changed_fields) - tracked_fields = %w(title description) + tracked_fields = %w[title description] return unless changed_fields.any? { |field| tracked_fields.include?(field) } diff --git a/app/services/metrics/global_metrics_update_service.rb b/app/services/metrics/global_metrics_update_service.rb deleted file mode 100644 index 356de58ba2e..00000000000 --- a/app/services/metrics/global_metrics_update_service.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Metrics - # Update metrics regarding GitLab instance wide - # - # Anything that is not specific to a machine, process, request or any other context - # can be updated from this services. - # - # Examples of metrics that qualify: - # * Global counters (instance users, instance projects...) - # * State of settings stored in the database (whether a feature is active or not, tuning values...) - # - class GlobalMetricsUpdateService - def execute - return unless ::Gitlab::Metrics.prometheus_metrics_enabled? - - maintenance_mode_metric.set({}, (::Gitlab.maintenance_mode? ? 1 : 0)) - end - - def maintenance_mode_metric - ::Gitlab::Metrics.gauge(:gitlab_maintenance_mode, 'Is GitLab Maintenance Mode enabled?') - end - end -end diff --git a/app/services/metrics/sample_metrics_service.rb b/app/services/metrics/sample_metrics_service.rb deleted file mode 100644 index 9bf32b295e2..00000000000 --- a/app/services/metrics/sample_metrics_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Metrics - class SampleMetricsService - DIRECTORY = "sample_metrics" - - attr_reader :identifier, :range_minutes - - def initialize(identifier, range_start:, range_end:) - @identifier = identifier - @range_minutes = convert_range_minutes(range_start, range_end) - end - - def query - return unless identifier && File.exist?(file_location) - - query_interval - end - - private - - def file_location - sanitized_string = identifier.gsub(/[^0-9A-Za-z_]/, '') - File.join(Rails.root, DIRECTORY, "#{sanitized_string}.yml") - end - - def query_interval - result = YAML.load_file(File.expand_path(file_location, __dir__)) - result[range_minutes] - end - - def convert_range_minutes(range_start, range_end) - ((range_end.to_time - range_start.to_time) / 1.minute).to_i - end - end -end diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index 1ce7e4cae16..14e670126c6 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -44,108 +44,6 @@ module Namespaces interval_days = TRACKS.dig(track.to_sym, :interval_days) interval_days&.count || 0 end - - def self.send_for_all_tracks_and_intervals - TRACKS.each_key do |track| - TRACKS[track][:interval_days].each do |interval| - new(track, interval).execute - end - end - end - - def initialize(track, interval) - @track = track - @interval = interval - @sent_email_records = ::Users::InProductMarketingEmailRecords.new - end - - def execute - raise ArgumentError, "Track #{track} not defined" unless TRACKS.key?(track) - - groups_for_track.each_batch do |groups| - groups.each do |group| - send_email_for_group(group) - end - end - end - - private - - attr_reader :track, :interval, :sent_email_records - - def send_email(user, group) - NotificationService.new.in_product_marketing(user.id, group.id, track, series) - end - - def send_email_for_group(group) - users_for_group(group).each do |user| - if can_perform_action?(user, group) - send_email(user, group) - sent_email_records.add(user, track: track, series: series) - end - end - - sent_email_records.save! - end - - def groups_for_track - onboarding_progress_scope = Onboarding::Progress - .completed_actions_with_latest_in_range(completed_actions, range) - .incomplete_actions(incomplete_actions) - - # Filtering out sub-groups is a temporary fix to prevent calling - # `.root_ancestor` on groups that are not root groups. - # See https://gitlab.com/groups/gitlab-org/-/epics/5594 for more information. - Group - .top_most - .with_onboarding_progress - .merge(onboarding_progress_scope) - .merge(subscription_scope) - end - - def subscription_scope - {} - end - - # rubocop: disable CodeReuse/ActiveRecord - def users_for_group(group) - group.users - .where(email_opted_in: true) - .merge(Users::InProductMarketingEmail.without_track_and_series(track, series)) - end - # rubocop: enable CodeReuse/ActiveRecord - - def can_perform_action?(user, group) - case track - when :create, :verify - user.can?(:create_projects, group) - when :trial, :trial_short - user.can?(:start_trial, group) - when :team, :team_short - user.can?(:admin_group_member, group) - when :admin_verify - user.can?(:admin_group, group) - when :experience - true - end - end - - def completed_actions - TRACKS[track][:completed_actions] - end - - def range - date = (interval + 1).days.ago - date.beginning_of_day..date.end_of_day - end - - def incomplete_actions - TRACKS[track][:incomplete_actions] - end - - def series - TRACKS[track][:interval_days].index(interval) - end end end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index fdab2a07990..1af26377b71 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -187,12 +187,12 @@ module Notes namespace: project&.namespace, user: user, label: metric_key_path, - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_key_path).to_context] + context: [Gitlab::Usage::MetricDefinition.context_for(metric_key_path).to_context] ) end def tracking_data_for(note) - label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note' + label = Gitlab.ee? && note.author == Users::Internal.visual_review_bot ? 'anonymous_visual_review_note' : 'note' { label: label, diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 9465b5218b0..6e92a887cdd 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -42,8 +42,6 @@ module Notes note.project.execute_hooks(note_data, hooks_scope) note.project.execute_integrations(note_data, hooks_scope) - return unless Feature.enabled?(:group_mentions, note.project) - execute_group_mention_hooks(note, note_data, is_confidential) end diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb index 3fabec29c0d..afbf5747429 100644 --- a/app/services/notification_recipients/builder/base.rb +++ b/app/services/notification_recipients/builder/base.rb @@ -44,6 +44,7 @@ module NotificationRecipients def add_recipients(users, type, reason) if users.is_a?(ActiveRecord::Relation) users = users.includes(:notification_settings) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421821') end users = Array(users).compact diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 648067e3452..f1781b3d3c5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -75,6 +75,19 @@ class NotificationService end end + def resource_access_tokens_about_to_expire(bot_user, token_names) + recipients = bot_user.resource_bot_owners.select { |owner| owner.can?(:receive_notifications) } + resource = bot_user.resource_bot_resource + + recipients.each do |recipient| + mailer.resource_access_tokens_about_to_expire_email( + recipient, + resource, + token_names + ).deliver_later + end + end + # Notify the owner of the account when a new personal access token is created def access_token_created(user, token_name) return unless user.can?(:receive_notifications) @@ -430,13 +443,14 @@ class NotificationService def send_service_desk_notification(note) return unless note.noteable_type == 'Issue' return if note.confidential + return unless note.project.service_desk_enabled? issue = note.noteable recipients = issue.email_participants_emails return unless recipients.any? - support_bot = User.support_bot + support_bot = Users::Internal.support_bot recipients.delete(issue.external_author) if note.author == support_bot recipients.each do |recipient| @@ -755,10 +769,6 @@ class NotificationService end end - def in_product_marketing(user_id, group_id, track, series) - mailer.in_product_marketing_email(user_id, group_id, track, series).deliver_later - end - def approve_mr(merge_request, current_user) approve_mr_email(merge_request, merge_request.target_project, current_user) end diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index 9feb860ae87..05f6d9af581 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -12,7 +12,7 @@ module Packages DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze # From https://salsa.debian.org/ftp-team/dak/-/blob/991aaa27a7f7aa773bb9c0cf2d516e383d9cffa0/setup/core-init.d/080_metadatakeys#L9 - METADATA_KEYS = %w( + METADATA_KEYS = %w[ Package Source Binary @@ -60,7 +60,7 @@ module Packages Tag Package-Type Installer-Menu-Item - ).freeze + ].freeze def initialize(distribution) @distribution = distribution diff --git a/app/services/packages/npm/generate_metadata_service.rb b/app/services/packages/npm/generate_metadata_service.rb index e1795079513..8eaac547f7e 100644 --- a/app/services/packages/npm/generate_metadata_service.rb +++ b/app/services/packages/npm/generate_metadata_service.rb @@ -4,6 +4,7 @@ module Packages module Npm class GenerateMetadataService include API::Helpers::RelatedResourcesHelpers + include Gitlab::Utils::StrongMemoize # Allowed fields are those defined in the abbreviated form # defined here: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object @@ -13,6 +14,8 @@ module Packages def initialize(name, packages) @name = name @packages = packages + @dependencies = {} + @dependency_ids = Hash.new { |h, key| h[key] = {} } end def execute(only_dist_tags: false) @@ -21,7 +24,7 @@ module Packages private - attr_reader :name, :packages + attr_reader :name, :packages, :dependencies, :dependency_ids def metadata(only_dist_tags) result = { dist_tags: dist_tags } @@ -38,9 +41,11 @@ module Packages package_versions = {} packages.each_batch do |relation| - batched_packages = relation.including_dependency_links - .preload_files - .preload_npm_metadatum + load_dependencies(relation) + load_dependency_ids(relation) + + batched_packages = relation.preload_files + .preload_npm_metadatum batched_packages.each do |package| package_file = package.installable_package_files.last @@ -82,15 +87,17 @@ module Packages end def build_package_dependencies(package) - dependencies = Hash.new { |h, key| h[key] = {} } - - package.dependency_links.each do |dependency_link| - dependency = dependency_link.dependency - dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern + dependency_ids[package.id].each_with_object(Hash.new { |h, key| h[key] = {} }) do |(type, ids), memo| + ids.each do |id| + memo[inverted_dependency_types[type]].merge!(dependencies[id]) + end end + end - dependencies + def inverted_dependency_types + Packages::DependencyLink.dependency_types.invert.stringify_keys end + strong_memoize_attr :inverted_dependency_types def sorted_versions versions = packages.pluck_versions.compact @@ -106,6 +113,31 @@ module Packages json = package.npm_metadatum&.package_json || {} json.slice(*PACKAGE_JSON_ALLOWED_FIELDS) end + + def load_dependencies(packages) + Packages::Dependency + .id_in( + Packages::DependencyLink + .for_packages(packages) + .select_dependency_id + ) + .id_not_in(dependencies.keys) + .each_batch do |relation| + relation.each do |dependency| + dependencies[dependency.id] = { dependency.name => dependency.version_pattern } + end + end + end + + def load_dependency_ids(packages) + Packages::DependencyLink + .dependency_ids_grouped_by_type(packages) + .each_batch(column: :package_id) do |relation| + relation.each do |dependency_link| + dependency_ids[dependency_link.package_id] = dependency_link.dependency_ids_by_type + end + end + end end end end diff --git a/app/services/packages/nuget/check_duplicates_service.rb b/app/services/packages/nuget/check_duplicates_service.rb new file mode 100644 index 00000000000..7ad9038d7c1 --- /dev/null +++ b/app/services/packages/nuget/check_duplicates_service.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class CheckDuplicatesService < BaseService + include Gitlab::Utils::StrongMemoize + + ExtractionError = Class.new(StandardError) + + def execute + return ServiceResponse.success if package_settings_allow_duplicates? || !target_package_is_duplicate? + + ServiceResponse.error( + message: 'A package with the same name and version already exists', + reason: :conflict + ) + rescue ExtractionError => e + ServiceResponse.error(message: e.message, reason: :bad_request) + end + + private + + def package_settings_allow_duplicates? + package_settings.nuget_duplicates_allowed? || package_settings.class.duplicates_allowed?(existing_package) + end + + def target_package_is_duplicate? + existing_package.name.casecmp(metadata[:package_name]) == 0 && + (existing_package.version.casecmp(metadata[:package_version]) == 0 || + existing_package.normalized_nuget_version&.casecmp(metadata[:package_version]) == 0) + end + + def package_settings + project.namespace.package_settings + end + strong_memoize_attr :package_settings + + def existing_package + ::Packages::Nuget::PackageFinder + .new( + current_user, + project, + package_name: metadata[:package_name], + package_version: metadata[:package_version] + ) + .execute + .first + end + strong_memoize_attr :existing_package + + def metadata + if remote_package_file? + ExtractMetadataContentService + .new(nuspec_file_content) + .execute + .payload + else # to cover the case when package file is on disk not in object storage + MetadataExtractionService + .new(mock_package_file) + .execute + .payload + end + end + strong_memoize_attr :metadata + + def remote_package_file? + params[:remote_url].present? + end + + def nuspec_file_content + ExtractRemoteMetadataFileService + .new(params[:remote_url]) + .execute + .payload + rescue ExtractRemoteMetadataFileService::ExtractionError => e + raise ExtractionError, e.message + end + + def mock_package_file + ::Packages::PackageFile.new( + params + .slice(:file, :file_name) + .merge(package: ::Packages::Package.nuget.build) + ) + end + end + end +end diff --git a/app/services/packages/nuget/extract_metadata_file_service.rb b/app/services/packages/nuget/extract_metadata_file_service.rb index 61e4892fee7..cc040a45016 100644 --- a/app/services/packages/nuget/extract_metadata_file_service.rb +++ b/app/services/packages/nuget/extract_metadata_file_service.rb @@ -3,14 +3,12 @@ module Packages module Nuget class ExtractMetadataFileService - include Gitlab::Utils::StrongMemoize - ExtractionError = Class.new(StandardError) MAX_FILE_SIZE = 4.megabytes.freeze - def initialize(package_file_id) - @package_file_id = package_file_id + def initialize(package_file) + @package_file = package_file end def execute @@ -21,12 +19,7 @@ module Packages private - attr_reader :package_file_id - - def package_file - ::Packages::PackageFile.find_by_id(package_file_id) - end - strong_memoize_attr :package_file + attr_reader :package_file def valid_package_file? package_file && @@ -41,7 +34,7 @@ module Packages raise ExtractionError, 'nuspec file not found' unless entry raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size - Tempfile.open("nuget_extraction_package_file_#{package_file_id}") do |file| + Tempfile.open("nuget_extraction_package_file_#{package_file.id}") do |file| entry.extract(file.path) { true } # allow #extract to overwrite the file file.unlink file.read diff --git a/app/services/packages/nuget/extract_remote_metadata_file_service.rb b/app/services/packages/nuget/extract_remote_metadata_file_service.rb new file mode 100644 index 00000000000..37624002ce7 --- /dev/null +++ b/app/services/packages/nuget/extract_remote_metadata_file_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ExtractRemoteMetadataFileService + include Gitlab::Utils::StrongMemoize + + ExtractionError = Class.new(StandardError) + + MAX_FILE_SIZE = 4.megabytes.freeze + METADATA_FILE_EXTENSION = '.nuspec' + MAX_FRAGMENTS = 5 # nuspec file is usually in the first 2 fragments but we buffer 5 max + + def initialize(remote_url) + @remote_url = remote_url + end + + def execute + raise ExtractionError, 'invalid file url' if remote_url.blank? + + if nuspec_file_content.blank? || !nuspec_file_content.instance_of?(String) + raise ExtractionError, 'nuspec file not found' + end + + ServiceResponse.success(payload: nuspec_file_content) + end + + private + + attr_reader :remote_url + + def nuspec_file_content + fragments = [] + + Gitlab::HTTP.get(remote_url, stream_body: true, allow_object_storage: true) do |fragment| + break if fragments.size >= MAX_FRAGMENTS + + fragments << fragment + joined_fragments = fragments.join + + next if joined_fragments.exclude?(METADATA_FILE_EXTENSION) + + nuspec_content = extract_nuspec_file(joined_fragments) + + break nuspec_content if nuspec_content.present? + end + end + strong_memoize_attr :nuspec_file_content + + def extract_nuspec_file(fragments) + StringIO.open(fragments) do |io| + Zip::InputStream.open(io) do |zip| + process_zip_entries(zip) + end + rescue Zip::Error => e + raise ExtractionError, "Error opening zip stream: #{e.message}" + end + end + + def process_zip_entries(zip) + while (entry = zip.get_next_entry) # rubocop:disable Lint/AssignmentInCondition + next unless entry.name.end_with?(METADATA_FILE_EXTENSION) + + raise ExtractionError, 'nuspec file too big' if entry.size > MAX_FILE_SIZE + + return extract_file_content(entry) + end + end + + def extract_file_content(entry) + Tempfile.create('extract_remote_metadata_file_service') do |file| + entry.extract(file.path) { true } # allow #extract to overwrite the file + file.read + end + rescue Zip::DecompressionError + '' # Ignore decompression errors and continue reading the next fragment + rescue Zip::EntrySizeError => e + raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}" + end + end + end +end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index e1ee29ef2c6..2c758a5ec20 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -3,8 +3,8 @@ module Packages module Nuget class MetadataExtractionService - def initialize(package_file_id) - @package_file_id = package_file_id + def initialize(package_file) + @package_file = package_file end def execute @@ -13,18 +13,18 @@ module Packages private - attr_reader :package_file_id + attr_reader :package_file - def nuspec_file_content - ExtractMetadataFileService - .new(package_file_id) + def metadata + ExtractMetadataContentService + .new(nuspec_file_content) .execute .payload end - def metadata - ExtractMetadataContentService - .new(nuspec_file_content) + def nuspec_file_content + ExtractMetadataFileService + .new(package_file) .execute .payload end diff --git a/app/services/packages/nuget/odata_package_entry_service.rb b/app/services/packages/nuget/odata_package_entry_service.rb new file mode 100644 index 00000000000..0cdcc38de16 --- /dev/null +++ b/app/services/packages/nuget/odata_package_entry_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class OdataPackageEntryService + include API::Helpers::RelatedResourcesHelpers + + SEMVER_LATEST_VERSION_PLACEHOLDER = '0.0.0-latest-version' + LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT = 'latest' + + def initialize(project, params) + @project = project + @params = params + end + + def execute + ServiceResponse.success(payload: package_entry) + end + + private + + attr_reader :project, :params + + def package_entry + <<-XML.squish + <entry xmlns='http://www.w3.org/2005/Atom' xmlns:d='http://schemas.microsoft.com/ado/2007/08/dataservices' xmlns:georss='http://www.georss.org/georss' xmlns:gml='http://www.opengis.net/gml' xmlns:m='http://schemas.microsoft.com/ado/2007/08/dataservices/metadata' xml:base="#{xml_base}"> + <id>#{id_url}</id> + <category term='V2FeedPackage' scheme='http://schemas.microsoft.com/ado/2007/08/dataservices/scheme'/> + <title type='text'>#{params[:package_name]}</title> + <content type='application/zip' src="#{download_url}"/> + <m:properties> + <d:Version>#{package_version}</d:Version> + </m:properties> + </entry> + XML + end + + def package_version + params[:package_version] || SEMVER_LATEST_VERSION_PLACEHOLDER + end + + def id_url + expose_url "#{api_v4_projects_packages_nuget_v2_path(id: project.id)}" \ + "/Packages(Id='#{params[:package_name]}',Version='#{package_version}')" + end + + # TODO: use path helper when download endpoint is merged + def download_url + expose_url "#{api_v4_projects_packages_nuget_v2_path(id: project.id)}" \ + "/download/#{params[:package_name]}/#{download_url_package_version}" + end + + def download_url_package_version + if latest_version? + LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT + else + params[:package_version] + end + end + + def latest_version? + params[:package_version].nil? || params[:package_version] == SEMVER_LATEST_VERSION_PLACEHOLDER + end + + def xml_base + expose_url api_v4_projects_packages_nuget_v2_path(id: project.id) + end + end + end +end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb index 73a52ea569f..258f8c8f6aa 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -148,7 +148,7 @@ module Packages end def metadata - ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute.payload + ::Packages::Nuget::MetadataExtractionService.new(@package_file).execute.payload end strong_memoize_attr :metadata diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index c8ccbe1465e..10aef87332a 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService private def quick_action_types - %w(Issue MergeRequest Commit WorkItem) + %w[Issue MergeRequest Commit WorkItem] end def explain_quick_actions(text) diff --git a/app/services/projects/apple_target_platform_detector_service.rb b/app/services/projects/apple_target_platform_detector_service.rb index ec4c16a1416..087bb1f22e1 100644 --- a/app/services/projects/apple_target_platform_detector_service.rb +++ b/app/services/projects/apple_target_platform_detector_service.rb @@ -20,7 +20,7 @@ module Projects # > AppleTargetPlatformDetectorService.new(multiplatform_project).execute # => [:ios, :osx, :tvos, :watchos] class AppleTargetPlatformDetectorService < BaseService - BUILD_CONFIG_FILENAMES = %w(project.pbxproj *.xcconfig).freeze + BUILD_CONFIG_FILENAMES = %w[project.pbxproj *.xcconfig].freeze # For the current iteration, we only want to detect when the project targets # iOS. In the future, we can use the same logic to detect projects that diff --git a/app/services/projects/container_repository/cleanup_tags_base_service.rb b/app/services/projects/container_repository/cleanup_tags_base_service.rb index 45557d03502..61b09de1643 100644 --- a/app/services/projects/container_repository/cleanup_tags_base_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb @@ -100,7 +100,7 @@ module Projects def older_than_in_seconds strong_memoize(:older_than_in_seconds) do - ChronicDuration.parse(older_than).seconds + ChronicDuration.parse(older_than, use_complete_matcher: true).seconds end end end diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb index 530cf87c338..a5b7f4bbb6f 100644 --- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb @@ -39,7 +39,7 @@ module Projects end end - @deleted_tags.any? ? success(deleted: @deleted_tags) : error('could not delete tags') + @deleted_tags.any? ? success(deleted: @deleted_tags) : error("could not delete tags: #{@tag_names.join(', ')}".truncate(1000)) end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index bca78b88630..e4987438c57 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -144,6 +144,8 @@ module Projects end def create_project_settings + Gitlab::Pages.add_unique_domain_to(project) + @project.project_setting.save if @project.project_setting.changed? end @@ -223,22 +225,26 @@ module Projects end def save_project_and_import_data - ApplicationRecord.transaction do - @project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424281' + ) do + ApplicationRecord.transaction do + @project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data - # Avoid project callbacks being triggered multiple times by saving the parent first. - # See https://github.com/rails/rails/issues/41701. - Namespaces::ProjectNamespace.create_from_project!(@project) if @project.valid? + # Avoid project callbacks being triggered multiple times by saving the parent first. + # See https://github.com/rails/rails/issues/41701. + Namespaces::ProjectNamespace.create_from_project!(@project) if @project.valid? - if @project.saved? - Integration.create_from_active_default_integrations(@project, :project_id) + if @project.saved? + Integration.create_from_active_default_integrations(@project, :project_id) - @project.create_labels unless @project.gitlab_project_import? + @project.create_labels unless @project.gitlab_project_import? - next if @project.import? + next if @project.import? - unless @project.create_repository(default_branch: default_branch) - raise 'Failed to create repository' + unless @project.create_repository(default_branch: default_branch) + raise 'Failed to create repository' + end end end end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 0ae6fcb4d97..a2a2f9d2800 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -255,7 +255,11 @@ module Projects # We need to remove them when a project is deleted # rubocop: disable CodeReuse/ActiveRecord def destroy_project_bots! - project.members.includes(:user).references(:user).merge(User.project_bot).each do |member| + members = project.members + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422405') + .includes(:user).references(:user).merge(User.project_bot) + + members.each do |member| Users::DestroyService.new(current_user).execute(member.user, skip_authorization: true) end end diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb index 22104409199..d67b7832bf8 100644 --- a/app/services/projects/download_service.rb +++ b/app/services/projects/download_service.rb @@ -28,7 +28,7 @@ module Projects end def http?(url) - url =~ /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/ + url =~ /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/ end def valid_domain?(url) diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index 023f8494d99..40c4fd5376c 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -6,7 +6,7 @@ module Projects extend ::Gitlab::Utils::Override # List of paths that can be excluded while evaluation if a target can be discarded - DISCARDABLE_PATHS = %w(tmp tmp/cache tmp/work).freeze + DISCARDABLE_PATHS = %w[tmp tmp/cache tmp/work].freeze def initialize(project:, old_disk_path:, logger: nil) super diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb index 737b794484d..a0fc5149bb4 100644 --- a/app/services/projects/import_error_filter.rb +++ b/app/services/projects/import_error_filter.rb @@ -4,7 +4,7 @@ module Projects # Used by project imports, it removes any potential paths # included in an error message that could be stored in the DB class ImportErrorFilter - ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/.freeze + ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/ FILTER_MESSAGE = '[FILTERED]' def self.filter_message(message) diff --git a/app/services/projects/in_product_marketing_campaign_emails_service.rb b/app/services/projects/in_product_marketing_campaign_emails_service.rb index 249a2d89fc1..a549d8f594e 100644 --- a/app/services/projects/in_product_marketing_campaign_emails_service.rb +++ b/app/services/projects/in_product_marketing_campaign_emails_service.rb @@ -26,13 +26,9 @@ module Projects sent_email_records.save! end - # rubocop: disable CodeReuse/ActiveRecord def project_users - @project_users ||= project.users - .where(email_opted_in: true) - .merge(Users::InProductMarketingEmail.without_campaign(campaign)) + @project_users ||= project.users.merge(Users::InProductMarketingEmail.without_campaign(campaign)) end - # rubocop: enable CodeReuse/ActiveRecord def project_users_max_access_levels ids = project_users.map(&:id) diff --git a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb index 09fec9939b9..0efe9fb16f6 100644 --- a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb @@ -9,7 +9,7 @@ module Projects include Gitlab::Utils::StrongMemoize HEAD_REV = 'HEAD' - LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze + LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/ LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch' LfsObjectDownloadListError = Class.new(StandardError) @@ -101,7 +101,7 @@ module Projects # The import url must end with '.git' here we ensure it is def default_endpoint_uri @default_endpoint_uri ||= import_uri.dup.tap do |uri| - path = uri.path.gsub(%r(/$), '') + path = uri.path.gsub(%r{/$}, '') path += '.git' unless path.ends_with?('.git') uri.path = path + LFS_BATCH_API_ENDPOINT end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 642ec37619f..3d08039942b 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -110,36 +110,40 @@ module Projects end def proceed_to_transfer - Project.transaction do - project.expire_caches_before_rename(@old_path) + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424282' + ) do + Project.transaction do + project.expire_caches_before_rename(@old_path) - # Apply changes to the project - update_namespace_and_visibility(@new_namespace) - project.reconcile_shared_runners_setting! - project.save! + # Apply changes to the project + update_namespace_and_visibility(@new_namespace) + project.reconcile_shared_runners_setting! + project.save! - # Notifications - project.send_move_instructions(@old_path) + # Notifications + project.send_move_instructions(@old_path) - # Directories on disk - move_project_folders(project) + # Directories on disk + move_project_folders(project) - transfer_missing_group_resources(@old_group) + transfer_missing_group_resources(@old_group) - # Move uploads - move_project_uploads(project) + # Move uploads + move_project_uploads(project) - update_integrations + update_integrations - remove_paid_features + remove_paid_features - project.old_path_with_namespace = @old_path + project.old_path_with_namespace = @old_path - update_repository_configuration(@new_path) + update_repository_configuration(@new_path) - remove_issue_contacts + remove_issue_contacts - execute_system_hooks + execute_system_hooks + end end update_pending_builds diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 403f645392c..dc92c501b8c 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -2,10 +2,12 @@ module Projects class UpdatePagesService < BaseService + include Gitlab::Utils::StrongMemoize + # old deployment can be cached by pages daemon # so we need to give pages daemon some time update cache # 10 minutes is enough, but 30 feels safer - OLD_DEPLOYMENTS_DESTRUCTION_DELAY = 30.minutes.freeze + OLD_DEPLOYMENTS_DESTRUCTION_DELAY = 30.minutes attr_reader :build, :deployment_update @@ -18,9 +20,7 @@ module Projects def execute register_attempt - # Create status notifying the deployment of pages - @commit_status = build_commit_status - ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(@commit_status) do |job| + ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(commit_status) do |job| job.enqueue! job.run! end @@ -31,13 +31,10 @@ module Projects deployment = create_pages_deployment(artifacts_path, build) break error('The uploaded artifact size does not match the expected value') unless deployment + break error(deployment_update.errors.first.full_message) unless deployment_update.valid? - if deployment_update.valid? - update_project_pages_deployment(deployment) - success - else - error(deployment_update.errors.first.full_message) - end + update_project_pages_deployment(deployment) + success end rescue StandardError => e error(e.message) @@ -47,7 +44,7 @@ module Projects private def success - @commit_status.success + commit_status.success @project.mark_pages_as_deployed publish_deployed_event super @@ -56,15 +53,14 @@ module Projects def error(message) register_failure log_error("Projects::UpdatePagesService: #{message}") - @commit_status.allow_failure = !deployment_update.latest? - @commit_status.description = message - @commit_status.drop(:script_failure) + commit_status.allow_failure = !deployment_update.latest? + commit_status.description = message + commit_status.drop(:script_failure) super end - def build_commit_status - stage = create_stage - + # Create status notifying the deployment of pages + def commit_status GenericCommitStatus.new( user: build.user, ci_stage: stage, @@ -73,26 +69,22 @@ module Projects stage_idx: stage.position ) end + strong_memoize_attr :commit_status # rubocop: disable Performance/ActiveRecordSubtransactionMethods - def create_stage + def stage build.pipeline.stages.safe_find_or_create_by(name: 'deploy', pipeline_id: build.pipeline.id) do |stage| stage.position = GenericCommitStatus::EXTERNAL_STAGE_IDX stage.project = build.project end end + strong_memoize_attr :commit_status # rubocop: enable Performance/ActiveRecordSubtransactionMethods def create_pages_deployment(artifacts_path, build) - sha256 = build.job_artifacts_archive.file_sha256 File.open(artifacts_path) do |file| - deployment = project.pages_deployments.create!( - file: file, - file_count: deployment_update.entries_count, - file_sha256: sha256, - ci_build_id: build.id, - root_directory: build.options[:publish] - ) + attributes = pages_deployment_attributes(file, build) + deployment = project.pages_deployments.create!(**attributes) break if deployment.size != file.size || deployment.file.size != file.size @@ -100,21 +92,28 @@ module Projects end end + # overridden on EE + def pages_deployment_attributes(file, build) + { + file: file, + file_count: deployment_update.entries_count, + file_sha256: build.job_artifacts_archive.file_sha256, + ci_build_id: build.id, + root_directory: build.options[:publish] + } + end + def update_project_pages_deployment(deployment) project.update_pages_deployment!(deployment) + + PagesDeployment.deactivate_deployments_older_than( + deployment, + time: OLD_DEPLOYMENTS_DESTRUCTION_DELAY.from_now) + DestroyPagesDeploymentsWorker.perform_in( OLD_DEPLOYMENTS_DESTRUCTION_DELAY, project.id, - deployment.id - ) - end - - def ref - build.ref - end - - def artifacts - build.artifacts_file.path + deployment.id) end def register_attempt @@ -126,12 +125,14 @@ module Projects end def pages_deployments_total_counter - @pages_deployments_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_total, "Counter of GitLab Pages deployments triggered") + Gitlab::Metrics.counter(:pages_deployments_total, "Counter of GitLab Pages deployments triggered") end + strong_memoize_attr :pages_deployments_total_counter def pages_deployments_failed_total_counter - @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed") + Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed") end + strong_memoize_attr :pages_deployments_failed_total_counter def publish_deployed_event event = ::Pages::PageDeployedEvent.new(data: { @@ -144,3 +145,5 @@ module Projects end end end + +::Projects::UpdatePagesService.prepend_mod diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index f5f6bb85995..799ae5677c3 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -80,8 +80,8 @@ module Projects end def pool_repository_exists_for?(shard_name:, pool_repository:) - PoolRepository.by_source_project_and_shard_name( - pool_repository.source_project, + PoolRepository.by_disk_path_and_shard_name( + pool_repository.disk_path, shard_name ).exists? end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 8639e2f833f..e5e39247dbf 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -53,13 +53,7 @@ module Projects def add_pages_unique_domain return unless params.dig(:project_setting_attributes, :pages_unique_domain_enabled) - # If the project used a unique domain once, it'll always use the same - return if project.project_setting.pages_unique_domain_in_database.present? - - params[:project_setting_attributes][:pages_unique_domain] = Gitlab::Pages::RandomDomain.generate( - project_path: project.path, - namespace_path: project.parent.full_path - ) + Gitlab::Pages.add_unique_domain_to(project) end def validate! @@ -112,6 +106,7 @@ module Projects # overridden by EE module end + # overridden by EE module def remove_unallowed_params params.delete(:emails_enabled) unless can?(current_user, :set_emails_disabled, project) @@ -119,11 +114,11 @@ module Projects end def after_update - todos_features_changes = %w( + todos_features_changes = %w[ issues_access_level merge_requests_access_level repository_access_level - ) + ] project_changed_feature_keys = project.project_feature.previous_changes.keys if project.visibility_level_previous_changes && project.private? diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb index ff2b3a7bd18..41b421662ef 100644 --- a/app/services/releases/destroy_service.rb +++ b/app/services/releases/destroy_service.rb @@ -7,6 +7,8 @@ module Releases return error(_('Access Denied'), 403) unless allowed? if release.destroy + update_catalog_resource! + success(tag: existing_tag, release: release) else error(release.errors.messages || '400 Bad request', 400) @@ -15,6 +17,14 @@ module Releases private + def update_catalog_resource! + return unless project.catalog_resource + + return unless project.catalog_resource.versions.none? + + project.catalog_resource.update!(state: 'draft') + end + def allowed? Ability.allowed?(current_user, :destroy_release, release) end diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb index b262b4a1f7b..bf7ac2e5fd8 100644 --- a/app/services/repositories/base_service.rb +++ b/app/services/repositories/base_service.rb @@ -29,7 +29,7 @@ class Repositories::BaseService < BaseService end def move_error(path) - error = %{Repository "#{path}" could not be moved} + error = %(Repository "#{path}" could not be moved) log_error(error) error(error) diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb index 0fb7dfdb85f..4258898c665 100644 --- a/app/services/repository_archive_clean_up_service.rb +++ b/app/services/repository_archive_clean_up_service.rb @@ -37,7 +37,7 @@ class RepositoryArchiveCleanUpService private def clean_up_old_archives - run(%W(find #{path} -mindepth 1 -maxdepth #{MAX_ARCHIVE_DEPTH} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -mmin +#{mmin} -delete)) + run(%W[find #{path} -mindepth 1 -maxdepth #{MAX_ARCHIVE_DEPTH} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -mmin +#{mmin} -delete]) end def clean_up_empty_directories @@ -45,7 +45,7 @@ class RepositoryArchiveCleanUpService end def clean_up_empty_directories_with_depth(depth) - run(%W(find #{path} -mindepth #{depth} -maxdepth #{depth} -type d -empty -delete)) + run(%W[find #{path} -mindepth #{depth} -maxdepth #{depth} -type d -empty -delete]) end def run(cmd) diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 1fea894a599..1c496aa5e77 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -60,7 +60,7 @@ module ResourceAccessTokens strong_memoize_attr :username_and_email_generator def has_permission_to_create? - %w(project group).include?(resource_type) && can?(current_user, :create_resource_access_tokens, resource) + %w[project group].include?(resource_type) && can?(current_user, :create_resource_access_tokens, resource) end def create_user diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb index 2aaf4cc31d2..46c71b04632 100644 --- a/app/services/resource_access_tokens/revoke_service.rb +++ b/app/services/resource_access_tokens/revoke_service.rb @@ -38,7 +38,7 @@ module ResourceAccessTokens end def can_destroy_token? - %w(project group).include?(resource.class.name.downcase) && can?(current_user, :destroy_resource_access_tokens, resource) + %w[project group].include?(resource.class.name.downcase) && can?(current_user, :destroy_resource_access_tokens, resource) end def find_member diff --git a/app/services/resource_events/base_change_timebox_service.rb b/app/services/resource_events/base_change_timebox_service.rb index ba7c9d90713..d0b0f635ed2 100644 --- a/app/services/resource_events/base_change_timebox_service.rb +++ b/app/services/resource_events/base_change_timebox_service.rb @@ -14,7 +14,7 @@ module ResourceEvents track_event - resource.expire_note_etag_cache + resource.broadcast_notes_changed end private diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 69e68922b91..f0ebf7fb40b 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -31,7 +31,7 @@ module ResourceEvents end create_timeline_events_from(added_labels: added_labels, removed_labels: removed_labels) - resource.expire_note_etag_cache + resource.broadcast_notes_changed return unless resource.is_a?(Issue) diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb index a396f7a1907..303d666c8e2 100644 --- a/app/services/resource_events/change_state_service.rb +++ b/app/services/resource_events/change_state_service.rb @@ -23,7 +23,7 @@ module ResourceEvents created_at: resource.system_note_timestamp ) - resource.expire_note_etag_cache + resource.broadcast_notes_changed end private diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index f4c0a743ef0..24549b1498b 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -6,7 +6,7 @@ module Search include Gitlab::Utils::StrongMemoize DEFAULT_SCOPE = 'projects' - ALLOWED_SCOPES = %w(projects issues merge_requests milestones users).freeze + ALLOWED_SCOPES = %w[projects issues merge_requests milestones users].freeze attr_accessor :current_user, :params diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 73d46a9ba70..24613dc2564 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -5,7 +5,7 @@ module Search include Search::Filter include Gitlab::Utils::StrongMemoize - ALLOWED_SCOPES = %w(blobs issues merge_requests wiki_blobs commits notes milestones users).freeze + ALLOWED_SCOPES = %w[blobs issues merge_requests wiki_blobs commits notes milestones users].freeze attr_accessor :project, :current_user, :params diff --git a/app/services/service_desk/custom_email_verifications/base_service.rb b/app/services/service_desk/custom_email_verifications/base_service.rb index fe456e4d3f3..e92700022f1 100644 --- a/app/services/service_desk/custom_email_verifications/base_service.rb +++ b/app/services/service_desk/custom_email_verifications/base_service.rb @@ -3,6 +3,8 @@ module ServiceDesk module CustomEmailVerifications class BaseService < ::BaseProjectService + include ::ServiceDesk::CustomEmails::Logger + attr_reader :settings def initialize(project:, current_user: nil, params: {}) @@ -35,15 +37,21 @@ module ServiceDesk end def error_response(message) + log_warning(error_message: message) ServiceResponse.error(message: message) end def error_not_verified(error_identifier) + log_info(error_message: error_identifier.to_s) ServiceResponse.error( message: _('ServiceDesk|Custom email address could not be verified.'), reason: error_identifier.to_s ) end + + def log_category + 'custom_email_verification' + end end end end diff --git a/app/services/service_desk/custom_email_verifications/create_service.rb b/app/services/service_desk/custom_email_verifications/create_service.rb index db518bfdf24..9c5721446a1 100644 --- a/app/services/service_desk/custom_email_verifications/create_service.rb +++ b/app/services/service_desk/custom_email_verifications/create_service.rb @@ -17,6 +17,7 @@ module ServiceDesk if ramp_up_error handle_error_case else + log_info ServiceResponse.success end end @@ -63,11 +64,11 @@ module ServiceDesk end def error_settings_missing - error_response(_('ServiceDesk|Service Desk setting missing')) + error_response(s_('ServiceDesk|Service Desk setting missing')) end def error_user_not_authorized - error_response(_('ServiceDesk|User cannot manage project.')) + error_response(s_('ServiceDesk|User cannot manage project.')) end end end diff --git a/app/services/service_desk/custom_email_verifications/update_service.rb b/app/services/service_desk/custom_email_verifications/update_service.rb index 813624cde23..5ef36ce0576 100644 --- a/app/services/service_desk/custom_email_verifications/update_service.rb +++ b/app/services/service_desk/custom_email_verifications/update_service.rb @@ -24,6 +24,7 @@ module ServiceDesk else verification.mark_as_finished! + log_info ServiceResponse.success end end @@ -75,15 +76,15 @@ module ServiceDesk end def error_parameter_missing - error_response(_('ServiceDesk|Service Desk setting or verification object missing')) + error_response(s_('ServiceDesk|Service Desk setting or verification object missing')) end def error_already_finished - error_response(_('ServiceDesk|Custom email address has already been verified.')) + error_response(s_('ServiceDesk|Custom email address has already been verified.')) end def error_already_failed - error_response(_('ServiceDesk|Custom email address verification has already been processed and failed.')) + error_response(s_('ServiceDesk|Custom email address verification has already been processed and failed.')) end end end diff --git a/app/services/service_desk/custom_emails/base_service.rb b/app/services/service_desk/custom_emails/base_service.rb index 62152f31012..91f4100a8ca 100644 --- a/app/services/service_desk/custom_emails/base_service.rb +++ b/app/services/service_desk/custom_emails/base_service.rb @@ -3,6 +3,8 @@ module ServiceDesk module CustomEmails class BaseService < ::BaseProjectService + include Logger + private def legitimate_user? @@ -34,6 +36,7 @@ module ServiceDesk end def error_response(message) + log_warning(error_message: message) ServiceResponse.error(message: message) end end diff --git a/app/services/service_desk/custom_emails/create_service.rb b/app/services/service_desk/custom_emails/create_service.rb index c3ca98a0259..305f5b3fa11 100644 --- a/app/services/service_desk/custom_emails/create_service.rb +++ b/app/services/service_desk/custom_emails/create_service.rb @@ -25,6 +25,7 @@ module ServiceDesk # we don't use its response here. create_verification + log_info ServiceResponse.success end diff --git a/app/services/service_desk/custom_emails/destroy_service.rb b/app/services/service_desk/custom_emails/destroy_service.rb index 1aa5994edd8..abbe39646aa 100644 --- a/app/services/service_desk/custom_emails/destroy_service.rb +++ b/app/services/service_desk/custom_emails/destroy_service.rb @@ -13,6 +13,7 @@ module ServiceDesk project.reset project.service_desk_setting&.update!(custom_email: nil, custom_email_enabled: false) + log_info ServiceResponse.success end diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb index 61cb6fce11f..182022beb1d 100644 --- a/app/services/service_desk_settings/update_service.rb +++ b/app/services/service_desk_settings/update_service.rb @@ -2,12 +2,19 @@ module ServiceDeskSettings class UpdateService < BaseService + include ::ServiceDesk::CustomEmails::Logger + def execute settings = ServiceDeskSetting.safe_find_or_create_by!(project_id: project.id) params[:project_key] = nil if params[:project_key].blank? + # We want to know when custom email got enabled + write_log_message = params[:custom_email_enabled].present? && !settings.custom_email_enabled? + if settings.update(params) + log_info if write_log_message + ServiceResponse.success else ServiceResponse.error(message: settings.errors.full_messages.to_sentence) diff --git a/app/services/service_response.rb b/app/services/service_response.rb index 86efc01bd30..fbc5660315b 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -2,18 +2,22 @@ class ServiceResponse def self.success(message: nil, payload: {}, http_status: :ok) - new(status: :success, - message: message, - payload: payload, - http_status: http_status) + new( + status: :success, + message: message, + payload: payload, + http_status: http_status + ) end def self.error(message:, payload: {}, http_status: nil, reason: nil) - new(status: :error, - message: message, - payload: payload, - http_status: http_status, - reason: reason) + new( + status: :error, + message: message, + payload: payload, + http_status: http_status, + reason: reason + ) end attr_reader :status, :message, :http_status, :payload, :reason diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 662e31a93aa..8cc6458227f 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -2,7 +2,7 @@ module Snippets class UpdateService < Snippets::BaseService - COMMITTABLE_ATTRIBUTES = %w(file_name content).freeze + COMMITTABLE_ATTRIBUTES = %w[file_name content].freeze UpdateError = Class.new(StandardError) diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb index d31b904f549..fd2b3c9a441 100644 --- a/app/services/spam/akismet_service.rb +++ b/app/services/spam/akismet_service.rb @@ -50,8 +50,10 @@ module Spam attr_accessor :owner_name, :owner_email def akismet_client - @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key, - Gitlab.config.gitlab.url) + @akismet_client ||= ::Akismet::Client.new( + Gitlab::CurrentSettings.akismet_api_key, + Gitlab.config.gitlab.url + ) end def akismet_enabled? diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 5c510990b2d..6ec8d09c37c 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -76,10 +76,9 @@ module Spam spam_verdict_service.execute.tap do |result| case result when BLOCK_USER - # TODO: improve BLOCK_USER handling, non-existent until now - # https://gitlab.com/gitlab-org/gitlab/-/issues/329666 target.spam! create_spam_log + ban_user! when DISALLOW target.spam! create_spam_log @@ -119,6 +118,12 @@ module Spam target.spam_log = spam_log end + def ban_user! + UserCustomAttribute.set_banned_by_spam_log(target.spam_log) + + user.ban! + end + def spam_verdict_service context = { action: action, @@ -131,12 +136,13 @@ module Spam referer: spam_params&.referer } - SpamVerdictService.new(target: target, - user: user, - options: options, - context: context, - extra_features: extra_features - ) + SpamVerdictService.new( + target: target, + user: user, + options: options, + context: context, + extra_features: extra_features + ) end def noteable_type diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 639d99ad906..9efe51b43b8 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -36,16 +36,17 @@ module Spam # The target can override the verdict via the `allow_possible_spam` application setting final_verdict = OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM if override_via_allow_possible_spam?(verdict: final_verdict) - logger.info(class: self.class.name, - akismet_verdict: akismet_verdict, - spam_check_verdict: spamcheck_verdict, - spam_check_rtt: external_spam_check_round_trip_time.real, - final_verdict: final_verdict, - username: user.username, - user_id: user.id, - target_type: target.class.to_s, - project_id: target.project_id - ) + logger.info( + class: self.class.name, + akismet_verdict: akismet_verdict, + spam_check_verdict: spamcheck_verdict, + spam_check_rtt: external_spam_check_round_trip_time.real, + final_verdict: final_verdict, + username: user.username, + user_id: user.id, + target_type: target.class.to_s, + project_id: target.project_id + ) final_verdict end diff --git a/app/services/submodules/update_service.rb b/app/services/submodules/update_service.rb index a6011a920bd..4a573e595ce 100644 --- a/app/services/submodules/update_service.rb +++ b/app/services/submodules/update_service.rb @@ -26,11 +26,13 @@ module Submodules end def create_commit! - repository.update_submodule(current_user, - @submodule, - @commit_sha, - message: @commit_message, - branch: @branch_name) + repository.update_submodule( + current_user, + @submodule, + @commit_sha, + message: @commit_message, + branch: @branch_name + ) rescue ArgumentError, TypeError raise ValidationError, 'Invalid parameters' end diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb index 239cd86e0ec..d7c261f1c25 100644 --- a/app/services/suggestions/create_service.rb +++ b/app/services/suggestions/create_service.rb @@ -9,20 +9,22 @@ module Suggestions def execute return unless @note.supports_suggestion? - suggestions = Gitlab::Diff::SuggestionsParser.parse(@note.note, - project: @note.project, - position: @note.position) + suggestions = Gitlab::Diff::SuggestionsParser.parse( + @note.note, + project: @note.project, + position: @note.position + ) - rows = - suggestions.map.with_index do |suggestion, index| - creation_params = - suggestion.to_hash.slice(:from_content, - :to_content, - :lines_above, - :lines_below) + rows = suggestions.map.with_index do |suggestion, index| + creation_params = suggestion.to_hash.slice( + :from_content, + :to_content, + :lines_above, + :lines_below + ) - creation_params.merge!(note_id: @note.id, relative_order: index) - end + creation_params.merge!(note_id: @note.id, relative_order: index) + end rows.in_groups_of(100, false) do |rows| ApplicationRecord.legacy_bulk_insert('suggestions', rows) # rubocop:disable Gitlab/BulkInsert diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb index 994e3174668..1a1dd84491a 100644 --- a/app/services/system_notes/alert_management_service.rb +++ b/app/services/system_notes/alert_management_service.rb @@ -14,7 +14,7 @@ module SystemNotes def create_new_alert(monitoring_tool) body = "logged an alert from **#{monitoring_tool}**" - create_note(NoteSummary.new(noteable, project, User.alert_bot, body, action: 'new_alert_added')) + create_note(NoteSummary.new(noteable, project, Users::Internal.alert_bot, body, action: 'new_alert_added')) end # Called when the status of an AlertManagement::Alert has changed @@ -61,7 +61,7 @@ module SystemNotes def log_resolving_alert(monitoring_tool) body = "logged a recovery alert from **#{monitoring_tool}**" - create_note(NoteSummary.new(noteable, project, User.alert_bot, body, action: 'new_alert_added')) + create_note(NoteSummary.new(noteable, project, Users::Internal.alert_bot, body, action: 'new_alert_added')) end end end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 61a4316e8ae..04ae734a8fe 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -32,8 +32,7 @@ module SystemNotes # # Returns the created Note object def relate_issuable(noteable_ref) - issuable_type = noteable.to_ability_name.humanize(capitalize: false) - body = "marked this #{issuable_type} as related to #{noteable_ref.to_reference(noteable.resource_parent)}" + body = "marked this #{noteable_name} as related to #{noteable_ref.to_reference(noteable.resource_parent)}" track_issue_event(:track_issue_related_action) @@ -351,12 +350,12 @@ module SystemNotes # Returns the created Note object def change_issue_confidentiality if noteable.confidential - body = 'made the issue confidential' + body = "made the #{noteable_name} confidential" action = 'confidential' track_issue_event(:track_issue_made_confidential_action) else - body = 'made the issue visible to everyone' + body = "made the #{noteable_name} visible to everyone" action = 'visible' track_issue_event(:track_issue_made_visible_action) @@ -534,6 +533,12 @@ module SystemNotes issue_activity_counter.public_send(event_name, author: author, project: project || noteable.project) # rubocop: disable GitlabSecurity/PublicSend end + + def noteable_name + name = noteable.try(:issue_type) || noteable.to_ability_name + + name.humanize(capitalize: false) + end end end diff --git a/app/services/todos/destroy/destroyed_issuable_service.rb b/app/services/todos/destroy/destroyed_issuable_service.rb index 759c430ec7a..6ba286458df 100644 --- a/app/services/todos/destroy/destroyed_issuable_service.rb +++ b/app/services/todos/destroy/destroyed_issuable_service.rb @@ -8,7 +8,7 @@ module Todos # Since we are moving towards work items, in some instances we create todos with # `target_type: WorkItem` in other instances we still create todos with `target_type: Issue` # So when an issue/work item is deleted, we just make sure to delete todos for both target types - BOUND_TARGET_TYPES = %w(Issue WorkItem).freeze + BOUND_TARGET_TYPES = %w[Issue WorkItem].freeze def initialize(target_id, target_type) @target_id = target_id diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb index 5b04d2fd3af..387c5ce063a 100644 --- a/app/services/todos/destroy/entity_leave_service.rb +++ b/app/services/todos/destroy/entity_leave_service.rb @@ -8,7 +8,7 @@ module Todos attr_reader :user, :entity def initialize(user_id, entity_id, entity_type) - unless %w(Group Project).include?(entity_type) + unless %w[Group Project].include?(entity_type) raise ArgumentError, "#{entity_type} is not an entity user can leave" end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 24aa4aa1061..b490df6a134 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -30,11 +30,9 @@ module Users return if Gitlab::Database.read_only? today = Date.today - return if user.last_activity_on == today - lease = Gitlab::ExclusiveLease.new("activity_service:#{user.id}", - timeout: LEASE_TIMEOUT) + lease = Gitlab::ExclusiveLease.new("activity_service:#{user.id}", timeout: LEASE_TIMEOUT) return unless lease.try_obtain user.update_attribute(:last_activity_on, today) diff --git a/app/services/users/authorized_build_service.rb b/app/services/users/authorized_build_service.rb index 5029105b087..446c897fe5a 100644 --- a/app/services/users/authorized_build_service.rb +++ b/app/services/users/authorized_build_service.rb @@ -12,7 +12,7 @@ module Users end def signup_params - super + [:skip_confirmation] + super + [:skip_confirmation, :external] end end end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 04a11f41eb1..b51684c6899 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -5,8 +5,8 @@ module Users ALLOWED_USER_TYPES = %i[project_bot security_policy_bot].freeze delegate :user_default_internal_regex_enabled?, - :user_default_internal_regex_instance, - to: :'Gitlab::CurrentSettings.current_application_settings' + :user_default_internal_regex_instance, + to: :'Gitlab::CurrentSettings.current_application_settings' def initialize(current_user, params = {}) @current_user = current_user diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index d4c00a4dcec..a0e1167836b 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -59,9 +59,6 @@ module Users Groups::DestroyService.new(group, current_user).execute end - namespace = user.namespace - namespace.prepare_for_destroy - user.personal_projects.each do |project| success = ::Projects::DestroyService.new(project, current_user).execute raise DestroyError, "Project #{project.id} can't be deleted" unless success @@ -70,9 +67,11 @@ module Users yield(user) if block_given? hard_delete = options.fetch(:hard_delete, false) - Users::GhostUserMigration.create!(user: user, - initiator_user: current_user, - hard_delete: hard_delete) + Users::GhostUserMigration.create!( + user: user, + initiator_user: current_user, + hard_delete: hard_delete + ) update_metrics end diff --git a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb index d294312cc30..e05f308343d 100644 --- a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb +++ b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb @@ -12,9 +12,11 @@ module Users ghost_user_migrations.each do |job| break if execution_tracker.over_limit? - service = Users::MigrateRecordsToGhostUserService.new(job.user, - job.initiator_user, - execution_tracker) + service = Users::MigrateRecordsToGhostUserService.new( + job.user, + job.initiator_user, + execution_tracker + ) service.execute(hard_delete: job.hard_delete) rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError # no-op diff --git a/app/services/users/migrate_records_to_ghost_user_service.rb b/app/services/users/migrate_records_to_ghost_user_service.rb index 5d518803315..06950292fea 100644 --- a/app/services/users/migrate_records_to_ghost_user_service.rb +++ b/app/services/users/migrate_records_to_ghost_user_service.rb @@ -18,7 +18,7 @@ module Users @user = user @initiator_user = initiator_user @execution_tracker = execution_tracker - @ghost_user = User.ghost + @ghost_user = Users::Internal.ghost end def execute(hard_delete: false) diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 197260a80ca..32acc3f170d 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -82,15 +82,17 @@ module Users attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback def log_refresh_details(remove, add) - Gitlab::AppJsonLogger.info(event: 'authorized_projects_refresh', - user_id: user.id, - 'authorized_projects_refresh.source': source, - 'authorized_projects_refresh.rows_deleted_count': remove.length, - 'authorized_projects_refresh.rows_added_count': add.length, - # most often there's only a few entries in remove and add, but limit it to the first 5 - # entries to avoid flooding the logs - 'authorized_projects_refresh.rows_deleted_slice': remove.first(5), - 'authorized_projects_refresh.rows_added_slice': add.first(5).map(&:values)) + Gitlab::AppJsonLogger.info( + event: 'authorized_projects_refresh', + user_id: user.id, + 'authorized_projects_refresh.source': source, + 'authorized_projects_refresh.rows_deleted_count': remove.length, + 'authorized_projects_refresh.rows_added_count': add.length, + # most often there's only a few entries in remove and add, but limit it to the first 5 + # entries to avoid flooding the logs + 'authorized_projects_refresh.rows_deleted_slice': remove.first(5), + 'authorized_projects_refresh.rows_added_slice': add.first(5).map(&:values) + ) end end end diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb index 61cf598f178..62df676db25 100644 --- a/app/services/users/upsert_credit_card_validation_service.rb +++ b/app/services/users/upsert_credit_card_validation_service.rb @@ -7,8 +7,10 @@ module Users end def execute + user_id = params.fetch(:user_id) + @params = { - user_id: params.fetch(:user_id), + user_id: user_id, credit_card_validated_at: params.fetch(:credit_card_validated_at), expiration_date: get_expiration_date(params), last_digits: Integer(params.fetch(:credit_card_mask_number), 10), @@ -16,7 +18,9 @@ module Users holder_name: params.fetch(:credit_card_holder_name) } - ::Users::CreditCardValidation.upsert(@params) + credit_card = Users::CreditCardValidation.find_or_initialize_by_user(user_id) + + credit_card.update(@params.except(:user_id)) ServiceResponse.success(message: 'CreditCardValidation was set') rescue ActiveRecord::InvalidForeignKey, ActiveRecord::NotNullViolation => e diff --git a/app/services/webauthn/authenticate_service.rb b/app/services/webauthn/authenticate_service.rb index 52437a77df8..7855b509595 100644 --- a/app/services/webauthn/authenticate_service.rb +++ b/app/services/webauthn/authenticate_service.rb @@ -40,8 +40,8 @@ module Webauthn # (which is done in #verify_webauthn_credential) def validate_webauthn_credential(webauthn_credential) webauthn_credential.type == WebAuthn::TYPE_PUBLIC_KEY && - webauthn_credential.raw_id && webauthn_credential.id && - webauthn_credential.raw_id == WebAuthn.standard_encoder.decode(webauthn_credential.id) + webauthn_credential.raw_id && webauthn_credential.id && + webauthn_credential.raw_id == WebAuthn.standard_encoder.decode(webauthn_credential.id) end ## @@ -53,9 +53,10 @@ module Webauthn rp_id = webauthn_credential.client_extension_outputs['appid'] ? WebAuthn.configuration.origin : URI(WebAuthn.configuration.origin).host webauthn_credential.response.verify( encoder.decode(challenge), - public_key: encoder.decode(stored_credential.public_key), - sign_count: stored_credential.counter, - rp_id: rp_id) + public_key: encoder.decode(stored_credential.public_key), + sign_count: stored_credential.counter, + rp_id: rp_id + ) end end end diff --git a/app/services/work_items/callbacks/award_emoji.rb b/app/services/work_items/callbacks/award_emoji.rb index 6344813d4b9..9ff5b6d049d 100644 --- a/app/services/work_items/callbacks/award_emoji.rb +++ b/app/services/work_items/callbacks/award_emoji.rb @@ -15,7 +15,8 @@ module WorkItems def execute_emoji_service(action, name) class_name = { add: ::AwardEmojis::AddService, - remove: ::AwardEmojis::DestroyService + remove: ::AwardEmojis::DestroyService, + toggle: ::AwardEmojis::ToggleService } raise_error(invalid_action_error(action)) unless class_name.key?(action) diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index 903736cf662..354a33a0384 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -32,8 +32,11 @@ module WorkItems end def before_create(work_item) - execute_widgets(work_item: work_item, callback: :before_create_callback, - widget_params: @widget_params) + execute_widgets( + work_item: work_item, + callback: :before_create_callback, + widget_params: @widget_params + ) super end @@ -41,8 +44,11 @@ module WorkItems def transaction_create(work_item) super.tap do |save_result| if save_result - execute_widgets(work_item: work_item, callback: :after_create_in_transaction, - widget_params: @widget_params) + execute_widgets( + work_item: work_item, + callback: :after_create_in_transaction, + widget_params: @widget_params + ) end end end diff --git a/app/services/work_items/related_work_item_links/create_service.rb b/app/services/work_items/related_work_item_links/create_service.rb index 6a9ddd5c83d..f313881470a 100644 --- a/app/services/work_items/related_work_item_links/create_service.rb +++ b/app/services/work_items/related_work_item_links/create_service.rb @@ -25,7 +25,7 @@ module WorkItems end def previous_related_issuables - @related_issues ||= issuable.related_issues(current_user).to_a + @related_issues ||= issuable.linked_work_items(authorize: false).to_a end private diff --git a/app/services/work_items/related_work_item_links/destroy_service.rb b/app/services/work_items/related_work_item_links/destroy_service.rb new file mode 100644 index 00000000000..6d1920d01b2 --- /dev/null +++ b/app/services/work_items/related_work_item_links/destroy_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module WorkItems + module RelatedWorkItemLinks + class DestroyService < BaseService + def initialize(work_item, user, params) + @work_item = work_item + @current_user = user + @params = params.dup + @failed_ids = [] + @removed_ids = [] + end + + def execute + return error(_('No work item found.'), 403) unless can?(current_user, :admin_work_item_link, work_item) + return error(_('No work item IDs provided.'), 409) if params[:item_ids].empty? + + destroy_links_for(params[:item_ids]) + + if removed_ids.any? + success(message: response_message, items_removed: removed_ids, items_with_errors: failed_ids.flatten) + else + error(error_message) + end + end + + private + + attr_reader :work_item, :current_user, :failed_ids, :removed_ids + + def destroy_links_for(item_ids) + destroy_links(source: work_item, target: item_ids, direction: :target) + destroy_links(source: item_ids, target: work_item, direction: :source) + end + + def destroy_links(source:, target:, direction:) + WorkItems::RelatedWorkItemLink.for_source_and_target(source, target).each do |link| + linked_item = link.try(direction) + + if can?(current_user, :admin_work_item_link, linked_item) + link.destroy! + removed_ids << linked_item.id + create_notes(link) + else + failed_ids << linked_item.id + end + end + end + + def create_notes(link) + SystemNoteService.unrelate_issuable(link.source, link.target, current_user) + SystemNoteService.unrelate_issuable(link.target, link.source, current_user) + end + + def error_message + not_linked = params[:item_ids] - (removed_ids + failed_ids) + error_messages = [] + + if failed_ids.any? + error_messages << format( + _('%{item_ids} could not be removed due to insufficient permissions'), item_ids: failed_ids.to_sentence + ) + end + + if not_linked.any? + error_messages << format( + _('%{item_ids} could not be removed due to not being linked'), item_ids: not_linked.to_sentence + ) + end + + return '' unless error_messages.any? + + format(_('IDs with errors: %{error_messages}.'), error_messages: error_messages.join(', ')) + end + + def response_message + success_message = format(_('Successfully unlinked IDs: %{item_ids}.'), item_ids: removed_ids.to_sentence) + + return success_message unless error_message.present? + + "#{success_message} #{error_message}" + end + end + end +end diff --git a/app/uploaders/design_management/design_v432x230_uploader.rb b/app/uploaders/design_management/design_v432x230_uploader.rb index 975050c26e4..0f1ebfed4aa 100644 --- a/app/uploaders/design_management/design_v432x230_uploader.rb +++ b/app/uploaders/design_management/design_v432x230_uploader.rb @@ -20,7 +20,7 @@ module DesignManagement # # We currently choose not to resize `image/svg+xml` for security reasons. # See https://gitlab.com/gitlab-org/gitlab/issues/207740#note_302766171 - MIME_TYPE_ALLOWLIST = %w(image/png image/jpeg image/bmp image/gif).freeze + MIME_TYPE_ALLOWLIST = %w[image/png image/jpeg image/bmp image/gif].freeze process resize_to_fit: [432, 230] diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 87a624ddb60..c28f0893c56 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -20,8 +20,8 @@ class FileUploader < GitlabUploader '!?\[.*?\]\(/uploads/(?P<secret>[0-9a-f]{32})/(?P<file>.*?)\)' ) - DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\b(\h{10}|\h{32}))\/(?<identifier>.*)}.freeze - VALID_SECRET_PATTERN = %r{\A\h{10,32}\z}.freeze + DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\b(\h{10}|\h{32}))\/(?<identifier>.*)} + VALID_SECRET_PATTERN = %r{\A\h{10,32}\z} InvalidSecret = Class.new(StandardError) diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index 06bf742a22d..c1ca535b336 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -5,7 +5,7 @@ class GitlabUploader < CarrierWave::Uploader::Base class_attribute :storage_location_identifier - PROTECTED_METHODS = %i(filename cache_dir work_dir store_dir).freeze + PROTECTED_METHODS = %i[filename cache_dir work_dir store_dir].freeze ObjectNotReadyError = Class.new(StandardError) diff --git a/app/uploaders/packages/nuget/symbol_uploader.rb b/app/uploaders/packages/nuget/symbol_uploader.rb new file mode 100644 index 00000000000..1d6ec9a8de8 --- /dev/null +++ b/app/uploaders/packages/nuget/symbol_uploader.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SymbolUploader < GitlabUploader + include ObjectStorage::Concern + + storage_location :packages + + alias_method :upload, :model + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + raise ObjectNotReadyError, 'Packages::Nuget::Symbol model not ready' unless model.object_storage_key + + model.object_storage_key + end + end + end +end diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb index 3e6ec0b6f29..6dcc089fa73 100644 --- a/app/validators/addressable_url_validator.rb +++ b/app/validators/addressable_url_validator.rb @@ -51,7 +51,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator # tasks that uses that url won't work. # See https://gitlab.com/gitlab-org/gitlab-foss/issues/66723 BLOCKER_VALIDATE_OPTIONS = { - schemes: %w(http https), + schemes: %w[http https], ports: [], allow_localhost: true, allow_local_network: true, diff --git a/app/validators/certificate_fingerprint_validator.rb b/app/validators/certificate_fingerprint_validator.rb index 79d78653ec7..c4e317c3e79 100644 --- a/app/validators/certificate_fingerprint_validator.rb +++ b/app/validators/certificate_fingerprint_validator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class CertificateFingerprintValidator < ActiveModel::EachValidator - FINGERPRINT_PATTERN = /\A([a-zA-Z0-9]{2}[\s\-:]?){16,}\z/.freeze + FINGERPRINT_PATTERN = /\A([a-zA-Z0-9]{2}[\s\-:]?){16,}\z/ def validate_each(record, attribute, value) unless value.try(:match, FINGERPRINT_PATTERN) diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb index defd28d7d3b..bcdcf665cba 100644 --- a/app/validators/duration_validator.rb +++ b/app/validators/duration_validator.rb @@ -12,7 +12,7 @@ # class DurationValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - ChronicDuration.parse(value) + ChronicDuration.parse(value, use_complete_matcher: true) rescue ChronicDuration::DurationParseError if options[:message] record.errors.add(:base, options[:message]) diff --git a/app/validators/gitlab/zoom_url_validator.rb b/app/validators/gitlab/zoom_url_validator.rb index c752cec07c2..d3da3977697 100644 --- a/app/validators/gitlab/zoom_url_validator.rb +++ b/app/validators/gitlab/zoom_url_validator.rb @@ -8,7 +8,7 @@ module Gitlab # @example usage # validates :url, 'gitlab/zoom_url': true class ZoomUrlValidator < ActiveModel::EachValidator - ALLOWED_SCHEMES = %w(https).freeze + ALLOWED_SCHEMES = %w[https].freeze def validate_each(record, attribute, value) links_count = Gitlab::ZoomLinkExtractor.new(value).links.size diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb index 9c246a114f6..2ef011df73e 100644 --- a/app/validators/json_schema_validator.rb +++ b/app/validators/json_schema_validator.rb @@ -10,9 +10,9 @@ # end # class JsonSchemaValidator < ActiveModel::EachValidator - FILENAME_ALLOWED = /\A[a-z0-9_-]*\Z/.freeze + FILENAME_ALLOWED = /\A[a-z0-9_-]*\Z/ FilenameError = Class.new(StandardError) - BASE_DIRECTORY = %w(app validators json_schemas).freeze + BASE_DIRECTORY = %w[app validators json_schemas].freeze def initialize(options) raise ArgumentError, "Expected 'filename' as an argument" unless options[:filename] diff --git a/app/validators/json_schemas/pinned_nav_items.json b/app/validators/json_schemas/pinned_nav_items.json index 60dee5cc463..aaeb2fe8bda 100644 --- a/app/validators/json_schemas/pinned_nav_items.json +++ b/app/validators/json_schemas/pinned_nav_items.json @@ -16,6 +16,13 @@ "type": "string" }, "uniqueItems": true + }, + "organization": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true } }, "additionalProperties": false 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 new file mode 100644 index 00000000000..5a81c61ede4 --- /dev/null +++ b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Scan result policy project_approval_settings", + "type": "object", + "properties": { + "prevent_approval_by_author": { + "type": "boolean" + }, + "prevent_approval_by_commit_author": { + "type": "boolean" + }, + "remove_approvals_with_new_commit": { + "type": "boolean" + }, + "require_password_to_approve": { + "type": "boolean" + }, + "block_unprotecting_branches": { + "type": "boolean" + } + } +} diff --git a/app/validators/line_code_validator.rb b/app/validators/line_code_validator.rb index e1abccc1dff..54f9272a2ae 100644 --- a/app/validators/line_code_validator.rb +++ b/app/validators/line_code_validator.rb @@ -4,7 +4,7 @@ # # Custom validator for GitLab line codes. class LineCodeValidator < ActiveModel::EachValidator - PATTERN = /\A[a-z0-9]+_\d+_\d+\z/.freeze + PATTERN = /\A[a-z0-9]+_\d+_\d+\z/ def validate_each(record, attribute, value) unless PATTERN.match?(value) diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml deleted file mode 100644 index aa5543700a7..00000000000 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -- reporter = abuse_report.reporter -- user = abuse_report.user -%tr - %th.d-block.d-sm-none - %strong= _('User') - %td - - if user - = link_to user.name, user - .light.small - = html_escape(_('Joined %{time_ago}')) % { time_ago: time_ago_with_tooltip(user.created_at).html_safe } - - else - = _('(removed)') - %td - - if reporter - %strong.subheading.d-block.d-sm-none - = _('Reported by %{reporter}').html_safe % { reporter: reporter ? link_to(reporter.name, reporter) : _('(removed)') } - .light.gl-display-none.gl-sm-display-block - = link_to(reporter.name, reporter) - .light.small - = time_ago_with_tooltip(abuse_report.created_at) - - else - = _('(removed)') - %td - %strong.subheading.d-block.d-sm-none - = _('Message') - .message - = markdown_field(abuse_report, :message) - %td - - if user && user != current_user - = render Pajamas::ButtonComponent.new(href: admin_abuse_report_path(abuse_report, remove_user: true), variant: :danger, block: true, button_options: { data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger", remote: true, method: :delete }, class: "js-remove-tr" }) do - = _('Remove user & report') - - if user.blocked? - = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, disabled: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do - = _('Already blocked') - - else - = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do - = _('Block user') - = render Pajamas::ButtonComponent.new(href: [:admin, abuse_report], block: true, button_options: { data: { remote: true, method: :delete }, class: "js-remove-tr" }) do - = _('Remove report') diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml index fee3a846849..ea2d4f3b4af 100644 --- a/app/views/admin/abuse_reports/index.html.haml +++ b/app/views/admin/abuse_reports/index.html.haml @@ -2,35 +2,5 @@ %h1.page-title.gl-font-size-h-display= _('Abuse Reports') -- if Feature.enabled?(:abuse_reports_list) - #js-abuse-reports-list-app{ data: abuse_reports_list_data(@abuse_reports) } - = gl_loading_icon(css_class: 'gl-my-5', size: 'md') -- else - .row-content-block.second-block - = form_tag admin_abuse_reports_path, method: :get, class: 'filter-form' do - .filter-categories.flex-fill - .filter-item.inline - = dropdown_tag(user_dropdown_label(params[:user_id], 'User'), - options: { toggle_class: 'js-filter-submit js-user-search', - title: _('Filter by user'), filter: true, filterInput: 'input#user-search', - dropdown_class: 'dropdown-menu-selectable dropdown-menu-user js-filter-submit', - placeholder: _('Search users'), - data: { current_user: true, field_name: 'user_id' }}) - - .abuse-reports - - if @abuse_reports.present? - .table-holder - %table.table.responsive-table - %thead.d-none.d-md-table-header-group - %tr - %th= _('User') - %th= _('Reported by') - %th.wide= _('Message') - %th= _('Action') - = render @abuse_reports - = paginate @abuse_reports, theme: 'gitlab' - - else - .empty-state - .text-center - %h4= _("There are no abuse reports!") - %h3= emoji_icon('tada') +#js-abuse-reports-list-app{ data: abuse_reports_list_data(@abuse_reports) } + = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index d8d6af606ac..b65649b5a07 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -19,25 +19,6 @@ = f.label :receive_max_input_size, _('Maximum push size (MiB)'), class: 'label-light' = f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' } .form-group - = f.label :max_export_size, _('Maximum export size (MiB)'), class: 'label-light' - = f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' } - %span.form-text.text-muted= _('Set to 0 for no size limit.') - .form-group - = f.label :max_import_size, _('Maximum import size (MiB)'), class: 'label-light' - = f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } - %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.') - .form-group - = f.label :max_import_remote_file_size, s_('Import|Maximum import remote file size (MB)'), class: 'label-light' - = f.number_field :max_import_remote_file_size, class: 'form-control gl-form-input', title: s_('Import|Maximum remote file size for imports from external object storages. For example, AWS S3.'), data: { toggle: 'tooltip', container: 'body' } - %span.form-text.text-muted= _('Set to 0 for no size limit.') - .form-group - = f.label :bulk_import_max_download_file_size, s_('BulkImport|Direct transfer maximum download file size (MB)'), class: 'label-light' - = f.number_field :bulk_import_max_download_file_size, class: 'form-control gl-form-input', title: s_('BulkImport|Maximum download file size when importing from source GitLab instances by direct transfer.'), data: { toggle: 'tooltip', container: 'body' } - .form-group - = f.label :max_decompressed_archive_size, s_('Import|Maximum decompressed size (MiB)'), class: 'label-light' - = f.number_field :max_decompressed_archive_size, class: 'form-control gl-form-input', title: s_('Import|Maximum size of decompressed archive.'), data: { toggle: 'tooltip', container: 'body' } - %span.form-text.text-muted= _('Set to 0 for no size limit.') - .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted#session_expire_delay_help_block= _('Restart GitLab to apply changes.') @@ -53,7 +34,7 @@ .form-group = f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold' - = f.gitlab_ui_checkbox_component :user_oauth_applications, _('Allow users to register any application to use GitLab as an OAuth provider') + = f.gitlab_ui_checkbox_component :user_oauth_applications, _('Allow users to register any application to use GitLab as an OAuth provider. This setting does not affect group-level OAuth applications.') .form-group = f.label :user_default_external, _('New users set to external'), class: 'label-bold' = f.gitlab_ui_checkbox_component :user_default_external, _('Newly-registered users are external by default') diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 2f31eb5f6d1..65049fa5466 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -16,9 +16,6 @@ = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f .form-group - = f.gitlab_ui_checkbox_component :in_product_marketing_emails_enabled, _('Enable in-product marketing emails'), help_text: _('Send emails to help guide new users through the onboarding process.') - - .form-group = f.gitlab_ui_checkbox_component :user_deactivation_emails_enabled, _('Enable user deactivation emails'), help_text: _('Send emails to users upon account deactivation.') - if Feature.enabled?(:deactivation_email_additional_text) diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index 988153d45a4..1f56487cea4 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -6,7 +6,7 @@ = _('Gitpod') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - .gl-text-secondary + .gl-text-secondary.gl-mb-5 #js-gitpod-settings-help-text{ data: {"message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" } } = link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information') diff --git a/app/views/admin/application_settings/_import_and_export.html.haml b/app/views/admin/application_settings/_import_and_export.html.haml new file mode 100644 index 00000000000..8e321406bf8 --- /dev/null +++ b/app/views/admin/application_settings/_import_and_export.html.haml @@ -0,0 +1,43 @@ += gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-import-export-settings'), html: { class: 'fieldset-form', id: 'import-export-settings' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :import_sources, s_('AdminSettings|Import sources'), class: 'label-bold gl-mb-0' + %span.form-text.gl-mt-0.gl-mb-3#import-sources-help + - tag_pair_github_docs = tag_pair(link_to('', help_page_path('integration/github'), target: '_blank', rel: 'noopener noreferrer'), :github_docs_link_start, :github_docs_link_end) + - tag_pair_bitbucket_docs = tag_pair(link_to('', help_page_path('integration/bitbucket'), target: '_blank', rel: 'noopener noreferrer'), :bitbucket_docs_link_start, :bitbucket_docs_link_end) + = safe_format(s_('AdminSettings|Code can be imported from enabled sources during project creation. OmniAuth must be configured for GitHub %{github_docs_link_start}%{icon}%{github_docs_link_end} and Bitbucket %{bitbucket_docs_link_start}%{icon}%{bitbucket_docs_link_end}.'), tag_pair_github_docs, tag_pair_bitbucket_docs, icon: sprite_icon('question-o')) + = hidden_field_tag 'application_setting[import_sources][]' + - import_sources_checkboxes(f).each do |source| + = source + .form-group{ data: { testid: 'project-export' } } + = f.label :project_export, s_('AdminSettings|Project export'), class: 'label-bold' + = f.gitlab_ui_checkbox_component :project_export_enabled, s_('AdminSettings|Enabled') + .form-group{ data: { testid: 'bulk-import' } } + = f.label :bulk_import, s_('AdminSettings|Allow migrating GitLab groups and projects by direct transfer'), class: 'gl-font-weight-bold' + = f.gitlab_ui_checkbox_component :bulk_import_enabled, s_('AdminSettings|Enabled') + .form-group + = f.label :max_export_size, _('Maximum export size (MiB)'), class: 'label-light' + = f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' } + %span.form-text.text-muted= _('Set to 0 for no size limit.') + .form-group + = f.label :max_import_size, _('Maximum import size (MiB)'), class: 'label-light' + = f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } + %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.') + .form-group + = f.label :max_import_remote_file_size, s_('Import|Maximum import remote file size (MiB)'), class: 'label-light' + = f.number_field :max_import_remote_file_size, class: 'form-control gl-form-input', title: s_('Import|Maximum remote file size for imports from external object storages. For example, AWS S3.'), data: { toggle: 'tooltip', container: 'body' } + %span.form-text.text-muted= _('Set to 0 for no size limit.') + .form-group + = f.label :bulk_import_max_download_file_size, s_('BulkImport|Direct transfer maximum download file size (MiB)'), class: 'label-light' + = f.number_field :bulk_import_max_download_file_size, class: 'form-control gl-form-input', title: s_('BulkImport|Maximum download file size when importing from source GitLab instances by direct transfer.'), data: { toggle: 'tooltip', container: 'body' } + .form-group + = f.label :max_decompressed_archive_size, s_('Import|Maximum decompressed file size for archives from imports (MiB)'), class: 'label-light' + = f.number_field :max_decompressed_archive_size, class: 'form-control gl-form-input', title: s_('Import|Maximum size of decompressed archive.'), data: { toggle: 'tooltip', container: 'body' } + %span.form-text.text-muted= _('Set to 0 for no size limit.') + .form-group + = f.label :decompress_archive_file_timeout, s_('Import|Timeout for decompressing archived files (seconds)'), class: 'label-light' + = f.number_field :decompress_archive_file_timeout, class: 'form-control gl-form-input', title: s_('Import|Timeout for decompressing archived files.'), data: { toggle: 'tooltip', container: 'body' } + %span.form-text.text-muted= _('Set to 0 to disable timeout.') + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index 1eb6b747704..d5f2c6afee3 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -19,28 +19,25 @@ = f.label :max_pages_size, _('Maximum size of pages (MiB)'), class: 'label-bold' = f.number_field :max_pages_size, class: 'form-control gl-form-input' .form-text.text-muted - - pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-size-of-gitlab-pages-site-in-a-project') - - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url } - = s_('AdminSettings|Set the maximum size of GitLab Pages per project (0 for unlimited). %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('administration/pages/index', anchor: 'set-maximum-size-of-gitlab-pages-site-in-a-project'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AdminSettings|Set the maximum size of GitLab Pages per project (0 for unlimited). %{link_start}Learn more.%{link_end}'), tag_pair(link, :link_start, :link_end)) .form-group = f.label :max_pages_custom_domains_per_project, s_('AdminSettings|Maximum number of custom domains per project'), class: 'label-bold' = f.number_field :max_pages_custom_domains_per_project, class: 'form-control gl-form-input' .form-text.text-muted - - pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-number-of-gitlab-pages-custom-domains-for-a-project') - - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url } - = s_('AdminSettings|Set the maximum number of GitLab Pages custom domains per project (0 for unlimited). %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('administration/pages/index', anchor: 'set-maximum-number-of-gitlab-pages-custom-domains-for-a-project'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AdminSettings|Set the maximum number of GitLab Pages custom domains per project (0 for unlimited). %{link_start}Learn more.%{link_end}'), tag_pair(link, :link_start, :link_end)) %h5 = s_("AdminSettings|Configure Let's Encrypt") %p - - lets_encrypt_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: "https://letsencrypt.org/" } - = _("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA) that issues digital certificates to enable HTTPS (SSL/TLS) for sites.").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: '</a>'.html_safe } + - link = link_to('', "https://letsencrypt.org/", target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA) that issues digital certificates to enable HTTPS (SSL/TLS) for sites."), tag_pair(link, :lets_encrypt_link_start, :lets_encrypt_link_end)) .form-group = f.label :lets_encrypt_notification_email, s_("AdminSettings|Let's Encrypt email"), class: 'label-bold' = f.text_field :lets_encrypt_notification_email, class: 'form-control gl-form-input' .form-text.text-muted - - pages_link_url = help_page_path('administration/pages/index', anchor: 'lets-encrypt-integration') - - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url } - = s_("AdminSettings|A Let's Encrypt account will be configured for this GitLab instance using this email address. You will receive emails to warn of expiring certificates. %{link_start}Learn more.%{link_end}").html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('administration/pages/index', anchor: 'lets-encrypt-integration'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_("AdminSettings|A Let's Encrypt account will be configured for this GitLab instance using this email address. You will receive emails to warn of expiring certificates. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) .form-group - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path } = f.gitlab_ui_checkbox_component :lets_encrypt_terms_of_service_accepted, diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml index 3a7a951d137..cd17e4bdec3 100644 --- a/app/views/admin/application_settings/_protected_paths.html.haml +++ b/app/views/admin/application_settings/_protected_paths.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.gitlab_ui_checkbox_component :throttle_protected_paths_enabled, - _('Enable rate limiting for POST requests to the specified paths'), + _('Enable rate limiting for requests to the specified paths'), help_text: _('Helps reduce request volume for protected paths.') .form-group = f.label :throttle_protected_paths_requests_per_period, 'Maximum requests per period per user', class: 'label-bold' @@ -14,11 +14,14 @@ = f.number_field :throttle_protected_paths_period_in_seconds, class: 'form-control gl-form-input' .form-group = f.label :protected_paths, class: 'label-bold' do - = _('Paths to protect with rate limiting') + = _('Paths with rate limiting for POST requests') = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control gl-form-input', rows: 10 + .form-group + = f.label :protected_paths_for_get_request, class: 'label-bold' do + = _('Paths with rate limiting for GET requests') + = f.text_area :protected_paths_for_get_request_raw, class: 'form-control gl-form-input', rows: 10 %span.form-text.text-muted - - relative_url_link = 'https://docs.gitlab.com/omnibus/settings/configuration.html#configuring-a-relative-url-for-gitlab' - - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link } - = _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URLs%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } + - link = link_to('', 'https://docs.gitlab.com/omnibus/settings/configuration.html#configuring-a-relative-url-for-gitlab', target: '_blank', rel: 'noopener noreferrer') + = safe_format(_('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URLs%{relative_url_link_end}.'), tag_pair(link, :relative_url_link_start, :relative_url_link_end)) = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml index 396c263dd5d..b318f7e5a20 100644 --- a/app/views/admin/application_settings/_search_limits.html.haml +++ b/app/views/admin/application_settings/_search_limits.html.haml @@ -12,5 +12,11 @@ = f.label :search_rate_limit_unauthenticated, _('Maximum number of requests per minute for an unauthenticated IP address'), class: 'label-bold' = f.number_field :search_rate_limit_unauthenticated, class: 'form-control gl-form-input' + .form-group + = f.label :search_rate_limit_allowlist, _('Users to exclude from the rate limit'), class: 'label-bold' + = f.text_area :search_rate_limit_allowlist_raw, class: 'form-control gl-form-input', rows: 5, aria: { describedBy: 'search-rate-limit-allowlist-field-description' } + .form-text.text-muted{ id: 'search-rate-limit-allowlist-field-description' } + = _('List of users who are allowed to exceed the rate limit. Example: username1, username2') + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml index 20164cfe88d..9f2a40e4e54 100644 --- a/app/views/admin/application_settings/_sentry.html.haml +++ b/app/views/admin/application_settings/_sentry.html.haml @@ -17,4 +17,12 @@ = f.label :sentry_environment, _('Environment'), class: 'label-light' = f.text_field :sentry_environment, class: 'form-control gl-form-input', placeholder: Rails.env + %p.text-muted + = _("Changing any setting bellow doesn't require an application restart") + + %fieldset + .form-group + = f.label :sentry_clientside_traces_sample_rate, _('Clientside traces sample rate'), class: 'label-light' + = f.number_field :sentry_clientside_traces_sample_rate, class: 'form-control gl-form-input', placeholder: '0.5', min: 0, max: 1, step: 0.001 + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_silent_mode_settings_form.html.haml b/app/views/admin/application_settings/_silent_mode_settings_form.html.haml new file mode 100644 index 00000000000..92b4174842f --- /dev/null +++ b/app/views/admin/application_settings/_silent_mode_settings_form.html.haml @@ -0,0 +1,11 @@ +%section.settings.no-animate#js-silent-mode-toggle{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('SilentMode|Silent mode') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded_by_default? ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = s_('SilentMode|Suppress outbound communication, such as emails, from GitLab.') + = link_to _('Learn more.'), help_page_path('administration/silent_mode/index'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + #js-silent-mode-settings{ data: { "silent-mode-enabled" => @application_setting.silent_mode_enabled.to_s } } diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 6f9aad56ce8..1b90432e1f3 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -6,7 +6,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - - help_link = link_to('', help_page_path('development/snowplow/index'), target: '_blank', rel: 'noopener noreferrer') + - help_link = link_to('', help_page_path('development/internal_analytics/snowplow/index'), target: '_blank', rel: 'noopener noreferrer') - snowplow_link = link_to('', 'https://snowplow.io/', target: '_blank', rel: 'noopener noreferrer') = safe_format(_('Configure %{snowplow_link_start}Snowplow%{snowplow_link_end} to track events. %{help_link_start}Learn more.%{help_link_end}'), tag_pair(snowplow_link, :snowplow_link_start, :snowplow_link_end), tag_pair(help_link, :help_link_start, :help_link_end)) .settings-content diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 0455394444c..5a3814ca83d 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -12,7 +12,7 @@ help_text: _("GitLab informs you if a new version is available. %{link_start}What information does GitLab Inc. collect?%{link_end}").html_safe % { link_start: help_link_start, link_end: link_end } .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? - - service_ping_link_start = link_start % { url: help_page_path('development/service_ping/index') } + - service_ping_link_start = link_start % { url: help_page_path('development/internal_analytics/service_ping/index') } - deactivating_service_ping_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'disable-usage-statistics-with-the-configuration-file') } - usage_ping_help_text = s_('AdminSettings|To help improve GitLab and its user experience, GitLab periodically collects usage information. %{link_start}What information is shared with GitLab Inc.?%{link_end}').html_safe % { link_start: service_ping_link_start, link_end: link_end } - disabled_help_text = s_('AdminSettings|Service ping is disabled in your configuration file, and cannot be enabled through this form. For more information, see the documentation on %{link_start}deactivating service ping%{link_end}.').html_safe % { link_start: deactivating_service_ping_link_start, link_end: link_end } @@ -35,19 +35,10 @@ checkbox_options: { id: 'application_setting_usage_ping_features_enabled' }, label_options: { id: 'service_ping_features_label' } .form-text.gl-text-gray-500.gl-pl-6 - %p.gl-mb-3= s_('AdminSettings|Registration Features include:') - - email_from_gitlab_path = help_page_path('administration/email_from_gitlab') - - repo_size_limit_path = help_page_path('administration/settings/account_and_limit_settings', anchor: 'repository-size-limit') - - restrict_ip_path = help_page_path('user/group/access_and_permissions', anchor: 'restrict-group-access-by-ip-address') - - email_from_gitlab_link = link_start % { url: email_from_gitlab_path } - - repo_size_limit_link = link_start % { url: repo_size_limit_path } - - restrict_ip_link = link_start % { url: restrict_ip_path } - %ul - %li - = s_('AdminSettings|Email from GitLab - email users right from the Admin Area. %{link_start}Learn more%{link_end}.').html_safe % { link_start: email_from_gitlab_link, link_end: link_end } - %li - = s_('AdminSettings|Limit project size at a global, group, and project level. %{link_start}Learn more%{link_end}.').html_safe % { link_start: repo_size_limit_link, link_end: link_end } - %li - = s_('AdminSettings|Restrict group access by IP address. %{link_start}Learn more%{link_end}.').html_safe % { link_start: restrict_ip_link, link_end: link_end } + %p.gl-mb-3 + - registration_features_gitlab_path = help_page_path('administration/settings/usage_statistics', anchor: 'registration-features-program') + - registration_features_gitlab_link = link_to('', registration_features_gitlab_path, target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AdminSettings|For a list of included Registration Features, see %{link_start}the documentation%{link_end}.'), tag_pair(registration_features_gitlab_link, :link_start, :link_end)) + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 7142128d2cd..624f5a48c3a 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -19,33 +19,17 @@ = s_('AdminSettings|Restricted visibility levels') %small.form-text.text-gl-muted = s_('AdminSettings|Prevent non-administrators from using the selected visibility levels for groups, projects and snippets.') + = s_('AdminSettings|The selected level must be different from the selected default group and project visibility.') + = link_to _('Learn more.'), help_page_path('administration/settings/visibility_and_access_controls', anchor: 'restrict-visibility-levels'), target: '_blank', rel: 'noopener noreferrer' = hidden_field_tag 'application_setting[restricted_visibility_levels][]' .gl-form-checkbox-group - restricted_level_checkboxes(f).each do |checkbox| = checkbox - .form-group - = f.label :import_sources, s_('AdminSettings|Import sources'), class: 'label-bold gl-mb-0' - %span.form-text.gl-mt-0.gl-mb-3#import-sources-help - = _('Code can be imported from enabled sources during project creation. OmniAuth must be configured for GitHub') - = link_to sprite_icon('question-o'), help_page_path("integration/github") - and Bitbucket - = link_to sprite_icon('question-o'), help_page_path("integration/bitbucket") - = hidden_field_tag 'application_setting[import_sources][]' - - import_sources_checkboxes(f).each do |source| - = source = render_if_exists 'admin/application_settings/ldap_access_setting', form: f = render_if_exists 'admin/application_settings/saml_group_locks_setting', form: f - .form-group{ data: { testid: 'project-export' } } - = f.label :project_export, s_('AdminSettings|Project export'), class: 'label-bold' - = f.gitlab_ui_checkbox_component :project_export_enabled, s_('AdminSettings|Enabled') - - .form-group{ data: { testid: 'bulk-import' } } - = f.label :bulk_import, s_('AdminSettings|Allow migrating GitLab groups and projects by direct transfer'), class: 'gl-font-weight-bold' - = f.gitlab_ui_checkbox_component :bulk_import_enabled, s_('AdminSettings|Enabled') - .form-group %label.label-bold= _('Enabled Git access protocols') = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 6ae9c58ffcd..5aa2684f084 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -10,7 +10,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary - = _('Set visibility of project contents. Configure import sources and Git access protocols.') + = s_('AdminSettings|Set visibility of project contents and configure Git access protocols.') .settings-content = render 'visibility_and_access' @@ -25,6 +25,17 @@ .settings-content = render 'account_and_limit' +%section.settings.as-import-export.no-animate#js-import-export-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'admin-import-export-settings' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _('Import and export settings') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle '}) do + = expanded_by_default? ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = _('Configure import sources and settings related to import and export features.') + .settings-content + = render 'import_and_export' + %section.settings.as-diff-limits.no-animate#js-merge-request-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only @@ -89,6 +100,7 @@ = render 'terminal' = render_if_exists 'admin/application_settings/maintenance_mode_settings_form' += render 'admin/application_settings/silent_mode_settings_form' = render 'admin/application_settings/gitpod' = render 'admin/application_settings/kroki' = render 'admin/application_settings/mailgun' diff --git a/app/views/admin/dev_ops_report/_score.html.haml b/app/views/admin/dev_ops_report/_score.html.haml index 208afefc73b..a504563ad91 100644 --- a/app/views/admin/dev_ops_report/_score.html.haml +++ b/app/views/admin/dev_ops_report/_score.html.haml @@ -1,6 +1,6 @@ - service_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled - if !service_ping_enabled - #js-devops-service-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_service_ping_path: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/service_ping/index.md') } } + #js-devops-service-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_service_ping_path: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/internal_analytics/service_ping/index.md') } } - else #js-devops-score{ data: { devops_score_metrics: devops_score_metrics(@metric).to_json, no_data_image_path: image_path('dev_ops_report_no_data.svg'), devops_score_intro_image_path: image_path('dev_ops_report_overview.svg') } } diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index b708564e23a..2da28910af3 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -41,6 +41,6 @@ - else .gl-mt-5 - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), data: { testid: 'save-changes-button' }, pajamas_button: true = render Pajamas::ButtonComponent.new(href: admin_group_path(@group)) do = _('Cancel') diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index 20d24161c57..0c4bf91f545 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -1,11 +1,11 @@ - group = local_assigns.fetch(:group) -%li.group-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'group_row_content' } } +%li.group-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { testid: 'group-row-content' } } = render Pajamas::AvatarComponent.new(group, size: 32, alt: '') .gl-min-w-0.gl-flex-grow-1.gl-ml-3 .title - = link_to [:admin, group], class: 'group-name', data: { qa_selector: 'group_name_link' } do + = link_to [:admin, group], class: 'group-name', data: { testid: 'group-name-link' } do = group.full_name - if group.description.present? diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 2a49b9c5ad8..9f42897d1da 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -7,7 +7,7 @@ = hidden_field_tag :sort, @sort .search-holder .search-field-holder - = search_field_tag :name, params[:name].presence, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' } + = search_field_tag :name, params[:name].presence, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { testid: 'group-search-field' } = sprite_icon('search', css_class: 'search-icon') = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_group_path) do diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 5f5f6c98663..f7a49c88d78 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -8,7 +8,7 @@ = _('Group: %{group_name}') % { group_name: @group.full_name } = render Pajamas::ButtonComponent.new(href: admin_group_edit_path(@group), - button_options: { class: 'gl-float-right', data: { qa_selector: 'edit_group_link' }}, + button_options: { class: 'gl-float-right', data: { testid: 'edit-group-link' }}, icon: 'pencil') do = _('Edit') %hr diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml index 0ccde159905..096fae8d457 100644 --- a/app/views/admin/hook_logs/show.html.haml +++ b/app/views/admin/hook_logs/show.html.haml @@ -1,5 +1,5 @@ - page_title _('Request details') -%h1.page-title.gl-font-size-h-display +%h2.page-title.gl-font-size-h-display = _("Request details") %hr diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index a24cd000464..921232125ff 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -7,6 +7,8 @@ = saml_group_link(identity) %td = identity.extern_uid + %td + = '-' %td{ class: 'gl-py-0!' } - button_classes = 'has-tooltip gl-my-3' = render Pajamas::ButtonComponent.new(category: :tertiary, diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 1bb14969939..8077f0e15ca 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -10,6 +10,7 @@ %th{ class: 'gl-border-t-0!' }= s_('Identity|Provider ID') %th{ class: 'gl-border-t-0!' }= _('Group') %th{ class: 'gl-border-t-0!' }= _('Identifier') + %th{ class: 'gl-border-t-0!' }= s_('Identity|Active') %th{ class: 'gl-border-t-0!' }= _('Actions') - if identity_cells_to_render?(@identities, @user) = render_if_exists partial: 'admin/identities/scim_identity', collection: scim_identities_collection(@user) @@ -17,6 +18,6 @@ - else %tbody %tr - %td{ colspan: '5' } + %td{ colspan: '6' } .text-center.my-2 = _('This user has no identities') diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index c5632e0d70b..b8a9ad32259 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -1,30 +1,7 @@ - add_page_specific_style 'page_bundles/ci_status' -- add_page_specific_style 'page_bundles/admin/jobs_index' - breadcrumb_title _("Jobs") - page_title _("Jobs") -- if Feature.enabled?(:admin_jobs_vue) - #admin-jobs-app{ data: { job_statuses: job_statuses.to_json, empty_state_svg_path: image_path('jobs-empty-state.svg'), url: cancel_all_admin_jobs_path } } -- else - .top-area - .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full - %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } - = sprite_icon('chevron-lg-left', size: 12) - %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } - = sprite_icon('chevron-lg-right', size: 12) - - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } - = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - - - if @all_builds.running_or_pending.any? - #js-stop-jobs-modal - .nav-controls - = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'js-stop-jobs-button', data: { url: cancel_all_admin_jobs_path } }) do - = s_('AdminArea|Stop all jobs') - - .row-content-block.second-block - #{(@scope || 'all').capitalize} jobs - - %ul.content-list.builds-content-list.admin-builds-table - = render "projects/jobs/table", builds: @builds, admin: true +#admin-jobs-app{ data: { job_statuses: job_statuses.to_json, empty_state_svg_path: image_path('jobs-empty-state.svg'), url: cancel_all_admin_jobs_path } } diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index c3b5161d617..2638e45c9eb 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -20,7 +20,7 @@ = f.label :description, _("Description") .js-markdown-editor{ data: { render_markdown_path: preview_markdown_admin_topics_path, markdown_docs_path: help_page_path('user/markdown'), - qa_selector: 'topic_form_description', + testid: 'topic-form-description', form_field_placeholder: _('Write a description…'), supports_quick_actions: 'false', enable_autocomplete: 'false', diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 4979f7e28e7..5c80b3b4352 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -9,7 +9,7 @@ = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input gl-form-input-sm' .form-group.gl-form-group{ role: 'group' } - = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group') + = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create top level group') = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile') %fieldset.form-group.gl-form-group diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 8f7741b8a32..f6b7db2032f 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -31,7 +31,7 @@ - if impersonation_enabled? .gl-p-2 %span.btn-group{ class: !@can_impersonate ? 'has-tooltip' : nil, title: @impersonation_error_text } - = render Pajamas::ButtonComponent.new(disabled: !@can_impersonate, method: :post, href: impersonate_admin_user_path(@user), button_options: { data: { qa_selector: 'impersonate_user_link', testid: 'impersonate_user_link' } }) do + = render Pajamas::ButtonComponent.new(disabled: !@can_impersonate, method: :post, href: impersonate_admin_user_path(@user), button_options: { data: { testid: 'impersonate-user-link' } }) do = _('Impersonate') - if can_force_email_confirmation?(@user) .gl-p-2 @@ -48,5 +48,5 @@ = gl_tab_link_to _("SSH keys"), keys_admin_user_path(@user) = gl_tab_link_to _("Identities"), admin_user_identities_path(@user) - if impersonation_enabled? - = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user), data: { qa_selector: 'impersonation_tokens_tab' } + = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user), data: { testid: 'impersonation-tokens-tab' } .gl-mb-3 diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 213d5847986..d4a9009a0cf 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -35,7 +35,7 @@ = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do = s_('AdminUsers|Banned') = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned)) - = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { qa_selector: 'pending_approval_tab' } } do + = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { testid: 'pending-approval-tab' } } do = s_('AdminUsers|Pending approval') = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked_pending_approval)) = gl_tab_link_to admin_users_path(filter: "deactivated"), { item_active: active_when(params[:filter] == 'deactivated'), class: 'gl-border-0!' } do @@ -56,7 +56,7 @@ = hidden_field_tag "filter", h(params[:filter]) .search-holder .search-field-holder.gl-mb-4 - = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email, or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' } + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email, or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { testid: 'user-search-field' } - if @sort.present? = hidden_field_tag :sort, @sort = sprite_icon('search', css_class: 'search-icon') diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index a4ae29bed81..4cc3e12a8ad 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -45,7 +45,7 @@ = link_button_to nil, remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: 'float-right', title: _('Remove secondary email'), id: "remove_email_#{email.id}", variant: :danger, size: :small, icon: 'close' %li %span.light ID: - %strong{ data: { qa_selector: 'user_id_content' } } + %strong{ data: { testid: 'user-id-content' } } = @user.id %li %span.light= _('Namespace ID:') @@ -71,7 +71,7 @@ = render_if_exists 'admin/users/provisioned_by', user: @user %li - %span.light= _('Can create groups:') + %span.light= _('Can create top level groups:') %strong = @user.can_create_group ? _('Yes') : _('No') %li diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index cdf25a9348c..c46aabf2604 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -8,13 +8,13 @@ = _("Register the runner with this URL:") %br %code#coordinator_address= root_url(only_path: false) - = clipboard_button(target: '#coordinator_address', title: _("Copy URL")) + = deprecated_clipboard_button(target: '#coordinator_address', title: _("Copy URL")) %br %br = _("And this registration token:") %br %code#registration_token{ data: {testid: 'registration_token' } }= registration_token - = clipboard_button(target: '#registration_token', title: _("Copy 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 diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml deleted file mode 100644 index a710655aa20..00000000000 --- a/app/views/ci/variables/_variable_row.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -- form_field = local_assigns.fetch(:form_field, nil) -- variable = local_assigns.fetch(:variable, nil) - -- id = variable&.id -- variable_type = variable&.variable_type -- key = variable&.key -- value = variable&.value - -- id_input_name = "#{form_field}[variables_attributes][][id]" -- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" -- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]" -- key_input_name = "#{form_field}[variables_attributes][][key]" -- value_input_name = "#{form_field}[variables_attributes][][secret_value]" - -%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } - .ci-variable-row-body.border-bottom - %input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id } - %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } - %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } - = options_for_select(ci_variable_type_options, variable_type) - %input.js-ci-variable-input-key.ci-variable-body-item.form-control.gl-form-input.table-section.section-15{ type: "text", - name: key_input_name, - value: key, - placeholder: s_('CiVariables|Input variable key') } - .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 - .form-control.js-secret-value-placeholder.overflow-hidden{ class: ('hide' unless id) } - = '*' * 17 - %textarea.js-ci-variable-input-value.js-secret-value.form-control.gl-form-input{ class: ('hide' if id), - rows: 1, - name: value_input_name, - placeholder: s_('CiVariables|Input variable value') } - = value - %p.masking-validation-error.gl-field-error.hide - = s_("CiVariables|Cannot use Masked Variable with current value") - = link_to sprite_icon('question-o'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' - = render Pajamas::ButtonComponent.new(icon: 'close', button_options: { class: 'js-row-remove-button ci-variable-row-remove-button table-section', 'aria-label': s_('CiVariables|Remove variable row') }) diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml index 59706b6d8c4..4b7164f9845 100644 --- a/app/views/clusters/clusters/_provider_details_form.html.haml +++ b/app/views/clusters/clusters/_provider_details_form.html.haml @@ -1,7 +1,7 @@ = gitlab_ui_form_for cluster, url: update_cluster_url_path, html: { class: 'js-provider-details gl-show-field-errors', role: 'form' }, as: :cluster do |field| .form-group - - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), + - copy_name_btn = deprecated_clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold required' .input-group.gl-field-error-anchor @@ -12,7 +12,7 @@ = field.fields_for :platform_kubernetes, platform do |platform_field| .form-group - - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), + - copy_api_url = deprecated_clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? = platform_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold required' .input-group.gl-field-error-anchor @@ -22,7 +22,7 @@ append: copy_api_url .form-group - - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), + - copy_ca_cert_btn = deprecated_clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' .input-group.gl-field-error-anchor diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index ea7cd75152d..ddf3bd5ae07 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -1,2 +1,2 @@ .js-groups-list-holder - #js-groups-tree{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } + #js-groups-tree{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap', groups_empty_state_illustration: image_path('illustrations/empty-state/empty-groups-md.svg') } } diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 7f004e405a7..c5abc964fda 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -3,8 +3,4 @@ = render_dashboard_ultimate_trial(current_user) = render 'dashboard/groups_head' -- if params[:filter].blank? && @groups.empty? - .empty-state - = render 'shared/groups/empty_state' -- else - = render 'groups' += render 'groups' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 1cd8015934e..181c79e7bd0 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,11 +1,11 @@ %li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer.gl-relative{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) } .gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-sm-align-items-center - .todo-item.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center.gl-w-full{ data: { qa_selector: "todo_item_container" } } + .todo-item.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center.gl-w-full{ data: { testid: "todo-item-container" } } .todo-title.gl-pt-2.gl-pb-3.gl-px-2.gl-md-mb-1.gl-font-sm.gl-text-secondary = todo_target_state_pill(todo) - %span.todo-target-title{ data: { qa_selector: "todo_target_title_content" }, :id => dom_id(todo) + "_describer" } + %span.todo-target-title{ :id => dom_id(todo) + "_describer" } = todo_target_title(todo) - if !todo.for_design? && !todo.member_access_requested? @@ -25,7 +25,7 @@ = author_avatar(todo, size: 24) .todo-note - if todo_author_display?(todo) - .author-name.bold.gl-display-inline{ data: { qa_selector: "todo_author_name_content" } }< + .author-name.bold.gl-display-inline{ data: { testid: "todo-author-name-content" } }< - if todo.author = link_to_author(todo, self_added: todo.self_added?) - else diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index c5f70397fad..3feb30085c0 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -36,7 +36,7 @@ .filter-item.gl-m-2 - if params[:group_id].present? = hidden_field_tag(:group_id, params[:group_id]) - = dropdown_tag(group_dropdown_label(params[:group_id], _("Group")), options: { toggle_class: 'js-group-search js-filter-submit gl-xs-w-full!', title: s_("Todos|Filter by group"), filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: _("Search groups"), data: { default_label: _("Group"), display: 'static', qa_selector: 'group_dropdown' } }) + = dropdown_tag(group_dropdown_label(params[:group_id], _("Group")), options: { toggle_class: 'js-group-search js-filter-submit gl-xs-w-full!', title: s_("Todos|Filter by group"), filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: _("Search groups"), data: { default_label: _("Group"), display: 'static', testid: 'group-dropdown' } }) .filter-item.gl-m-2 - if params[:project_id].present? = hidden_field_tag(:project_id, params[:project_id]) diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 345a1cc0225..e6551adffde 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,6 +1,6 @@ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'gl-p-5 gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| .form-group - = f.label :login, _('Username or email') + = f.label :login, _('Username or primary email') = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input js-username-field', autocomplete: 'username', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } .form-group = f.label :password, _('Password') diff --git a/app/views/devise/shared/_email_opted_in.html.haml b/app/views/devise/shared/_email_opted_in.html.haml deleted file mode 100644 index d8ed0028222..00000000000 --- a/app/views/devise/shared/_email_opted_in.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- return unless Gitlab.com? - -.gl-mb-3.js-email-opt-in.hidden - .gl-font-weight-bold.gl-mb-3 - = _('Email updates (optional)') - = f.gitlab_ui_checkbox_component :email_opted_in, _("I'd like to receive updates about GitLab via email") diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 8f2c2c58790..73b9a3d5c5a 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -3,7 +3,7 @@ - if restyle_login_page_enabled && (any_form_based_providers_enabled? || password_authentication_enabled_for_web?) .omniauth-divider.gl-display-flex.gl-align-items-center - = _("or") + = _("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 diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 6d37257232b..bf1b604465b 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -17,7 +17,7 @@ class: 'form-control gl-form-input top js-block-emoji js-validate-length', data: { max_length: max_first_name_length, max_length_message: s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, - qa_selector: 'new_user_first_name_field' }, + testid: 'new-user-first-name-field' }, required: true, title: _('This field is required.') .col.form-group @@ -26,7 +26,7 @@ class: 'form-control gl-form-input top js-block-emoji js-validate-length', data: { max_length: max_last_name_length, max_length_message: s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, - qa_selector: 'new_user_last_name_field' }, + testid: 'new-user-last-name-field' }, required: true, title: _('This field is required.') .username.form-group @@ -44,7 +44,7 @@ = f.label :email, _('Email') = f.email_field :email, class: 'form-control gl-form-input middle js-validate-email', - data: { qa_selector: 'new_user_email_field' }, + data: { testid: 'new-user-email-field' }, required: true, title: _('Please provide a valid email address.') %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.') @@ -56,7 +56,7 @@ %input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }, minimum_password_length: @minimum_password_length, - qa_selector: 'new_user_password_field', + testid: 'new-user-password-field', autocomplete: 'new-password', name: "#{form_resource_name}[password]" } } %p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } @@ -69,7 +69,7 @@ - elsif show_recaptcha_sign_up? = recaptcha_tags nonce: content_security_policy_nonce - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'new_user_register_button' }}) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'new-user-register-button' }}) do = button_text = render 'devise/shared/terms_of_service_notice', button_text: button_text diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml index fcd52f33121..76a805d4d1b 100644 --- a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml @@ -5,4 +5,5 @@ = form_tag path do %input{ :name => "_method", :type => "hidden", :value => "delete" }/ - = submit_tag _('Revoke'), class: 'gl-button btn btn-danger btn-sm', aria: { label: s_('AuthorizedApplication|Revoke application') }, data: { confirm: s_('AuthorizedApplication|Are you sure you want to revoke this application?'), confirm_btn_variant: 'danger' } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :danger, size: :small, button_options: { aria: { label: s_('AuthorizedApplication|Revoke application') }, data: { confirm: s_('AuthorizedApplication|Are you sure you want to revoke this application?'), confirm_btn_variant: 'danger' } }) do + = _('Revoke') diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index c429bbbb610..43a545c4b4e 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -6,7 +6,7 @@ event = event.present event_url = event_feed_url(event) xml.entry do - xml.id "tag:#{request.host},#{event.created_at.strftime("%Y-%m-%d")}:#{event.id}" + xml.id "tag:#{request.host},#{event.created_at.to_date.iso8601}:#{event.id}" xml.link href: event_url if event_url xml.title truncate(event_feed_title(event), length: 80) xml.updated event.updated_at.xmlschema diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 4a6b7fcfa84..0ad969116e0 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -6,13 +6,11 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - - many_refs = event.ref_count.to_i > 1 - %span.event-type.d-inline-block.gl-mr-2.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}" - - unless many_refs + %span.event-type.d-inline-block.gl-mr-2.pushed= event.push_activity_description + - unless event.batch_push? %span.gl-mr-2.text-truncate - commits_link = project_commits_path(project, event.ref_name) - - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) - = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' + = link_to_if event.linked_to_reference?, event.ref_name, commits_link, class: 'ref-name' = render "events/event_scope", event: event diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml index 3291129fd69..37e5be521b4 100644 --- a/app/views/explore/groups/_groups.html.haml +++ b/app/views/explore/groups/_groups.html.haml @@ -1,3 +1,3 @@ .js-groups-list-holder - #js-groups-tree{ data: { endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } + #js-groups-tree{ data: { endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap', groups_empty_state_illustration: image_path('illustrations/empty-state/empty-groups-md.svg') } } = gl_loading_icon(size: 'md', css_class: 'gl-mt-6') diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 213346b4cc2..ddb82411add 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -22,7 +22,4 @@ %p= _("Below you will find all the groups that are public.") %p= _("You can easily contribute to them by requesting to join these groups.") -- if params[:filter].blank? && @groups.empty? - .nothing-here-block= _("No public groups") -- else - = render 'groups' += render 'groups' diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index 8c2434ca4a0..c35bbce6ba7 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -13,10 +13,9 @@ = s_('GroupsNew|Importing groups by direct transfer is currently disabled.') - if current_user.admin? - - admin_link_start = '<a href="%{url}">'.html_safe % { url: general_admin_application_settings_path(anchor: 'js-visibility-settings') } - - admin_link_end = '</a>'.html_safe + - admin_link = link_to('', general_admin_application_settings_path(anchor: 'js-visibility-settings')) - = s_('GroupsNew|Please %{admin_link_start}enable it in the Admin settings%{admin_link_end}.').html_safe % { admin_link_start: admin_link_start, admin_link_end: admin_link_end } + = safe_format(s_('GroupsNew|Please %{admin_link_start}enable it in the Admin settings%{admin_link_end}.'), tag_pair(admin_link, :admin_link_start, :admin_link_end)) - else = s_('GroupsNew|Please ask your Administrator to enable it in the Admin settings.') @@ -25,9 +24,8 @@ = render Pajamas::AlertComponent.new(dismissible: false, variant: :warning) 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.md', anchor: 'migrated-group-items') } - - docs_link_end = '</a>'.html_safe - = s_('GroupsNew|Not all group items are migrated. %{docs_link_start}What items are migrated%{docs_link_end}?').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } + - docs_link = link_to('', help_page_path('user/group/import/index.md', anchor: 'migrated-group-items'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('GroupsNew|Not all group items are migrated. %{docs_link_start}What items are migrated%{docs_link_end}?'), tag_pair(docs_link, :docs_link_start, :docs_link_end)) %p.gl-mt-3 = s_('GroupsNew|Provide credentials for the source instance to import from. You can provide this instance as a source to move groups in this instance.') @@ -41,9 +39,9 @@ .form-group.gl-display-flex.gl-flex-direction-column = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token' .gl-font-weight-normal - - pat_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/profile/personal_access_tokens') } - - short_living_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('security/token_overview', anchor: 'security-considerations') } - = s_('GroupsNew|Create a token with %{code_start}api%{code_end} and %{code_start}read_repository%{code_end} scopes in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, set a short expiration date for the token. Keep in mind that large migrations take more time.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe, pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe, short_living_link_start: short_living_link_start, short_living_link_end: '</a>'.html_safe } + - pat_link = link_to('', help_page_path('user/profile/personal_access_tokens'), target: '_blank') + - short_living_link = link_to('', help_page_path('security/token_overview', anchor: 'security-considerations'), target: '_blank') + = safe_format(s_('GroupsNew|Create a token with %{code_start}api%{code_end} and %{code_start}read_repository%{code_end} scopes in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, set a short expiration date for the token. Keep in mind that large migrations take more time.'), tag_pair('<code></code>'.html_safe, :code_start , :code_end), tag_pair(pat_link, :pat_link_start, :pat_link_end), tag_pair(short_living_link, :short_living_link_start, :short_living_link_end)) = f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8', required: true, disabled: bulk_imports_disabled, diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index 8416cb81c95..02a024ed3b5 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -1,6 +1,7 @@ - page_title _("Dependency Proxy") #js-dependency-proxy{ data: { group_path: @group.full_path, + endpoint: group_dependency_proxy_path(@group), no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'), group_id: @group.id, settings_path: group_settings_packages_and_registries_path(@group), diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index c11154cbd75..8d6eebc27b0 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -28,8 +28,8 @@ .settings-content = render 'groups/settings/permissions' -= render_if_exists 'groups/merge_requests', expanded: expanded, group: @group -= render_if_exists 'groups/merge_request_approval_settings', expanded: expanded, group: @group, user: current_user += render_if_exists 'groups/settings/merge_requests/merge_requests', expanded: expanded, group: @group += render_if_exists 'groups/settings/merge_requests/merge_request_approval_settings', expanded: expanded, group: @group, user: current_user = render_if_exists 'groups/analytics', expanded: expanded %section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) } diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index e5c66c2c432..fbfaaa49b39 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -15,7 +15,7 @@ = render_if_exists 'groups/group_members/create_service_account' .js-invite-members-trigger{ data: { variant: 'confirm', classes: 'gl-md-w-auto gl-w-full', - trigger_source: 'group-members-page', + trigger_source: 'group_members_page', display_text: _('Invite members') } } = render 'groups/invite_groups_modal', group: @group, reload_page_on_submit: true diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml index 9af842b01df..1a64ba0c27d 100644 --- a/app/views/groups/labels/edit.html.haml +++ b/app/views/groups/labels/edit.html.haml @@ -1,8 +1,9 @@ - add_to_breadcrumbs _("Labels"), group_labels_path(@group) - breadcrumb_title _("Edit") - page_title _("Edit"), @label.name, _("Labels") +- show_lock_on_merge = @group.supports_lock_on_merge? %h1.page-title.gl-font-size-h-display = _('Edit Label') -= render 'shared/labels/form', url: group_label_path(@group, @label), back_path: @previous_labels_path += render 'shared/labels/form', url: group_label_path(@group, @label), back_path: @previous_labels_path, show_lock_on_merge: show_lock_on_merge diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index e84fd7a8692..7665da08582 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -13,7 +13,7 @@ - @gfm_form = true .js-markdown-editor{ data: { render_markdown_path: group_preview_markdown_path, markdown_docs_path: help_page_path('user/markdown'), - qa_selector: 'milestone_description_field', + testid: 'milestone-description-field', form_field_placeholder: _('Write milestone description...'), supports_quick_actions: 'false', enable_autocomplete: 'true', diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index f4749617463..45fd98adbb9 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -37,7 +37,7 @@ = render_if_exists 'groups/settings/wiki', f: f, group: @group = render 'groups/settings/lfs', f: f = render_if_exists 'groups/settings/code_suggestions', f: f, group: @group - = render_if_exists 'groups/settings/ai_related_settings', f: f, group: @group + = render_if_exists 'groups/settings/experimental_settings', f: f, group: @group = render_if_exists 'groups/settings/ai_third_party_settings', f: f, group: @group = render 'groups/settings/git_access_protocols', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group diff --git a/app/views/groups/settings/applications/index.html.haml b/app/views/groups/settings/applications/index.html.haml index da3257ca27d..595336d8abb 100644 --- a/app/views/groups/settings/applications/index.html.haml +++ b/app/views/groups/settings/applications/index.html.haml @@ -3,7 +3,7 @@ - @force_desktop_expanded_sidebar = true = render 'shared/doorkeeper/applications/index', - oauth_applications_enabled: user_oauth_applications?, + oauth_applications_enabled: true, oauth_authorized_applications_enabled: false, form_url: group_settings_applications_path(@group), application_url: ->(application) { group_settings_application_path(@group, application) }, diff --git a/app/views/groups/work_items/index.html.haml b/app/views/groups/work_items/index.html.haml index 2e3d3dda941..299a90b362d 100644 --- a/app/views/groups/work_items/index.html.haml +++ b/app/views/groups/work_items/index.html.haml @@ -1,4 +1,4 @@ - page_title s_('WorkItem|Work items') - add_page_specific_style 'page_bundles/issuable_list' -.js-work-items-list-root{ data: { full_path: @group.full_path } } +.js-work-items-list-root{ data: work_items_list_data(@group) } diff --git a/app/views/groups/work_items/show.html.haml b/app/views/groups/work_items/show.html.haml new file mode 100644 index 00000000000..eb962cc0b69 --- /dev/null +++ b/app/views/groups/work_items/show.html.haml @@ -0,0 +1 @@ +.h1 Work Item diff --git a/app/views/help/instance_configuration/_size_limits.html.haml b/app/views/help/instance_configuration/_size_limits.html.haml index add484feac9..1f6379314b9 100644 --- a/app/views/help/instance_configuration/_size_limits.html.haml +++ b/app/views/help/instance_configuration/_size_limits.html.haml @@ -42,8 +42,8 @@ %td= _('Maximum snippet size') %td= instance_configuration_human_size_cell(size_limits[:snippet_size_limit]) %tr - %td= s_('Import|Maximum import remote file size (MB)') + %td= s_('Import|Maximum import remote file size (MiB)') %td= instance_configuration_human_size_cell(size_limits[:max_import_remote_file_size]) %tr - %td= s_('BulkImport|Direct transfer maximum download file size (MB)') + %td= s_('BulkImport|Direct transfer maximum download file size (MiB)') %td= instance_configuration_human_size_cell(size_limits[:bulk_import_max_download_file_size]) diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml index 292dd9d071c..de94f142a40 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -24,4 +24,5 @@ .col-md-4 = password_field_tag :personal_access_token, '', class: 'form-control gl-form-input gl-mr-3', placeholder: _('Personal Access Token'), size: 40 .form-actions - = submit_tag _('List your Bitbucket Server repositories'), class: 'gl-button btn btn-confirm' + = render Pajamas::ButtonComponent.new(type: 'submit', variant: :confirm) do + = _('List your Bitbucket Server repositories') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 53ecad1b474..bbde5f2843b 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -4,7 +4,7 @@ %head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } } %meta{ charset: "utf-8" } %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } - %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' } + %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1' } %title= page_title(site_name) = Gon::Base.render_data(nonce: content_security_policy_nonce) = yield :project_javascripts @@ -48,6 +48,16 @@ = webpack_bundle_tag 'legacy_sentry' = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? + - if vite_enabled + %meta{ name: 'controller-path', content: controller_full_path } + - if Rails.env.development? + = vite_client_tag + = vite_javascript_tag "main" + - if Gitlab.ee? + = vite_javascript_tag "main_ee" + - if Gitlab.jh? + = vite_javascript_tag "main_jh" + = yield :page_specific_javascripts = webpack_bundle_tag 'super_sidebar' if show_super_sidebar? diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 95627c2884a..f52ea801eef 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -20,7 +20,6 @@ .mobile-overlay = dispensable_render_if_exists 'layouts/header/verification_reminder' .alert-wrapper.gl-force-block-formatting-context - = yield :code_suggestions_third_party_alert = dispensable_render 'shared/new_nav_announcement' = dispensable_render 'shared/outdated_browser' = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold" @@ -37,6 +36,7 @@ = dispensable_render_if_exists "layouts/header/seat_count_alert" = dispensable_render_if_exists "shared/namespace_user_cap_reached_alert" = dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert" + = dispensable_render_if_exists "shared/silent_mode_banner" = yield :page_level_alert = yield :group_invite_members_banner - unless @hide_top_bar diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index 399a826d611..10b2002dfef 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -2,6 +2,8 @@ - namespace = @group || @project&.namespace || @namespace = webpack_bundle_tag 'tracker' +- if Gitlab.com? && Feature.enabled?(:browsersdk_tracking) + = webpack_bundle_tag 'analytics' = javascript_tag do :plain window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json} diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 28cbdf0a7a1..451c66b074b 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -12,13 +12,8 @@ = render 'peek/bar' = header_message - - if show_super_sidebar? # TODO: Move this CSS to a better place - - if current_user - :css - body { - --header-height: 0px; - } - - else + - if show_super_sidebar? + - if !current_user = render partial: "layouts/header/super_sidebar_logged_out" - else = render partial: "layouts/header/default", locals: { project: @project, group: @group } diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index 5ad20478f51..d07daf0aab9 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: I18n.locale } %head - %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } + %meta{ :content => "width=device-width, initial-scale=1", :name => "viewport" } %title= yield(:title) %style = Rails.application.assets_manifest.find_sources('errors.css').first.to_s.html_safe diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 83641fbb184..1d67ac942fa 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -21,7 +21,6 @@ = render 'groups/invite_members_modal', group: @group = dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert" -= dispensable_render_if_exists "shared/code_suggestions_third_party_alert", source: @group = dispensable_render_if_exists "shared/free_user_cap_alert", source: @group = dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 7ce914cf660..75de13d4862 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -43,7 +43,7 @@ = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url, data: { track_action: "click_link", track_label: "switch_to_canary", track_property: "navigation_top" } %li.divider - .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_url} } + .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_path} } - if current_user_menu?(:sign_out) %li.divider diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml index 67322aced74..31dfdfb2bb3 100644 --- a/app/views/layouts/header/_super_sidebar_logged_out.haml +++ b/app/views/layouts/header/_super_sidebar_logged_out.haml @@ -44,4 +44,4 @@ - if allow_signup? %li = render Pajamas::ButtonComponent.new(href: new_user_registration_path, variant: :confirm) do - = _('Register') + = Gitlab.com? ? _('Get free trial') : _('Register') diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml index 5531c0ab23a..8b6a2a2f2a7 100644 --- a/app/views/layouts/minimal.html.haml +++ b/app/views/layouts/minimal.html.haml @@ -3,7 +3,7 @@ !!! 5 %html{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ data: body_data } + %body{ data: body_data, class: system_message_class } = header_message = render 'peek/bar' = render 'layouts/published_experiments' diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml index d5e0e8e9c1d..697bd9b5864 100644 --- a/app/views/layouts/oauth_error.html.haml +++ b/app/views/layouts/oauth_error.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: I18n.locale } %head - %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } + %meta{ :content => "width=device-width, initial-scale=1", :name => "viewport" } %title= yield(:title) = stylesheet_link_tag 'application_utilities' %style diff --git a/app/views/layouts/organization.html.haml b/app/views/layouts/organization.html.haml index 5a357c6f805..7e1bf228876 100644 --- a/app/views/layouts/organization.html.haml +++ b/app/views/layouts/organization.html.haml @@ -1,6 +1,6 @@ -- page_title @organization.name -- header_title @organization.name, organization_path(@organization) -- nav "organization" +- page_title @organization.name if @organization +- header_title @organization.name, organization_path(@organization) if @organization +- nav(%w[index new].include?(params[:action]) ? "your_work" : "organization") - @left_sidebar = true = render template: "layouts/application" diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 18ae3353f4d..1e85bb6cc3a 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -23,7 +23,6 @@ = render 'projects/invite_members_modal', project: @project = dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert" -= dispensable_render_if_exists "projects/code_suggestions_third_party_alert", project: @project = dispensable_render_if_exists "projects/free_user_cap_alert", project: @project = dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml index 13e9785317c..e9c5d51c674 100644 --- a/app/views/layouts/service_desk.html.haml +++ b/app/views/layouts/service_desk.html.haml @@ -24,11 +24,5 @@ %br = link_to "Unsubscribe", @unsubscribe_url - -# EE-specific start - - if Gitlab::CurrentSettings.email_additional_text.present? - %br - %br - = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.email_additional_text) - -# EE-specific end - + = render_if_exists 'layouts/email_additional_text' = html_footer_message diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 9a50e3e2eb2..29a561ae1a9 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -1,6 +1,7 @@ !!! 5 - add_page_specific_style 'page_bundles/terms' - @hide_top_bar = true +- @hide_top_bar_padding = true - body_classes = [user_application_theme] %html{ lang: I18n.locale, class: page_class } = render "layouts/head" diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml deleted file mode 100644 index a88d581c5de..00000000000 --- a/app/views/notify/in_product_marketing_email.html.haml +++ /dev/null @@ -1,51 +0,0 @@ -- if @message.series? - %tr{ style: "background-color: #ffffff;" } - %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } - %p - = @message.progress.html_safe -%tr - %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" } - = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' }) - %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" } - = @message.title - %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" } - = @message.subtitle -%tr - %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } - %p{ style: "margin: 0 0 20px 0;" } - = @message.body_line1.html_safe - - @message.body_line2&.tap do |line| - %p{ style: "margin: 0 0 20px 0;" } - = line.html_safe -- if @message.cta_text - %tr - %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .cta_link.cta_link_primary= @message.cta_link -- else - %tr - %td{ style: "padding: 10px 20px 10px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 16px; line-height: 20px;" } - %table{ border: "0", cellpadding: "0", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%;" } - %tr - %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: left;", align: "left" } - = @message.feedback_ratings(1) - %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: right;", align: "right" } - = @message.feedback_ratings(5) - %tr - %td{ align: "center", style: "padding: 10px 1px 30px 1px;" } - %table{ align: "center", cellpadding: "5", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%; border: 1px solid #dae0ea; border-radius: 0; min-width: 100%; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px;" } - %tr - - (1..5).each do |rating| - %td{ height: "54", style: "border-left: 1px solid #dae0ea; padding-bottom: 0; width: 9% !important;", width: "9%" } - %a{ href: @message.feedback_link(rating), style: "color: #424242; display: block; text-decoration: none;" } - %span{ height: "54", style: "display: block; font-size: 18px; height: 22px; line-height: 22px; padding: 16px 0; width: 100%; text-decoration: none;" } - = rating - %tr - %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } - %p{ style: "margin: 0 0 50px 0;" } - = @message.feedback_thanks -- if @message.invite_members? - %tr - %td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - = @message.invite_text - %br - = @message.invite_link diff --git a/app/views/notify/in_product_marketing_email.text.erb b/app/views/notify/in_product_marketing_email.text.erb deleted file mode 100644 index 79a366eb1cc..00000000000 --- a/app/views/notify/in_product_marketing_email.text.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%= @message.tagline %> - -<%= @message.title %> -<%= @message.subtitle %> - - -<%= @message.body_line1 %> - -<%= @message.body_line2 %> - -<% if @message.cta_text %> -<%= @message.cta_link %> - - - -<% else %> -<% (1..5).each do |rating| %> -<%= "#{rating} - #{@message.feedback_ratings(rating).upcase} - #{@message.feedback_link(rating)}" %> -<% end %> - - -<%= @message.feedback_thanks %> -<% end %> -<% if @message.invite_members? %> -<%= @message.invite_text %> -<%= @message.invite_link %> -<% end %> - - - - -<%= @message.footer_links %> - -<%= @message.address %> - -<%= @message.unsubscribe %> diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index cce36f7b8a6..49a571f154e 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -8,11 +8,6 @@ %td.text-content %p = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type } - - if member.tasks_to_be_done.present? - = s_("InviteEmail|You were assigned the following tasks:") - %ul.list-style-position-inside - - member.tasks_to_be_done.each do |task| - %li= localized_tasks_to_be_done_choices[task] %p - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index 6d5207510da..21d0f8b9108 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -22,11 +22,6 @@ %p - if member.created_by = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to inviter_name, user_url(member.created_by)).html_safe }) - - if member.tasks_to_be_done.present? - = s_("InviteEmail|and has assigned you the following tasks:") - %ul.list-style-position-inside - - member.tasks_to_be_done.each do |task| - %li= localized_tasks_to_be_done_choices[task] - else = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders %p.invite-actions diff --git a/app/views/notify/new_email_address_added_email.haml b/app/views/notify/new_email_address_added_email.html.haml index 6d00aaedfd5..6d00aaedfd5 100644 --- a/app/views/notify/new_email_address_added_email.haml +++ b/app/views/notify/new_email_address_added_email.html.haml diff --git a/app/views/notify/new_email_address_added_email.erb b/app/views/notify/new_email_address_added_email.text.erb index 3af1953c902..3af1953c902 100644 --- a/app/views/notify/new_email_address_added_email.erb +++ b/app/views/notify/new_email_address_added_email.text.erb diff --git a/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml b/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml new file mode 100644 index 00000000000..e4e34f6c8ee --- /dev/null +++ b/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml @@ -0,0 +1,13 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = _('One or more of your resource access tokens will expire in %{days_to_expire} or less:') % { days_to_expire: pluralize(@days_to_expire, _('day')) } +%p + #{@resource.class.name.titleize}: #{@resource.full_path} +%p + %ul + - @token_names.each do |token| + %li= token +%p + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } + = html_escape(_('You can create a new one or check them in your %{link_start}access tokens%{link_end} settings.')) % { link_start: link_start, link_end: '</a>'.html_safe } diff --git a/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb b/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb new file mode 100644 index 00000000000..bea74f09129 --- /dev/null +++ b/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb @@ -0,0 +1,11 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('One or more of your resource access tokens will expire in %{days_to_expire} or less:') % { days_to_expire: pluralize(@days_to_expire, _('day')) } %> + +<%= "#{@resource.class.name.titleize}: #{@resource.full_path}" %> + +<% @token_names.each do |token| %> + - <%= token %> +<% end %> + +<%= _('You can create a new one or check them in your access token settings: %{target_url}') % { target_url: @target_url } %> diff --git a/app/views/organizations/organizations/groups_and_projects.html.haml b/app/views/organizations/organizations/groups_and_projects.html.haml index 8890f4b1ce5..a993e1c9404 100644 --- a/app/views/organizations/organizations/groups_and_projects.html.haml +++ b/app/views/organizations/organizations/groups_and_projects.html.haml @@ -1,3 +1,3 @@ - page_title _('Groups and projects') -#js-organizations-groups-and-projects +#js-organizations-groups-and-projects{ data: { app_data: organization_groups_and_projects_app_data } } diff --git a/app/views/organizations/organizations/index.html.haml b/app/views/organizations/organizations/index.html.haml new file mode 100644 index 00000000000..04a90b7589f --- /dev/null +++ b/app/views/organizations/organizations/index.html.haml @@ -0,0 +1,2 @@ +- page_title s_('Organization|Organizations') +- header_title _("Your work"), root_path diff --git a/app/views/organizations/organizations/new.html.haml b/app/views/organizations/organizations/new.html.haml new file mode 100644 index 00000000000..4d7f552c87b --- /dev/null +++ b/app/views/organizations/organizations/new.html.haml @@ -0,0 +1,3 @@ +- page_title s_('Organization|New organization') +- header_title _("Your work"), root_path +- add_to_breadcrumbs s_('Organization|Organizations'), organizations_path diff --git a/app/views/organizations/organizations/show.html.haml b/app/views/organizations/organizations/show.html.haml index 8ba2a3d96ac..2ce4c0688ae 100644 --- a/app/views/organizations/organizations/show.html.haml +++ b/app/views/organizations/organizations/show.html.haml @@ -1,2 +1,4 @@ - page_title s_('Organization|Organization overview') - @skip_current_level_breadcrumb = true + +#js-organizations-show{ data: { app_data: organization_show_app_data(@organization) } } diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 2714193d1d1..982199d3d6f 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -28,7 +28,7 @@ %h4.gl-mt-0 = _('Add a GPG key') %p - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') } + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/signed_commits/gpg.md') } = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } = render 'form' diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index d5193a424ef..1307c388041 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -46,7 +46,7 @@ .gl-display-flex %pre.well-pre.gl-pl-5.gl-mb-0.gl-border-0 = @key.key - = clipboard_button(title: s_('Profiles|Copy SSH key'), text: @key.key, class: 'gl-bg-gray-10 gl-px-3! gl-border-none! gl-rounded-top-left-none! gl-rounded-bottom-left-none!') + = deprecated_clipboard_button(title: s_('Profiles|Copy SSH key'), text: @key.key, class: 'gl-bg-gray-10 gl-px-3! gl-border-none! gl-rounded-top-left-none! gl-rounded-bottom-left-none!') = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c| - c.with_header do diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml index 60f366f8878..6848426306b 100644 --- a/app/views/profiles/notifications/_email_settings.html.haml +++ b/app/views/profiles/notifications/_email_settings.html.haml @@ -1,6 +1,3 @@ -- form = local_assigns.fetch(:form) .js-notification-email-listbox-input.gl-mb-3{ data: { label: _('Global notification email'), name: 'user[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Use primary email (%{email})') % { email: @user.email }, value: @user.notification_email, disabled: local_assigns.fetch(:email_change_disabled, nil) } } .help-block = local_assigns.fetch(:help_text, nil) -.form-group - = form.gitlab_ui_checkbox_component :email_opted_in, _('Receive product marketing emails') diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 2c7ef2b7e0e..87945f66ae7 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -19,8 +19,8 @@ = _('You can specify notification level per group or per project.') .gl-mt-0 - = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f| - = render_if_exists 'profiles/notifications/email_settings', form: f + = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3 gl-mb-6' } do |f| + = render_if_exists 'profiles/notifications/email_settings' = label_tag :global_notification_level, _('Global notification level'), class: "label-bold gl-mb-0" .gl-text-secondary.gl-mb-3 diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 681d4e087f3..a6534a16e86 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -67,6 +67,13 @@ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :layout, class: 'label-bold' do + = s_('Preferences|Keyboard shortcuts') + - shortcuts_help_link = link_to('', help_page_path('user/shortcuts'), target: '_blank', rel: 'noopener noreferrer') + = f.gitlab_ui_checkbox_component :keyboard_shortcuts_enabled, + s_('Preferences|Enable keyboard shortcuts'), + help_text: safe_format(s_('Preferences|%{link_start}List of keyboard shortcuts%{link_end}'), tag_pair(shortcuts_help_link, :link_start, :link_end)) + .form-group + = f.label :layout, class: 'label-bold' do = s_('Preferences|Layout width') = f.gitlab_ui_radio_component :layout, layout_choices[0][1], layout_choices[0][0], help_text: fixed_help_text = f.gitlab_ui_radio_component :layout, layout_choices[1][1], layout_choices[1][0], help_text: fluid_help_text @@ -140,7 +147,7 @@ %p.gl-text-secondary = s_('Preferences|Configure how dates and times display for you.') = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'show-exact-times-instead-of-relative-times'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :time_display_relative, s_('Preferences|Use relative times'), diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 20fb2b43c63..58c760c54e8 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -9,8 +9,8 @@ - c.with_body do %p - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/import_export') } - = _('Export this project with all its related data in order to move it to a new GitLab instance. When the exported file is ready, you can download it from this page or from the download link in the email notification you will receive. You can then import it when creating a new project. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/settings/import_export'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_('Export this project with all its related data in order to move it to a new GitLab instance. When the exported file is ready, you can download it from this page or from the download link in the email notification you will receive. You can then import it when creating a new project. %{link_start}Learn more.%{link_end}'), tag_pair(link, :link_start, :link_end)) .gl-mb-0 %p.gl-font-weight-bold= _('The following items will be exported:') %ul diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 4ac30547ce3..759ec541af5 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -18,7 +18,7 @@ - if can?(current_user, :read_project, @project) %span.gl-display-inline-block.gl-vertical-align-middle = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - = clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id) + = deprecated_clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id) - if current_user %span.gl-ml-3.gl-mb-3 = render 'shared/members/access_request_links', source: @project diff --git a/app/views/projects/_invite_members_empty_project.html.haml b/app/views/projects/_invite_members_empty_project.html.haml index 18d06c7d0bb..d6cab06f773 100644 --- a/app/views/projects/_invite_members_empty_project.html.haml +++ b/app/views/projects/_invite_members_empty_project.html.haml @@ -6,4 +6,4 @@ .js-invite-members-trigger{ data: { variant: 'confirm', classes: 'gl-mb-8 gl-xs-w-full', display_text: s_('InviteMember|Invite members'), - trigger_source: 'project-empty-page' } } + trigger_source: 'project_empty_page' } } diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml deleted file mode 100644 index bb7a7731067..00000000000 --- a/app/views/projects/_merge_request_merge_checks_settings.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- form = local_assigns.fetch(:form) - -.form-group - %b= s_('ProjectSettings|Merge checks') - %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged.') - = render 'projects/merge_request_pipelines_and_threads_options', form: form, project: @project - = render_if_exists 'projects/merge_request_merge_checks_status_checks', form: form, project: @project - = render_if_exists 'projects/merge_request_merge_checks_jira_enforcement', form: form, project: @project diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml deleted file mode 100644 index eb2fc05686c..00000000000 --- a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- form = local_assigns.fetch(:form) - -.form-group - %b= s_('ProjectSettings|Merge suggestions') - %p.text-secondary - = s_('ProjectSettings|The commit message used when applying merge request suggestions.') - .mb-2 - = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE - %p.form-text.text-muted - = s_('ProjectSettings|Leave empty to use default template.') - = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_SUGGESTIONS_TEMPLATE_LENGTH }) - - configure_the_commit_message_for_applied_suggestions_help_link_url = help_page_path('user/project/merge_requests/reviews/suggestions.md', anchor: 'configure-the-commit-message-for-applied-suggestions') - - configure_the_commit_message_for_applied_suggestions_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_commit_message_for_applied_suggestions_help_link_url } - = s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}').html_safe % { link_start: configure_the_commit_message_for_applied_suggestions_help_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml deleted file mode 100644 index 728ff597860..00000000000 --- a/app/views/projects/_merge_request_settings.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- form = local_assigns.fetch(:form) - -= render 'projects/merge_request_merge_method_settings', project: @project, form: form - -= render 'projects/merge_request_merge_options_settings', project: @project, form: form - -= render 'projects/merge_request_squash_options_settings', form: form - -= render 'projects/merge_request_merge_checks_settings', project: @project, form: form - -= render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form - -= render 'projects/merge_request_merge_commit_template', project: @project, form: form - -= render 'projects/merge_request_squash_commit_template', project: @project, form: form - -- if @project.forked? - = render 'projects/merge_request_target_project_settings', project: @project, form: form diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index c2382a66132..3dbc4c0fad7 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -12,7 +12,7 @@ enabled: "#{@project.service_desk_enabled}", issue_tracker_enabled: "#{@project.project_feature.issues_enabled?}", incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled), - service_desk_email: (@project.service_desk_custom_address if @project.service_desk_enabled), + service_desk_email: (@project.service_desk_alias_address if @project.service_desk_enabled), service_desk_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}", selected_template: "#{@project.service_desk_setting&.issue_template_key}", selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index fe84a83c43c..cf0634ee411 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -8,15 +8,15 @@ .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title= _('Transfer project') %p.gl-new-card-description - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transfer-a-project-to-another-namespace') } - = _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/settings/index', anchor: 'transfer-a-project-to-another-namespace'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) - c.with_body do = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f| .form-group.gl-mb-0 %p - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') } - = _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) %p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.') %p = _("Don't have a group?") diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml index 674b21b66b9..6a4760c3954 100644 --- a/app/views/projects/activity.html.haml +++ b/app/views/projects/activity.html.haml @@ -1,6 +1,4 @@ - page_title _("Activity") -= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project - = render 'projects/last_push' = render 'projects/activity' diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index c68cc19f6c1..56105c4cac3 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -3,6 +3,6 @@ %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name = tree_icon('folder', '755', directory.name) - = link_to path_to_directory, class: 'str-truncated', data: { qa_selector: 'directory_name_link', qa_directory_name: directory.name } do + = link_to path_to_directory, class: 'str-truncated', data: { testid: 'directory-name-link', qa_directory_name: directory.name } do %span= directory.name %td diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index 9cd2f583fdd..8c132a16797 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -3,7 +3,7 @@ .js-table-contents = blob_icon blob.mode, blob.name - %strong.file-title-name.gl-word-break-all{ data: { qa_selector: 'file_name_content' } } + %strong.file-title-name.gl-word-break-all{ data: { testid: 'file-name-content' } } = blob.name = copy_file_path_button(blob.path) diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index 703ffa8896e..01f730db33e 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -1,3 +1,3 @@ - blob = viewer.blob -.file-content.md +.file-content.js-markup-content.md = markup(blob.name, blob.data, viewer.banzai_render_context) diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml index 9c3f9b6c9fd..b7bc43d08d8 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml @@ -7,5 +7,3 @@ %ul - viewer.errors.each do |error| %li= error - -= link_to _('Learn more'), help_page_path('operations/metrics/dashboards/index.md') diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml index 5e355ecc4b8..1f5086dc3bd 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml @@ -1,4 +1,2 @@ = gl_loading_icon(inline: true, css_class: "mr-1") = _('Metrics Dashboard YAML definition') + '…' - -= link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md') diff --git a/app/views/projects/branch_defaults/_branch_names_fields.html.haml b/app/views/projects/branch_defaults/_branch_names_fields.html.haml index 4e4a72c154f..3e77cb51a85 100644 --- a/app/views/projects/branch_defaults/_branch_names_fields.html.haml +++ b/app/views/projects/branch_defaults/_branch_names_fields.html.haml @@ -6,7 +6,7 @@ .form-group .gl-mb-2 - = f.text_field :issue_branch_template, class: 'form-control gl-mb-2', placeholder: "%{id}-%{title}" + = f.text_field :issue_branch_template, class: 'form-control gl-mb-2 gl-form-input-xl', placeholder: "%{id}-%{title}" %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Issue::MAX_BRANCH_TEMPLATE }) diff --git a/app/views/projects/branch_defaults/_default_branch_fields.html.haml b/app/views/projects/branch_defaults/_default_branch_fields.html.haml index e4f51725f1a..2c59e187d30 100644 --- a/app/views/projects/branch_defaults/_default_branch_fields.html.haml +++ b/app/views/projects/branch_defaults/_default_branch_fields.html.haml @@ -6,7 +6,8 @@ .form-group = f.label :default_branch, _("Default branch"), class: 'label-bold' %p= s_('ProjectSettings|All merge requests and commits are made against this branch unless you specify a different one.') - .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id } } + .gl-form-input-xl + .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id } } .form-group - help_text = _("When merge requests and commits in the default branch close, any issues they reference also close.") diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index db5d1ff5693..b5679bc512c 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -15,7 +15,7 @@ .input-group.btn-group = text_field_tag :ssh_project_clone, ssh_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } .input-group-append - = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") + = deprecated_clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' - if http_enabled? %li.pt-2{ class: 'gl-px-4!' } @@ -24,7 +24,7 @@ .input-group.btn-group = text_field_tag :http_project_clone, http_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } .input-group-append - = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") + = deprecated_clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' %li.divider.mt-2 diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 24d063d3b4d..e79a91eddaf 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -45,11 +45,12 @@ = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle') - if can?(current_user, :read_pipeline, @last_pipeline) + - status = @last_pipeline.detailed_status(current_user) .well-segment.pipeline-info .js-commit-pipeline-status{ data: { full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } } #{ _('Pipeline') } = link_to "##{@last_pipeline.id}", project_pipeline_path(@project, @last_pipeline.id) - = ci_label_for_status(@last_pipeline.status) + = status&.label - if @last_pipeline.stages_count.nonzero? #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), @last_pipeline.stages_count) } .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 5b99a88f29e..6aefc2eaa8b 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -17,17 +17,17 @@ - if signature.x509? = render partial: "projects/commit/x509/certificate_details", locals: { signature: signature } - = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gl-link gl-display-block') + = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/signed_commits/x509.md'), class: 'gl-link gl-display-block') - elsif signature.ssh? = _('SSH key fingerprint:') %span.gl-font-monospace= signature.key_fingerprint_sha256 || _('Unknown') - = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/ssh_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3') + = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/signed_commits/ssh.md'), class: 'gl-link gl-display-block gl-mt-3') - else = _('GPG Key ID:') %span.gl-font-monospace= signature.gpg_key_primary_keyid - = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3') + = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3') %a.signature-badge.gl-display-inline-block.gl-ml-4{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = gl_badge_tag label, variant: variant diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 13a406d442d..c42d0fe9931 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -76,5 +76,5 @@ .commit-sha-group.btn-group.d-none.d-sm-flex .label.label-monospace.monospace = commit.short_id - = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "gl-button btn btn-default btn-icon", container: "body") + = clipboard_button(text: commit.id, category: :primary, size: :medium, title: _("Copy commit SHA")) = link_to_browse_code(project, commit) diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 4a29402bfe7..38633c9e5f1 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title s_("CompareRevisions|Compare revisions") -- page_title _("CompareRevisions|Compare revisions") +- page_title s_("CompareRevisions|Compare revisions") .prepend-top-20 #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, @compare_params) } diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml deleted file mode 100644 index 509ed62b39d..00000000000 --- a/app/views/projects/deployments/_commit.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -.table-mobile-content - .branch-commit.cgray - - if deployment.ref - %span.icon-container.gl-display-inline-block - = deployment.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('fork', css_class: 'sprite') - = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" - .icon-container.commit-icon - = custom_icon("icon_commit") - = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha mr-0" - - %p.commit-title.flex-truncate-parent - %span.flex-truncate-child - - if commit_title = deployment.commit_title - = author_avatar(deployment.commit, size: 20) - = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message cgray" - - else - = _("Can't find HEAD commit for this branch") diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml deleted file mode 100644 index e3688c8d323..00000000000 --- a/app/views/projects/deployments/_deployment.html.haml +++ /dev/null @@ -1,49 +0,0 @@ -.gl-responsive-table-row.deployment{ role: 'row' } - .table-section.section-15{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("Status") - .table-mobile-content - = render_deployment_status(deployment) - - .table-section.section-10{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("ID") - %strong.table-mobile-content{ data: { testid: 'deployment-id' } } ##{deployment.iid} - - .table-section.section-10{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("Triggerer") - .table-mobile-content - - if deployment.deployed_by - = user_avatar(user: deployment.deployed_by, size: 26, css_class: "mr-0 float-none") - - .table-section.section-25{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("Commit") - = render 'projects/deployments/commit', deployment: deployment - - .table-section.section-10.build-column{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("Job") - - if deployment.deployable - .table-mobile-content - .flex-truncate-parent - .flex-truncate-child.has-tooltip.gl-white-space-normal.gl-md-white-space-nowrap{ :title => "#{deployment.deployable.name} (##{deployment.deployable.id})", data: { container: 'body' } } - = link_to deployment_path(deployment), class: 'build-link' do - #{deployment.deployable.name} (##{deployment.deployable.id}) - - else - = gl_badge_tag s_('Deployment|API'), { variant: :info }, { class: 'gl-cursor-help', data: { toggle: 'tooltip' }, title: s_('Deployment|This deployment was created using the API') } - - .table-section.section-10{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("Created") - %span.table-mobile-content.flex-truncate-parent - %span.flex-truncate-child - = time_ago_with_tooltip(deployment.created_at) - - .table-section.section-10{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' }= _("Deployed") - - if deployment.deployed_at - %span.table-mobile-content.flex-truncate-parent - %span.flex-truncate-child - = time_ago_with_tooltip(deployment.deployed_at) - - .table-section.section-10.table-button-footer{ role: 'gridcell' } - .btn-group.table-action-buttons - = render 'projects/deployments/actions', deployment: deployment - = render 'projects/deployments/rollback', deployment: deployment - = render_if_exists 'projects/deployments/approvals', deployment: deployment diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml deleted file mode 100644 index e50fa1fa0f7..00000000000 --- a/app/views/projects/deployments/_rollback.haml +++ /dev/null @@ -1,4 +0,0 @@ -- if deployment.deployable && can?(current_user, :play_job, deployment.deployable) - - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') - - icon = deployment.last? ? 'repeat' : 'redo' - = render Pajamas::ButtonComponent.new(icon: icon, button_options: { title: tooltip, class: 'js-confirm-rollback-modal-button has-tooltip', data: { environment_name: @environment.name, commit_short_sha: deployment.short_sha, commit_url: project_commit_path(@project, deployment.sha), is_last_deployment: deployment.last?.to_s, retry_path: retry_project_job_path(@environment.project, deployment.deployable) } }) diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index afca27c5430..17e55699615 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -5,7 +5,7 @@ - if diff_file.submodule? %span - = sprite_icon('archive') + = sprite_icon('folder-git', file_icon: true) %strong.file-title-name = submodule_link(diff_file.blob, diff_file.content_sha, diff_file.repository) @@ -23,7 +23,7 @@ %strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.new_path, container: 'body' } } = new_path - else - %strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.file_path, container: 'body', qa_selector: 'file_name_content' } } + %strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.file_path, container: 'body', testid: 'file-name-content' } } = diff_file.file_path - if diff_file.deleted_file? diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 662f1bb158d..0158018ecc0 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -5,8 +5,6 @@ - reduce_visibility_form_id = 'reduce-visibility-form' - @force_desktop_expanded_sidebar = true -= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project - = render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'), alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| - c.with_body do @@ -91,8 +89,8 @@ .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title= _('Change path') %p.gl-new-card-description - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') } - = _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) - c.with_body do = render 'projects/errors' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index e97cae911d9..46ec430cadb 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -8,33 +8,4 @@ #environments-detail-view{ data: { details: environments_detail_data_json(current_user, @project, @environment) } } #environments-detail-view-header - - if Feature.enabled?(:environment_details_vue, @project) - #environment_details_page - - else - .environments-container - - if @deployments.blank? - .empty-state - .text-content - %h4.state-title - = _("You don't have any deployments right now.") - %p - = html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - .text-center - = render Pajamas::ButtonComponent.new(variant: :confirm, href: help_page_path("ci/environments/index.md")) do - = _('Read more') - - - else - .table-holder.gl-overflow-visible - .ci-table.environments{ role: 'grid' } - .gl-responsive-table-row.table-row-header{ role: 'row' } - .table-section.section-15{ role: 'columnheader' }= _('Status') - .table-section.section-10{ role: 'columnheader' }= _('ID') - .table-section.section-10{ role: 'columnheader' }= _('Triggerer') - .table-section.section-25{ role: 'columnheader' }= _('Commit') - .table-section.section-10{ role: 'columnheader' }= _('Job') - .table-section.section-10{ role: 'columnheader' }= _('Created') - .table-section.section-10{ role: 'columnheader' }= _('Deployed') - - = render @deployments - - = paginate @deployments, theme: 'gitlab' + #environment_details_page diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index 2eaf89be4ef..f8025f7c462 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -1,7 +1,7 @@ - add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path - page_title _('Webhook Logs') -%h1.page-title.gl-font-size-h-display +%h2.page-title.gl-font-size-h-display = _("Request details") %hr diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml index 6d733dc46df..400c07835cd 100644 --- a/app/views/projects/incidents/show.html.haml +++ b/app/views/projects/incidents/show.html.haml @@ -1,9 +1,17 @@ -- @content_class = "limit-container-width" unless fluid_layout - add_to_breadcrumbs _("Incidents"), project_incidents_path(@project) - breadcrumb_title @issue.to_reference + - page_title "#{@issue.title} (#{@issue.to_reference})", _("Incidents") +- page_description @issue.description_html +- page_card_attributes @issue.card_attributes +- if @issue.relocation_target + - page_canonical_link @issue.relocation_target.present(current_user: current_user).web_url + - add_page_specific_style 'page_bundles/design_management' - add_page_specific_style 'page_bundles/incidents' +- add_page_specific_style 'page_bundles/issuable' - add_page_specific_style 'page_bundles/issues_show' -= render 'projects/issuable/show', issuable: @issue +- @content_class = "limit-container-width" unless fluid_layout + += render 'projects/issues/details_content', issuable: @issue diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml deleted file mode 100644 index e502457808d..00000000000 --- a/app/views/projects/issuable/_show.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- api_awards_path = local_assigns.fetch(:api_awards_path, nil) -- page_description issuable.description_html -- page_card_attributes issuable.card_attributes -- if issuable.relocation_target - - page_canonical_link issuable.relocation_target.present(current_user: current_user).web_url -- add_page_specific_style 'page_bundles/issuable' - -= render "projects/issues/service_desk/alert_moved_from_service_desk", issue: issuable - -= render 'shared/issue_type/details_content', issuable: issuable, api_awards_path: api_awards_path diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/projects/issues/_details_content.html.haml index 249e296b41a..51ffb68f4e5 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/projects/issues/_details_content.html.haml @@ -1,14 +1,12 @@ - related_branches_path = related_branches_project_issue_path(@project, issuable) - api_awards_path = local_assigns.fetch(:api_awards_path, nil) += render "projects/issues/service_desk/alert_moved_from_service_desk", issue: issuable + .issue-details.issuable-details.js-issue-details .detail-page-description.content-block.js-detail-page-description.gl-pt-3.gl-pb-0.gl-border-none #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, - header_actions_data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar).to_json, - issuable_id: issuable.id, - full_path: @project.full_path, - register_path: new_user_registration_path(redirect_to_referer: 'yes'), - sign_in_path: new_session_path(:user, redirect_to_referer: 'yes') } } + header_actions_data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar).to_json } } .title-container %h1.title.page-title.gl-font-size-h-display= markdown_field(issuable, :title) - if issuable.description.present? @@ -18,10 +16,10 @@ = edited_time_ago_with_tooltip(issuable, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') .js-issue-widgets - = render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path + = render 'projects/issues/emoji_block', issuable: issuable, api_awards_path: api_awards_path .js-issue-widgets - = render 'shared/issue_type/sentry_stack_trace', issuable: issuable + = render 'projects/issues/sentry_stack_trace', issuable: issuable = render 'projects/issues/design_management' @@ -29,7 +27,10 @@ = render_if_exists 'projects/issues/linked_resources' = render 'projects/issues/related_issues' - #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } + #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), + has_closing_merge_request: (issuable.merge_requests_count(current_user) != 0).to_s, + project_namespace: @project.namespace.path, + project_path: @project.path } } - if can?(current_user, :read_code, @project) - add_page_startup_api_call related_branches_path diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/projects/issues/_emoji_block.html.haml index 7eb3c0f5c9f..7eb3c0f5c9f 100644 --- a/app/views/shared/issue_type/_emoji_block.html.haml +++ b/app/views/projects/issues/_emoji_block.html.haml diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml index 2409c61fbf2..73a88e63a4e 100644 --- a/app/views/projects/issues/_related_issues.html.haml +++ b/app/views/projects/issues/_related_issues.html.haml @@ -4,6 +4,7 @@ full_path: @project.full_path, has_issue_weights_feature: @project.licensed_feature_available?(:issue_weights).to_s, help_path: help_page_path('user/project/issues/related_issues'), + issuable_type: @issue.issue_type, show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s, has_iterations_feature: @project.licensed_feature_available?(:iterations).to_s, report_abuse_path: add_category_abuse_reports_path } } diff --git a/app/views/shared/issue_type/_sentry_stack_trace.html.haml b/app/views/projects/issues/_sentry_stack_trace.html.haml index 40b29a74b53..40b29a74b53 100644 --- a/app/views/shared/issue_type/_sentry_stack_trace.html.haml +++ b/app/views/projects/issues/_sentry_stack_trace.html.haml diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml index 2d17719a8c2..4453cb2e538 100644 --- a/app/views/projects/issues/service_desk.html.haml +++ b/app/views/projects/issues/service_desk.html.haml @@ -2,10 +2,11 @@ - page_title _("Service Desk") - add_page_specific_style 'page_bundles/issuable_list' +- add_page_specific_style 'page_bundles/issues_list' - content_for :breadcrumbs_extra do = render "projects/issues/service_desk/nav_btns", show_export_button: false, show_rss_button: false -- support_bot_attrs = { service_desk_enabled: @project.service_desk_enabled?, **UserSerializer.new.represent(User.support_bot) }.to_json +- support_bot_attrs = { service_desk_enabled: @project.service_desk_enabled?, **UserSerializer.new.represent(Users::Internal.support_bot) }.to_json .js-service-desk-issues.service-desk-issues{ data: { support_bot: support_bot_attrs } } - if ::Feature.enabled?(:service_desk_vue_list, @project) diff --git a/app/views/projects/issues/service_desk/_issue.html.haml b/app/views/projects/issues/service_desk/_issue.html.haml index 5b98712d3eb..66b2eabac9d 100644 --- a/app/views/projects/issues/service_desk/_issue.html.haml +++ b/app/views/projects/issues/service_desk/_issue.html.haml @@ -48,10 +48,10 @@ .issuable-meta %ul.controls - if issue.closed? && issue.moved? - %li.issuable-status + %li = render Pajamas::BadgeComponent.new(_('Closed (moved)'), size: 'sm', variant: 'info') - elsif issue.closed? - %li.issuable-status + %li = render Pajamas::BadgeComponent.new(_('Closed'), size: 'sm', variant: 'info') - if issue.assignees.any? %li.gl-display-flex diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 7e8bf4ae57f..457eaf5e194 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -1,10 +1,18 @@ -- @content_class = "limit-container-width" unless fluid_layout - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title @issue.to_reference + - page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues") +- page_description @issue.description_html +- page_card_attributes @issue.card_attributes +- if @issue.relocation_target + - page_canonical_link @issue.relocation_target.present(current_user: current_user).web_url + - add_page_specific_style 'page_bundles/design_management' - add_page_specific_style 'page_bundles/incidents' +- add_page_specific_style 'page_bundles/issuable' - add_page_specific_style 'page_bundles/issues_show' - add_page_specific_style 'page_bundles/work_items' -= render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue) +- @content_class = "limit-container-width" unless fluid_layout + += render 'projects/issues/details_content', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue) diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml deleted file mode 100644 index 0bb512b4035..00000000000 --- a/app/views/projects/jobs/_table.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -- admin = local_assigns.fetch(:admin, false) - -- if builds.blank? - - if @project - .row.empty-state - .col-12 - .svg-content.svg-250 - = image_tag('jobs-empty-state.svg') - .col-12 - .text-content.gl-text-center - %h4 - = s_('Jobs|Use jobs to automate your tasks') - %p - = s_('Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.') - = link_button_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'js-empty-state-button', variant: :confirm - - else - .nothing-here-block= s_('Jobs|No jobs to show') -- else - .table-holder - %table.table.ci-table.builds-page - %thead - %tr - %th= _('Status') - %th= _('Name') - %th= _('Job') - %th= _('Pipeline') - - if admin - %th= _('Project') - %th= _('Runner') - %th= _('Stage') - %th= _('Duration') - %th= _('Coverage') - %th - - = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin } - - = paginate_collection(builds) diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml index 4f4609e6016..ce8b3f70204 100644 --- a/app/views/projects/labels/edit.html.haml +++ b/app/views/projects/labels/edit.html.haml @@ -1,8 +1,9 @@ - add_to_breadcrumbs _("Labels"), project_labels_path(@project) - breadcrumb_title _("Edit") - page_title _("Edit"), @label.name, _("Labels") +- show_lock_on_merge = @project.supports_lock_on_merge? %h1.page-title.gl-font-size-h-display = _('Edit Label') -= render 'shared/labels/form', url: project_label_path(@project, @label), back_path: project_labels_path(@project) += render 'shared/labels/form', url: project_label_path(@project, @label), back_path: project_labels_path(@project), show_lock_on_merge: show_lock_on_merge diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 8855e8024b3..4b27b344498 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -3,7 +3,6 @@ - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project - if labels_or_filters #js-promote-label-modal diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml index 4cab6fac388..bfa33f26453 100644 --- a/app/views/projects/merge_requests/_code_dropdown.html.haml +++ b/app/views/projects/merge_requests/_code_dropdown.html.haml @@ -1,6 +1,6 @@ .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', qa_selector: 'mr_code_dropdown' } do + = 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!" .dropdown-menu.dropdown-menu-right @@ -16,7 +16,7 @@ = _('Check out branch') - if current_user %li.gl-dropdown-item - = link_to ide_merge_request_path(@merge_request), class: 'dropdown-item', target: '_blank', data: { qa_selector: 'open_in_web_ide_button' } do + = link_to ide_merge_request_path(@merge_request), class: 'dropdown-item', target: '_blank', data: { testid: 'open-in-web-ide-button' } do .gl-dropdown-item-text-wrapper = _('Open in Web IDE') - if Gitlab::CurrentSettings.gitpod_enabled && current_user&.gitpod_enabled @@ -30,10 +30,10 @@ %header.dropdown-header = _('Download') %li.gl-dropdown-item - = link_to merge_request_path(@merge_request, format: :patch), class: 'dropdown-item', download: '', data: { qa_selector: 'download_email_patches_menu_item' } do + = link_to merge_request_path(@merge_request, format: :patch), class: 'dropdown-item', download: '', data: { testid: 'download-email-patches-menu-item' } do .gl-dropdown-item-text-wrapper = _('Patches') %li.gl-dropdown-item - = link_to merge_request_path(@merge_request, format: :diff), class: 'dropdown-item', download: '', data: { qa_selector: 'download_plain_diff_menu_item' } do + = link_to merge_request_path(@merge_request, format: :diff), class: 'dropdown-item', download: '', data: { testid: 'download-plain-diff-menu-item' } do .gl-dropdown-item-text-wrapper = _('Plain diff') diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 7b815d996e0..4a7aa9a86ab 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -45,8 +45,10 @@ .issuable-meta %ul.controls.d-flex.align-items-end - if merge_request.merged? + - merged_at = merge_request.merged_at ? l(merge_request.merged_at.to_time) : _("Merge date & time could not be determined") %li.d-none.d-sm-flex - = render Pajamas::BadgeComponent.new(_('Merged'), size: 'sm', variant: 'info') + %a.has-tooltip{ href: "#{merge_request_path(merge_request)}#widget-state", title: merged_at } + = render Pajamas::BadgeComponent.new(_('Merged'), size: 'sm', variant: 'info') - elsif merge_request.closed? %li.d-none.d-sm-flex = render Pajamas::BadgeComponent.new(_('Closed'), size: 'sm', variant: 'danger') diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index dfa582f4c60..f0e7df8a379 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -15,7 +15,7 @@ .detail-page-header.border-bottom-0.gl-display-block.gl-pt-5{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } .detail-page-header-body .issuable-meta.gl-display-flex - #js-issuable-header-warnings{ data: { hidden: @merge_request.hidden?.to_s } } + .js-header-metadata-root{ data: { hidden: @merge_request.hidden?.to_s } } %h1.title.page-title.gl-font-size-h-display.gl-my-0.gl-display-inline-block{ data: { qa_selector: 'title_content' } } = markdown_field(@merge_request, :title) diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 69e2487152e..dfb18b52021 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -28,12 +28,12 @@ .merge-request-tabs-holder{ class: "#{'js-tabs-affix' unless ENV['RAILS_ENV'] == 'test'} #{'gl-static' if moved_mr_sidebar_enabled?}" } .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between{ class: "#{'is-merge-request' if Feature.enabled?(:moved_mr_sidebar, @project) && !fluid_layout}" } %ul.merge-request-tabs.nav-tabs.nav.nav-links.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0{ class: "#{'gl-w-full gl-lg-w-auto!' if Feature.enabled?(:moved_mr_sidebar, @project)}" } - = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do + = render "projects/merge_requests/tabs/tab", class: "notes-tab", testid: "notes-tab" do = tab_link_for @merge_request, :show, force_link: @commit.present? do = _("Overview") = gl_badge_tag @merge_request.related_notes.user.count, { size: :sm }, { class: 'js-discussions-count' } - if @merge_request.source_project - = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do + = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", testid: "commits-tab" do = tab_link_for @merge_request, :commits do = _("Commits") = gl_badge_tag tab_count_display(@merge_request, @commits_count), { size: :sm }, { class: 'js-commits-count' } @@ -42,7 +42,7 @@ = tab_link_for @merge_request, :pipelines do = _("Pipelines") = gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' } - = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do + = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", testid: "diffs-tab" do = tab_link_for @merge_request, :diffs do = _("Changes") = gl_badge_tag tab_count_display(@merge_request, @diffs_count), { size: :sm } @@ -61,6 +61,7 @@ = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do %div{ class: "#{'merge-request-overview' if moved_mr_sidebar_enabled?}" } %section + = render_if_exists "projects/merge_requests/diff_summary" .issuable-discussion.js-vue-notes-event - if @merge_request.description.present? .detail-page-description.gl-pb-0 diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 07bae4d2396..e6bd0b05f00 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -14,7 +14,7 @@ = gitlab_ui_form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| - if params[:nav_source].present? = hidden_field_tag(:nav_source, params[:nav_source]) - .js-merge-request-new-compare.row{ 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) } + .js-merge-request-new-compare.row{ data: mr_compare_form_data(current_user, @merge_request) } .col-lg-6 .card-new-merge-request %h2.gl-font-size-h2 @@ -31,4 +31,4 @@ = form_errors(@merge_request) .row .col-12 - = f.submit _('Compare branches and continue'), data: { qa_selector: 'compare_branches_button' }, pajamas_button: true + = f.submit _('Compare branches and continue'), data: { testid: 'compare-branches-button' }, pajamas_button: true diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index a7151421acb..996928ba377 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -50,7 +50,7 @@ = _("Pipelines") = gl_badge_tag @pipelines.size, { size: :sm }, { class: 'gl-tab-counter-badge' } %li.diffs-tab - = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do + = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', testid: 'diffs-tab'} do = _("Changes") = gl_badge_tag @merge_request.diff_size, { size: :sm }, { class: 'gl-tab-counter-badge' } diff --git a/app/views/projects/merge_requests/tabs/_tab.html.haml b/app/views/projects/merge_requests/tabs/_tab.html.haml index 9d942da8098..f6c8f4cd87b 100644 --- a/app/views/projects/merge_requests/tabs/_tab.html.haml +++ b/app/views/projects/merge_requests/tabs/_tab.html.haml @@ -1,8 +1,8 @@ - tab_name = local_assigns.fetch(:name, nil) - tab_class = local_assigns.fetch(:class, nil) -- qa_selector = local_assigns.fetch(:qa_selector, nil) +- testid = local_assigns.fetch(:testid, nil) - id = local_assigns.fetch(:id, nil) -- attrs = { class: [tab_class, ("active" if params[:tab] == tab_name)], data: { qa_selector: qa_selector } } +- attrs = { class: [tab_class, ("active" if params[:tab] == tab_name)], data: { testid: testid } } - attrs[:id] = id if id.present? %li{ attrs } diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index a592062a17d..abf2949938c 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -15,7 +15,7 @@ - @gfm_form = true .js-markdown-editor{ data: { render_markdown_path: preview_markdown_path(@project), markdown_docs_path: help_page_path('user/markdown'), - qa_selector: 'milestone_description_field', + testid: 'milestone-description-field', form_field_placeholder: _('Write milestone description...'), supports_quick_actions: 'false', enable_autocomplete: 'true', diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 54e1f1a8b20..c4cf128a62a 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -1,14 +1,17 @@ - mirror = f.object -- auth_options = [[_('Password'), 'password'], [_('SSH public key'), 'ssh_public_key']] +- auth_options = [[_('Username and Password'), 'password'], [_('SSH public key'), 'ssh_public_key']] .form-group = f.label :auth_method, _('Authentication method'), class: 'label-bold' = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), - {}, { class: "custom-select gl-form-select js-mirror-auth-type", data: { qa_selector: 'authentication_method_field' } } + {}, { class: "custom-select gl-form-select js-mirror-auth-type gl-max-w-34 gl-display-block", data: { qa_selector: 'authentication_method_field' } } = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" .form-group - .well-password-auth.collapse.js-well-password-auth + = f.label :user, _('Username'), class: 'label-bold' + = f.text_field :user, class: 'form-control gl-form-input gl-form-input-xl', value: nil, autocomplete: 'off', required: false, autocorrect: 'off', autocapitalize: 'off', spellcheck: false, data: { testid: 'username-field' } +.well-password-auth.collapse.js-well-password-auth + .form-group = f.label :password, _("Password"), class: "label-bold" - = f.password_field :password, class: 'form-control gl-form-input js-mirror-password-field', autocomplete: 'off', data: { qa_selector: 'password_field' } + = f.password_field :password, class: 'form-control gl-form-input js-mirror-password-field gl-form-input-xl', autocomplete: 'off', data: { testid: 'password-field' } diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml index 2bd2c7cac44..5a8710a64b0 100644 --- a/app/views/projects/mirrors/_instructions.html.haml +++ b/app/views/projects/mirrors/_instructions.html.haml @@ -4,7 +4,7 @@ = html_escape(_('The repository must be accessible over %{code_open}http://%{code_close}, %{code_open}https://%{code_close}, %{code_open}ssh://%{code_close} or %{code_open}git://%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li= html_escape(_('When using the %{code_open}http://%{code_close} or %{code_open}https://%{code_close} protocols, please provide the exact URL to the repository. HTTP redirects will not be followed.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li= html_escape(_('Include the username in the URL if required: %{code_open}https://username@gitlab.company.com/group/project.git%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li= html_escape(_('Do not include the username in the URL, use the username field below if required: %{code_open}https://gitlab.company.com/group/project.git%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li - minutes = Gitlab.config.gitlab_shell.git_timeout / 60 = _("The update action will time out after %{number_of_minutes} minutes. For big repositories, use a clone/push combination.") % { number_of_minutes: minutes } diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index a1c89a9dd30..00837ce1c73 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -35,7 +35,7 @@ %div= form_errors(@project) .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { qa_selector: 'mirror_repository_url_field' } + = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url gl-form-input-xl', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { qa_selector: 'mirror_repository_url_field' } = render 'projects/mirrors/instructions' diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index 1322e677d5a..8378a74311f 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,7 +1,7 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' .select-wrapper - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-select select-control js-mirror-direction', disabled: true, data: { qa_selector: 'mirror_direction_field' } + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-select select-control js-mirror-direction gl-max-w-34 gl-display-block', disabled: true, data: { qa_selector: 'mirror_direction_field' } = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml index 0debd13709d..59611db941f 100644 --- a/app/views/projects/mirrors/_mirror_repos_list.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -34,7 +34,7 @@ - if mirror_settings_enabled .btn-group.mirror-actions-group{ role: 'group' } - if mirror.ssh_key_auth? - = clipboard_button(text: mirror.ssh_public_key, class: 'gl-button btn btn-default btn-icon', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button') + = clipboard_button(text: mirror.ssh_public_key, variant: :default, category: :primary, size: :medium, title: _('Copy SSH public key'), testid: 'copy_public_key_button') = render 'shared/remote_mirror_update_button', remote_mirror: mirror = render Pajamas::ButtonComponent.new(variant: :danger, icon: 'remove', diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 59a21cecd39..bf288d3601b 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -29,9 +29,8 @@ = render Pajamas::CardComponent.new(card_options: { class: 'gl-my-5' }) do |c| - c.with_body do %div - - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing' - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url } - = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', 'https://gitlab.com/gitlab-org/project-templates/contributing', target: '_blank', rel: 'noopener noreferrer') + = safe_format(_('Learn how to %{link_start}contribute to the built-in templates%{link_end}'), tag_pair(link, :link_start, :link_end)) = gitlab_ui_form_for @project, html: { class: 'new_project' } do |f| .project-template .form-group diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index e3cc9199352..54d1bf012f3 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -5,7 +5,7 @@ = render Pajamas::ButtonComponent.new(icon: 'ellipsis_v', category: :tertiary, button_options: { class: 'note-action-button more-actions-toggle has-tooltip', data: { title: 'More actions', toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' }}) %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left %li - = clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) + = deprecated_clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) - unless is_current_user .gl-ml-n2 .js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: note.author.id, reported_from_url: noteable_note_url(note) } } diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml index b1ec7a362b7..1aa8148dfed 100644 --- a/app/views/projects/pages/_pages_settings.html.haml +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -1,11 +1,7 @@ -- can_edit_max_page_size = can?(current_user, :update_max_pages_size) -- can_enforce_https_only = Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = gitlab_ui_form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f| - - if can_edit_max_page_size - = render_if_exists 'shared/pages/max_pages_size_input', form: f + = render_if_exists 'shared/pages/max_pages_size_input', form: f - - if can_enforce_https_only + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https .form-group = f.gitlab_ui_checkbox_component :pages_https_only, s_('GitLabPages|Force HTTPS (requires valid certificates)'), @@ -24,5 +20,14 @@ %p.gl-pl-6 = s_("GitLabPages|When enabled, a unique domain is generated to access pages.").html_safe + - if can?(current_user, :pages_multiple_versions, @project) + .form-group + = f.fields_for :project_setting do |settings| + = settings.gitlab_ui_checkbox_component :pages_multiple_versions_enabled, + s_('GitLabPages|Use multiple versions'), + label_options: { class: 'label-bold' } + %p.gl-pl-6 + = s_("GitLabPages|When enabled, you can create multiple versions of your pages site.").html_safe + .gl-mt-3 = f.submit s_('GitLabPages|Save changes'), pajamas_button: true diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml index 0edce28bb9d..9ca9360199d 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 - = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') + = deprecated_clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') %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 - = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') + = deprecated_clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') %p.form-text.text-muted - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', 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/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml deleted file mode 100644 index df85963218d..00000000000 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ /dev/null @@ -1,43 +0,0 @@ -= gitlab_ui_form_for [@project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f| - = form_errors(@schedule) - .form-group.row - .col-md-9 - = f.label :description, _('Description'), class: 'label-bold' - = f.text_field :description, class: 'form-control gl-form-input', required: true, autofocus: true, placeholder: s_('PipelineSchedules|Provide a short description for this pipeline') - .form-group.row - .col-md-9 - = f.label :cron, _('Interval Pattern'), class: 'label-bold' - #interval-pattern-input{ data: { initial_interval: @schedule.cron, daily_limit: @schedule.daily_limit } } - .form-group.row - .col-md-9{ data: { testid: 'schedule-timezone' } } - = f.label :cron_timezone, _("Cron Timezone") - .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @schedule.cron_timezone, name: 'schedule[cron_timezone]' } } - - .form-group.row - .col-md-9 - = f.label :ref, _('Target branch or tag'), class: 'label-bold' - %div{ data: { testid: 'schedule-target-ref' } } - .js-target-ref-dropdown{ data: { project_id: @project.id, default_branch: @project.default_branch } } - = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true - .form-group.row.js-ci-variable-list-section - .col-md-9 - %label.label-bold - #{ s_('PipelineSchedules|Variables') } - %ul.ci-variable-list - - @schedule.variables.each do |variable| - = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable - = render 'ci/variables/variable_row', form_field: 'schedule' - - if @schedule.variables.size > 0 - = render Pajamas::ButtonComponent.new(category: :secondary, variant: :confirm, button_options: { class: 'gl-mt-3 js-secret-value-reveal-button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" }}) do - - if @schedule.variables.size == 0 - = n_('Hide value', 'Hide values', @schedule.variables.size) - - else - = n_('Reveal value', 'Reveal values', @schedule.variables.size) - .form-group.row - .col-md-9 - = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold' - %div - = f.gitlab_ui_checkbox_component :active, _('Active'), checkbox_options: { value: @schedule.active, required: false } - .footer-block - = f.submit _('Save pipeline schedule'), pajamas_button: true - = link_button_to _('Cancel'), pipeline_schedules_path(@project) diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml deleted file mode 100644 index a050808f13c..00000000000 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ /dev/null @@ -1,45 +0,0 @@ -- if pipeline_schedule - %tr.pipeline-schedule-table-row - %td{ role: 'cell', data: { label: _('Description') } } - %div - = pipeline_schedule.description - %td.branch-name-cell.gl-text-truncate{ role: 'cell', data: { label: s_("PipelineSchedules|Target") } } - %div - - if pipeline_schedule.for_tag? - = sprite_icon('tag', size: 12, css_class: 'gl-vertical-align-middle!') - - else - = sprite_icon('fork', size: 12, css_class: 'gl-vertical-align-middle!') - - if pipeline_schedule.ref.present? - = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name" - %td{ role: 'cell', data: { label: _("Last Pipeline") } } - %div - - if pipeline_schedule.last_pipeline - .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } - = link_to project_pipeline_path(@project, pipeline_schedule.last_pipeline.id) do - = ci_icon_for_status(pipeline_schedule.last_pipeline.status) - %span.gl-text-blue-500! ##{pipeline_schedule.last_pipeline.id} - - else - = s_("PipelineSchedules|None") - %td.gl-text-gray-500{ role: 'cell', data: { label: s_("PipelineSchedules|Next Run") }, 'data-testid': 'next-run-cell' } - %div - - if pipeline_schedule.active? && pipeline_schedule.next_run_at - = time_ago_with_tooltip(pipeline_schedule.real_next_run) - - else - = s_("PipelineSchedules|Inactive") - %td{ role: 'cell', data: { label: _("Owner") } } - %div - - if pipeline_schedule.owner - = render Pajamas::AvatarComponent.new(pipeline_schedule.owner, size: 24, class: "gl-mr-2") - = link_to user_path(pipeline_schedule.owner) do - = pipeline_schedule.owner&.name - %td{ role: 'cell', data: { label: _('Actions') } } - .float-right.btn-group - - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) - = link_button_to nil, play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), icon: 'play' - - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) && pipeline_schedule.owner != current_user - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-take-ownership-button has-tooltip', title: s_('PipelineSchedule|Take ownership to edit'), data: { url: take_ownership_pipeline_schedule_path(pipeline_schedule) } }) do - = s_('PipelineSchedules|Take ownership') - - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) - = link_button_to nil, edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), icon: 'pencil' - - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) - = link_button_to nil, pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, aria: { label: _('Delete pipeline schedule') }, data: { confirm: _("Are you sure you want to delete this pipeline schedule?"), confirm_btn_variant: 'danger' }, variant: :danger, icon: 'remove' diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml deleted file mode 100644 index 2f96ac6a534..00000000000 --- a/app/views/projects/pipeline_schedules/_table.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.table-holder - %table.table.ci-table.responsive-table.b-table.gl-table.b-table-stacked-md{ role: 'table' } - %thead{ role: 'rowgroup' } - %tr{ role: 'row' } - %th.table-th-transparent.border-bottom{ role: 'cell', style: 'width: 34%' }= _("Description") - %th.table-th-transparent.border-bottom{ role: 'cell' }= s_("PipelineSchedules|Target") - %th.table-th-transparent.border-bottom{ role: 'cell' }= _("Last Pipeline") - %th.table-th-transparent.border-bottom{ role: 'cell' }= s_("PipelineSchedules|Next Run") - %th.table-th-transparent.border-bottom{ role: 'cell' }= _("Owner") - %th.table-th-transparent.border-bottom{ role: 'cell' } - %tbody{ role: 'rowgroup' } - = render partial: "pipeline_schedule", collection: @schedules diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml deleted file mode 100644 index f825ef35902..00000000000 --- a/app/views/projects/pipeline_schedules/_tabs.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -= gl_tabs_nav({ class: 'gl-display-flex gl-flex-grow-1 gl-border-0' }) do - = gl_tab_link_to schedule_path_proc.call(nil), { item_active: active_when(scope.nil?) } do - = s_("PipelineSchedules|All") - = gl_tab_counter_badge(number_with_delimiter(all_schedules.count(:id)), { class: 'js-totalbuilds-count' }) - - = gl_tab_link_to schedule_path_proc.call('active'), { item_active: active_when(scope == 'active') } do - = s_("PipelineSchedules|Active") - = gl_tab_counter_badge(number_with_delimiter(all_schedules.active.count(:id))) - - = gl_tab_link_to schedule_path_proc.call('inactive'), { item_active: active_when(scope == 'inactive') } do - = s_("PipelineSchedules|Inactive") - = gl_tab_counter_badge(number_with_delimiter(all_schedules.inactive.count(:id))) diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml index 4e1ae53a101..647c0272852 100644 --- a/app/views/projects/pipeline_schedules/edit.html.haml +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -1,12 +1,8 @@ - add_to_breadcrumbs _("Schedules"), pipeline_schedules_path(@project) - breadcrumb_title "##{@schedule.id}" - page_title _("Edit"), @schedule.description, _("Pipeline Schedule") -- add_page_specific_style 'page_bundles/pipeline_schedules' %h1.page-title.gl-font-size-h-display = _("Edit Pipeline Schedule") -- if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-edit{ data: js_pipeline_schedules_form_data(@project, @schedule) } -- else - = render "form" +#pipeline-schedules-form-edit{ data: js_pipeline_schedules_form_data(@project, @schedule) } diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 5051fc6a5f5..15a80b6c7b1 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -1,28 +1,4 @@ - breadcrumb_title _("Schedules") - page_title _("Pipeline Schedules") -- add_page_specific_style 'page_bundles/pipeline_schedules' -- add_page_specific_style 'page_bundles/ci_status' -- add_page_specific_style 'page_bundles/merge_request' -#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_url: image_path('illustrations/pipeline_schedule_callout.svg') } } - -- if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-app{ data: { full_path: @project.full_path, pipelines_path: project_pipelines_path(@project), new_schedule_path: new_project_pipeline_schedule_path(@project) } } -- else - .top-area - - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } - = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope - - - if can?(current_user, :create_pipeline_schedule, @project) - .nav-controls - = link_button_to new_project_pipeline_schedule_path(@project), variant: :confirm do - = _('New schedule') - - - if @schedules.present? - %ul.content-list - = render partial: "table" - - else - .nothing-here-block - = _("No schedules") - - #pipeline-take-ownership-modal +#pipeline-schedules-app{ data: { full_path: @project.full_path, pipelines_path: project_pipelines_path(@project), new_schedule_path: new_project_pipeline_schedule_path(@project) } } diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml index ef99a79b06f..2cd65521ae9 100644 --- a/app/views/projects/pipeline_schedules/new.html.haml +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -1,14 +1,10 @@ - breadcrumb_title _('Schedules') - @breadcrumb_link = namespace_project_pipeline_schedules_path(@project.namespace, @project) - page_title _("New Pipeline Schedule") -- add_page_specific_style 'page_bundles/pipeline_schedules' - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project)) %h1.page-title.gl-font-size-h-display = _("Schedule a new pipeline") -- if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-new{ data: js_pipeline_schedules_form_data(@project, @schedule) } -- else - = render "form" +#pipeline-schedules-form-new{ data: js_pipeline_schedules_form_data(@project, @schedule) } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 6b6aaaad802..3eb24873daf 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,8 +1,6 @@ - add_page_specific_style 'page_bundles/members' - page_title _("Members") -= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project - .row.gl-mt-3 .col-lg-12 .gl-display-flex.gl-flex-wrap @@ -26,7 +24,7 @@ - if can_admin_project_member?(@project) .js-invite-members-trigger{ data: { variant: 'confirm', classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3', - trigger_source: 'project-members-page', + trigger_source: 'project_members_page', display_text: _('Invite members') } } - else - if project_can_be_shared? diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index ef3974b04b5..1dc31503db9 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -1,9 +1,5 @@ - content_for :create_access_levels do .create_access_levels-container - = dropdown_tag('Select', - options: { toggle_class: 'js-allowed-to-create js-multiselect wide', - dropdown_class: 'dropdown-menu-selectable capitalize-header', - dropdown_qa_selector: 'access_levels_content', dropdown_testid: 'allowed-to-create-dropdown', - data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes', qa_selector: 'access_levels_dropdown' }}) + .js-allowed-to-create = render 'projects/protected_tags/shared/create_protected_tag' diff --git a/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml b/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml index 30b9e3e9005..389a88293a5 100644 --- a/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml +++ b/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml @@ -1,8 +1,5 @@ - protected_tag = local_assigns.fetch(:protected_tag) - create_access_level = local_assigns.fetch(:create_access_level) -- dropdown_label = create_access_level.first&.humanize || 'Select' = hidden_field_tag "allowed_to_create_#{protected_tag.id}", create_access_level.first&.access_level -= dropdown_tag(dropdown_label, - options: { toggle_class: 'js-allowed-to-create js-multiselect', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', - data: { field_name: "allowed_to_create_#{protected_tag.id}", preselected_items: access_levels_data(create_access_level) }}) +.js-allowed-to-create{ data: { preselected_items: access_levels_data(create_access_level).to_json } } diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index b81c3bc9704..ea3ad370fb5 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -18,9 +18,8 @@ = _('Project access token creation is disabled in this group.') - root_group = @project.group.root_ancestor - if current_user.can?(:admin_group, root_group) - - group_settings_link = edit_group_path(root_group) - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link } - = _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', edit_group_path(root_group), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_('You can enable project access token creation in %{link_start}group settings%{link_end}.'), tag_pair(link, :link_start, :link_end)) = html_escape(_('You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe } #js-new-access-token-app{ data: { access_token_type: type } } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 6de39058455..17953e3bc14 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -25,9 +25,9 @@ %p.gl-text-secondary - auto_devops_url = help_page_path('topics/autodevops/index') - quickstart_url = help_page_path('topics/autodevops/cloud_deployments/auto_devops_with_gke') - - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } - = s_('AutoDevOps|%{auto_devops_start}Automate building, testing, and deploying%{auto_devops_end} your applications based on your continuous integration and delivery configuration. %{quickstart_start}How do I get started?%{quickstart_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } + - auto_devops_link = link_to('', auto_devops_url, target: '_blank', rel: 'noopener noreferrer') + - quickstart_link = link_to('', quickstart_url, target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AutoDevOps|%{auto_devops_start}Automate building, testing, and deploying%{auto_devops_end} your applications based on your continuous integration and delivery configuration. %{quickstart_start}How do I get started?%{quickstart_end}'), tag_pair(auto_devops_link, :auto_devops_start, :auto_devops_end), tag_pair(quickstart_link, :quickstart_start, :quickstart_end)) .settings-content = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled? @@ -44,10 +44,7 @@ = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") = link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer' .settings-content - - if Feature.enabled?(:project_runners_vue_ui, @project) - #js-project-runners{ data: { project_full_path: @project.full_path } } - - else - = render 'projects/runners/settings' + = render 'projects/runners/settings' - if Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact? %section.settings.no-animate#js-artifacts-settings{ class: ('expanded' if expanded) } diff --git a/app/views/projects/settings/integrations/_form.html.haml b/app/views/projects/settings/integrations/_form.html.haml index 97c7729de44..d2df01c22bb 100644 --- a/app/views/projects/settings/integrations/_form.html.haml +++ b/app/views/projects/settings/integrations/_form.html.haml @@ -15,9 +15,12 @@ = render 'shared/integrations/slack_notifications_deprecation_alert' %h2.gl-mb-0.gl-display-flex.gl-align-items-center.gl-gap-3 + = render Pajamas::AvatarComponent.new(integration, size: 64, alt: '') = integration.title - if integration.operating? - = render Pajamas::BadgeComponent.new(s_('FeatureFlags|Active'), variant: 'success') + = render Pajamas::BadgeComponent.new(_('Active'), variant: 'success', icon: 'status-success') + - elsif integration.persisted? + = render Pajamas::BadgeComponent.new(_('Inactive'), variant: 'neutral', icon: 'status-paused') = render 'shared/integration_settings', integration: integration - if lookup_context.template_exists?('show', "shared/integrations/#{integration.to_param}", true) diff --git a/app/views/projects/settings/integrations/index.html.haml b/app/views/projects/settings/integrations/index.html.haml index 6c0c99543cc..dca028d6167 100644 --- a/app/views/projects/settings/integrations/index.html.haml +++ b/app/views/projects/settings/integrations/index.html.haml @@ -6,7 +6,7 @@ %section.js-search-settings-section %h3= _('Integrations') - - integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/index') } - - webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) } - %p.gl-text-secondary= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe } + - integrations_link = link_to('', help_page_url('user/project/integrations/index')) + - webhooks_link = link_to('', project_hooks_path(@project)) + %p.gl-text-secondary= safe_format(_("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}."), tag_pair(integrations_link, :integrations_link_start, :link_end), tag_pair(webhooks_link, :webhooks_link_start, :link_end)) = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/projects/settings/merge_requests/_merge_request_merge_checks_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_checks_settings.html.haml new file mode 100644 index 00000000000..fa9b39e0846 --- /dev/null +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_checks_settings.html.haml @@ -0,0 +1,8 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge checks') + %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged.') + = render 'projects/settings/merge_requests/merge_request_pipelines_and_threads_options', form: form, project: @project + = render_if_exists 'projects/settings/merge_requests/merge_request_merge_checks_status_checks', form: form, project: @project + = render_if_exists 'projects/settings/merge_requests/merge_request_merge_checks_jira_enforcement', form: form, project: @project diff --git a/app/views/projects/_merge_request_merge_commit_template.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_commit_template.html.haml index 502014b7279..da1965f549c 100644 --- a/app/views/projects/_merge_request_merge_commit_template.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_commit_template.html.haml @@ -9,6 +9,5 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_COMMIT_TEMPLATE_LENGTH }) - - configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md') - - configure_the_merge_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_merge_commit_message_help_link_url } - = s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}').html_safe % { link_start: configure_the_merge_commit_message_help_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/merge_requests/commit_templates.md'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_method_settings.html.haml index dd32d3f9d92..dd32d3f9d92 100644 --- a/app/views/projects/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_method_settings.html.haml diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_options_settings.html.haml index e91c001ea3d..db7e59d6e2a 100644 --- a/app/views/projects/_merge_request_merge_options_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_options_settings.html.haml @@ -3,8 +3,8 @@ .form-group#project-merge-options{ data: { project_full_path: @project.full_path } } %b= s_('ProjectSettings|Merge options') %p.text-secondary= s_('ProjectSettings|Additional settings that influence how and when merges are done.') - = render_if_exists 'projects/merge_pipelines_settings', form: form - = render_if_exists 'projects/merge_trains_settings', form: form + = render_if_exists 'projects/settings/merge_requests/merge_pipelines_settings', form: form + = render_if_exists 'projects/settings/merge_requests/merge_trains_settings', form: form = form.gitlab_ui_checkbox_component :resolve_outdated_diff_discussions, s_('ProjectSettings|Automatically resolve merge request diff threads when they become outdated') = form.gitlab_ui_checkbox_component :printing_merge_request_link_enabled, diff --git a/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml new file mode 100644 index 00000000000..501288f727b --- /dev/null +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml @@ -0,0 +1,13 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge suggestions') + %p.text-secondary + = s_('ProjectSettings|The commit message used when applying merge request suggestions.') + .mb-2 + = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE + %p.form-text.text-muted + = s_('ProjectSettings|Leave empty to use default template.') + = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_SUGGESTIONS_TEMPLATE_LENGTH }) + - link = link_to('', help_page_path('user/project/merge_requests/reviews/suggestions.md', anchor: 'configure-the-commit-message-for-applied-suggestions'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml b/app/views/projects/settings/merge_requests/_merge_request_pipelines_and_threads_options.html.haml index a9609434f15..a9609434f15 100644 --- a/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_pipelines_and_threads_options.html.haml diff --git a/app/views/projects/settings/merge_requests/_merge_request_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_settings.html.haml new file mode 100644 index 00000000000..9449fc7f4ae --- /dev/null +++ b/app/views/projects/settings/merge_requests/_merge_request_settings.html.haml @@ -0,0 +1,18 @@ +- form = local_assigns.fetch(:form) + += render 'projects/settings/merge_requests/merge_request_merge_method_settings', project: @project, form: form + += render 'projects/settings/merge_requests/merge_request_merge_options_settings', project: @project, form: form + += render 'projects/settings/merge_requests/merge_request_squash_options_settings', form: form + += render 'projects/settings/merge_requests/merge_request_merge_checks_settings', project: @project, form: form + += render 'projects/settings/merge_requests/merge_request_merge_suggestions_settings', project: @project, form: form + += render 'projects/settings/merge_requests/merge_request_merge_commit_template', project: @project, form: form + += render 'projects/settings/merge_requests/merge_request_squash_commit_template', project: @project, form: form + +- if @project.forked? + = render 'projects/settings/merge_requests/merge_request_target_project_settings', project: @project, form: form diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/settings/merge_requests/_merge_request_settings_description_text.html.haml index 123520acad8..123520acad8 100644 --- a/app/views/projects/_merge_request_settings_description_text.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_settings_description_text.html.haml diff --git a/app/views/projects/_merge_request_squash_commit_template.html.haml b/app/views/projects/settings/merge_requests/_merge_request_squash_commit_template.html.haml index 4d1b89bea83..bc6530b927c 100644 --- a/app/views/projects/_merge_request_squash_commit_template.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_squash_commit_template.html.haml @@ -9,6 +9,5 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_COMMIT_TEMPLATE_LENGTH }) - - configure_the_squash_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md') - - configure_the_squash_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_squash_commit_message_help_link_url } - = s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}').html_safe % { link_start: configure_the_squash_commit_message_help_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/merge_requests/commit_templates.md'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/projects/_merge_request_squash_options_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_squash_options_settings.html.haml index 372c0723600..372c0723600 100644 --- a/app/views/projects/_merge_request_squash_options_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_squash_options_settings.html.haml diff --git a/app/views/projects/_merge_request_target_project_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_target_project_settings.html.haml index 6f2917f24e0..6f2917f24e0 100644 --- a/app/views/projects/_merge_request_target_project_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_target_project_settings.html.haml diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml index ef528d17fc9..01af028f30c 100644 --- a/app/views/projects/settings/merge_requests/show.html.haml +++ b/app/views/projects/settings/merge_requests/show.html.haml @@ -5,15 +5,16 @@ %section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } .settings-header %h4= _('Merge requests') - = render_if_exists 'projects/merge_request_settings_description_text' + = render_if_exists 'projects/settings/merge_requests/merge_request_settings_description_text' .settings-content = render_if_exists 'shared/promotions/promote_mr_features' = gitlab_ui_form_for @project, url: project_settings_merge_requests_path(@project), html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } - = render 'projects/merge_request_settings', form: f + = render 'projects/settings/merge_requests/merge_request_settings', form: f = f.submit _('Save changes'), class: "rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }, pajamas_button: true = render_if_exists 'projects/settings/merge_requests/merge_request_approvals_settings', expanded: true = render_if_exists 'projects/settings/merge_requests/suggested_reviewers_settings', expanded: true += render_if_exists 'projects/settings/merge_requests/target_branch_rules_settings' diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index 398c7758d66..c29cedd8250 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -3,7 +3,7 @@ - add_page_specific_style 'page_bundles/alert_management_settings' - add_page_specific_style 'page_bundles/incident_management_list' -%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded), data: { qa_selector: 'alerts_settings_content' } } +%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded), data: { testid: 'alerts-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Alerts') diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 8a35db357ee..c76fa5e2220 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -6,7 +6,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") -= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project = render_if_exists 'shared/promotions/promote_mobile_devops', project: @project = render partial: 'flash_messages', locals: { project: @project } diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 53c3d16ee64..281eac6c773 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -11,9 +11,8 @@ = s_('TagsPage|New Tag') %p.gl-text-secondary - - link_start = '<a href="%{url}">'.html_safe % { url: new_namespace_project_release_path } - - link_end = '</a>'.html_safe - = s_('TagsPage|Do you want to create a release with the new tag? You can do that in the %{link_start}New release page%{link_end}.').html_safe % { link_start: link_start, link_end: link_end } + - link = link_to('', new_namespace_project_release_path) + = safe_format(s_('TagsPage|Do you want to create a release with the new tag? You can do that in the %{link_start}New release page%{link_end}.'), tag_pair(link, :link_start, :link_end)) = form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "common-note-form tag-form js-quick-submit js-requires-input" do .form-group.row diff --git a/app/views/projects/tracing/index.html.haml b/app/views/projects/tracing/index.html.haml deleted file mode 100644 index ae6608cf343..00000000000 --- a/app/views/projects/tracing/index.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- page_title _('Tracing') - -#js-tracing{ data: { view_model: observability_tracing_view_model(@project) } } - diff --git a/app/views/projects/tracing/show.html.haml b/app/views/projects/tracing/show.html.haml deleted file mode 100644 index 4ba316a0b5c..00000000000 --- a/app/views/projects/tracing/show.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- page_title _('Trace Details') -- add_to_breadcrumbs _('Tracing'), project_tracing_index_path(@project) - -#js-tracing-details{ data: { view_model: observability_tracing_details_model(@project, @trace_id) } } - diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index 616af3f3338..6f2a2aacf66 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -1,9 +1,6 @@ - page_title s_("UsageQuota|Usage") -- add_page_specific_style 'page_bundles/projects_usage_quotas' - @force_desktop_expanded_sidebar = true -= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project - = render Pajamas::AlertComponent.new(title: _('Repository usage recalculation started'), variant: :info, alert_options: { class: 'js-recalculation-started-alert gl-mt-4 gl-mb-5 gl-display-none' }) do |c| diff --git a/app/views/protected_branches/_create_protected_branch.html.haml b/app/views/protected_branches/_create_protected_branch.html.haml index 799f6aa6031..bc5da1653bf 100644 --- a/app/views/protected_branches/_create_protected_branch.html.haml +++ b/app/views/protected_branches/_create_protected_branch.html.haml @@ -1,14 +1,9 @@ - content_for :merge_access_levels do .merge_access_levels-container - = dropdown_tag(_('Select'), - options: { toggle_class: 'js-allowed-to-merge wide', - dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown', - data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'select_allowed_to_merge_dropdown' }}) + .js-allowed-to-merge + - content_for :push_access_levels do .push_access_levels-container - = dropdown_tag(_('Select'), - options: { toggle_class: "js-allowed-to-push js-multiselect wide", - dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown', - data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'select_allowed_to_push_dropdown' }}) + .js-allowed-to-push = render 'protected_branches/shared/create_protected_branch', protected_branch_entity: protected_branch_entity diff --git a/app/views/protected_branches/shared/_index.html.haml b/app/views/protected_branches/shared/_index.html.haml index dccfefc1cb8..8e72563182c 100644 --- a/app/views/protected_branches/shared/_index.html.haml +++ b/app/views/protected_branches/shared/_index.html.haml @@ -13,6 +13,13 @@ .settings-content .js-alert-protected-branch-created-container.gl-mt-5 + = render Pajamas::AlertComponent.new(variant: :warning, + alert_options: { class: 'gl-mb-5' }, + dismissible: false) do |c| + - c.with_body do + = s_("ProtectedBranch|Giving merge rights to a protected branch also gives elevated permissions for certain CI/CD features.") + = link_to s_("ProtectedBranch|What are the security implications?"), help_page_path('ci/pipelines/index', anchor: 'pipeline-security-on-protected-branches'), target: '_blank', rel: 'noopener noreferrer' + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| - c.with_header do .gl-new-card-title-wrapper.gl-justify-content-space-between diff --git a/app/views/protected_branches/shared/_update_protected_branch.html.haml b/app/views/protected_branches/shared/_update_protected_branch.html.haml index ad61f557bb8..e4c8b779447 100644 --- a/app/views/protected_branches/shared/_update_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_update_protected_branch.html.haml @@ -3,17 +3,13 @@ %td.merge_access_levels-container = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level - = dropdown_tag((merge_access_levels.first&.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', - data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }}) + .js-allowed-to-merge{ data: { preselected_items: access_levels_data(merge_access_levels).to_json } } = render_if_exists 'protected_branches/shared/user_merge_access_levels', protected_branch: protected_branch = render_if_exists 'protected_branches/shared/group_merge_access_levels', protected_branch: protected_branch %td.push_access_levels-container = hidden_field_tag "allowed_to_push_#{protected_branch.id}", push_access_levels.first&.access_level - = dropdown_tag((push_access_levels.first&.humanize || 'Select') , - options: { toggle_class: "js-allowed-to-push js-multiselect", dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', - data: { field_name: "allowed_to_push_#{protected_branch.id}", preselected_items: access_levels_data(push_access_levels) }}) + .js-allowed-to-push{ data: { preselected_items: access_levels_data(push_access_levels).to_json } } = render_if_exists 'protected_branches/shared/user_push_access_levels', protected_branch: protected_branch = render_if_exists 'protected_branches/shared/group_push_access_levels', protected_branch: protected_branch diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index caaa209a702..0f161855cdb 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -33,7 +33,7 @@ = render_if_exists "registrations/welcome/jobs_to_be_done", f: f = render_if_exists "registrations/welcome/setup_for_company", f: f = render_if_exists "registrations/welcome/joining_project" - = render 'devise/shared/email_opted_in', f: f + = render_if_exists "registrations/welcome/opt_in_to_email" .row .form-group.col-sm-12.gl-mb-0 - if partial_exists? "registrations/welcome/button" diff --git a/app/views/repository_check_mailer/notify.html.haml b/app/views/repository_check_mailer/notify.html.haml index dfcd1c6b19f..d6c57b98ea9 100644 --- a/app/views/repository_check_mailer/notify.html.haml +++ b/app/views/repository_check_mailer/notify.html.haml @@ -7,4 +7,4 @@ %p = _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url } -= render_if_exists 'repository_check_mailer/email_additional_text' += render_if_exists 'shared/additional_email_text' diff --git a/app/views/repository_check_mailer/notify.text.haml b/app/views/repository_check_mailer/notify.text.haml index a2e04fa710f..dc316d3e2be 100644 --- a/app/views/repository_check_mailer/notify.text.haml +++ b/app/views/repository_check_mailer/notify.text.haml @@ -4,4 +4,4 @@ = _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url } -= render_if_exists 'repository_check_mailer/email_additional_text' += render_if_exists 'shared/additional_email_text' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 7399f51d7f8..a1839b3dd39 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,3 +1,3 @@ .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - = render partial: 'search/results_status' unless @search_objects.to_a.empty? + = render partial: 'search/results_status' = render partial: 'search/results_list' diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml index ff79f003e7d..fb96672cf99 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -8,7 +8,7 @@ - elsif @search_objects.blank? = render partial: "search/results/empty" - else - - statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : '' + - statusBarClass = !show_super_sidebar? ? 'gl-lg-pl-5' : '' .section{ class: statusBarClass } - if @scope == 'commits' diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index 6fc07d35296..8417b66eb34 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -1,28 +1,33 @@ -- return unless @search_service_presenter.show_results_status? -- statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : '' +- statusBarClass = !show_super_sidebar? ? 'gl-lg-pl-5' : '' +- statusBarClass = statusBarClass + ' gl-lg-display-none' if @search_objects.to_a.empty? .section{ class: statusBarClass } .search-results-status .gl-display-flex.gl-flex-direction-column - .gl-p-5.gl-display-flex - .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1.gl-white-space-nowrap.gl-max-w-full - - unless @search_service_presenter.without_count? - .gl-text-truncate - = search_entries_info(@search_objects, @scope, @search_term) - - unless @search_service_presenter.show_snippets? - - if @project - - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1 gl-text-truncate search-wrap-f-md-down') - - if @scope == 'blobs' - = _("in") - .mx-md-1 - #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } - = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - - else - = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - - elsif @group - - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') - = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - - if @search_service_presenter.show_sort_dropdown? - .gl-md-display-flex.gl-flex-direction-column - #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } - %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full + .gl-p-5.gl-display-flex.gl-flex-wrap + - unless @search_objects.to_a.empty? + .gl-display-flex.gl-text-left.gl-flex-grow-1.gl-flex-shrink-1.gl-white-space-nowrap.gl-flex-wrap.gl-sm-w-half + %p.gl-text-truncate.gl-my-auto + - unless @search_service_presenter.without_count? + = search_entries_info(@search_objects, @scope, @search_term) + - unless @search_service_presenter.show_snippets? + - if @project + - link_to_project = link_to(@project.full_name, @project, class: 'search-wrap-f-md-down') + - if @scope == 'blobs' + = _("in") + .mx-md-1.gl-my-auto + #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } + %p.gl-text-truncate.gl-my-auto + = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } + - else + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } + - elsif @group + - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } + .gl-display-flex.gl-my-3.gl-flex-grow-1.gl-flex-shrink-1.gl-justify-content-end + = render Pajamas::ButtonComponent.new(category: 'primary', icon: 'filter', button_options: {id: 'js-open-mobile-filters', class: 'gl-lg-display-none'}) do + = s_('GlobalSearch|Filters') + - if @search_service_presenter.show_sort_dropdown? && !@search_objects.to_a.empty? + .gl-ml-3 + #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } + %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 16ca829a6d4..2fd6e4a5ca5 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -22,7 +22,7 @@ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } } -.results.gl-md-display-flex.gl-mt-0 - #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } } +.results.gl-lg-display-flex.gl-mt-0 + #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json, search_type: search_service.search_type } } - if @search_term = render 'search/results' diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 48ae1f7eb1d..dde4ec3cf52 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -18,5 +18,5 @@ = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'clone_url_content' } .input-group-append - = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text gl-button btn-default btn-clipboard") + = clipboard_button(target: '#clone_url', title: _("Copy URL"), variant: :default, category: :primary, size: :medium) diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 5058455dcd7..05b376003bc 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -15,6 +15,9 @@ .gl-my-1 = markdown_field(label, :description) %ul.label-links.gl-m-0.gl-p-0.gl-white-space-nowrap + - if label.lock_on_merge + %li.inline.gl-mr-3.gl-mt-1 + .label-badge.gl-bg-orange-50= _('Lock on merge') - if force_priority %li.js-priority-badge.inline.gl-mr-3.gl-mt-1 .label-badge.gl-bg-blue-50= _('Prioritized') diff --git a/app/views/shared/_logo.svg b/app/views/shared/_logo.svg index 83f6fe5c16c..dfc35856366 100644 --- a/app/views/shared/_logo.svg +++ b/app/views/shared/_logo.svg @@ -1,4 +1,4 @@ -<svg class="tanuki-logo" width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg role="img" class="tanuki-logo" width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path class="tanuki-shape tanuki" d="m24.507 9.5-.034-.09L21.082.562a.896.896 0 0 0-1.694.091l-2.29 7.01H7.825L5.535.653a.898.898 0 0 0-1.694-.09L.451 9.411.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 2.56 1.935 1.554 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z" fill="#E24329"/> <path class="tanuki-shape right-cheek" d="m24.507 9.5-.034-.09a11.44 11.44 0 0 0-4.56 2.051l-7.447 5.632 4.742 3.584 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z" diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index aa3043b8fd6..c594cee326e 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -3,7 +3,7 @@ - http_copy_label = _('Copy %{http_label} clone URL') % { http_label: gitlab_config.protocol.upcase } .btn-group.mobile-git-clone.js-mobile-git-clone.btn-block - = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "gl-button btn-confirm flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label") + = clipboard_button(button_text: default_clone_label, size: :medium, category: :primary, variant: :confirm, text: default_url_to_repo(project), hide_button_icon: true, class: "clone-dropdown-btn js-clone-dropdown-label") %button.btn.gl-button.btn-confirm.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } } = sprite_icon("chevron-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml index 79d0231536b..e62e3bb4a6c 100644 --- a/app/views/shared/_outdated_browser.html.haml +++ b/app/views/shared/_outdated_browser.html.haml @@ -3,5 +3,5 @@ - c.with_body do = s_('OutdatedBrowser|GitLab may not work properly, because you are using an outdated web browser.') %br - - browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('install/requirements', anchor: 'supported-web-browsers') } - = s_('OutdatedBrowser|Please install a %{browser_link_start}supported web browser%{browser_link_end} for a better experience.').html_safe % { browser_link_start: browser_link_start, browser_link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('install/requirements', anchor: 'supported-web-browsers'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('OutdatedBrowser|Please install a %{browser_link_start}supported web browser%{browser_link_end} for a better experience.'), tag_pair(link, :browser_link_start, :browser_link_end)) diff --git a/app/views/shared/_silent_mode_banner.html.haml b/app/views/shared/_silent_mode_banner.html.haml new file mode 100644 index 00000000000..10e5d05fad2 --- /dev/null +++ b/app/views/shared/_silent_mode_banner.html.haml @@ -0,0 +1,9 @@ +- return unless ::Gitlab::SilentMode.enabled? + += content_for :page_level_alert do + %div{ class: [container_class, @content_class, 'gl-pt-5!'] } + = render Pajamas::AlertComponent.new(title: s_('SilentMode|Silent mode is enabled'), + dismissible: false, + variant: :warning) do |c| + - c.with_body do + = s_('SilentMode|All outbound communications are blocked. %{link_start}Learn more%{link_end}.').html_safe % { link_start: "<a href='#{help_page_path('administration/silent_mode/index')}' target='_blank' rel='noopener noreferrer'>".html_safe, link_end: '</a>'.html_safe } diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml index 763ae5a498b..3cf13222f4e 100644 --- a/app/views/shared/_visibility_level.html.haml +++ b/app/views/shared/_visibility_level.html.haml @@ -5,9 +5,8 @@ = f.label :visibility_level, _('Visibility level'), class: 'label-bold gl-mb-0' %p = _('Who can see this group?') - - visibility_docs_path = help_page_path('user/public_access') - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: visibility_docs_path } - = _('%{docs_link_start}Learn about visibility levels.%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/public_access'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_('%{docs_link_start}Learn about visibility levels.%{docs_link_end}'), tag_pair(link, :docs_link_start, :docs_link_end)) - if can_change_visibility_level = render('shared/visibility_radios', model_method: :visibility_level, form: f, selected_level: visibility_level, form_model: form_model) - else diff --git a/app/views/shared/builds/_build_output.html.haml b/app/views/shared/builds/_build_output.html.haml deleted file mode 100644 index a3b7d4926f8..00000000000 --- a/app/views/shared/builds/_build_output.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%pre.build-log - %code.bash.js-build-output - .build-loader-animation.js-build-refresh - .dot - .dot - .dot diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml deleted file mode 100644 index 8f2b9fc06e3..00000000000 --- a/app/views/shared/builds/_tabs.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -- count_badge_classes = 'gl-display-none gl-sm-display-inline-flex' - -= gl_tabs_nav({class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-border-b-0', data: { testid: 'jobs-tabs' } }) do - = gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do - = _('All') - = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds), { class: count_badge_classes }) - = gl_tab_link_to build_path_proc.call('pending'), { item_active: scope == 'pending' } do - = _('Pending') - = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.pending), { class: count_badge_classes }) - = gl_tab_link_to build_path_proc.call('running'), { item_active: scope == 'running' } do - = _('Running') - = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.running), { class: count_badge_classes }) - = gl_tab_link_to build_path_proc.call('finished'), { item_active: scope == 'finished' } do - = _('Finished') - = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.finished), { class: count_badge_classes }) diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index 650e50e0312..5188c530672 100644 --- a/app/views/shared/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -5,8 +5,8 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_keys/index') } - = _("Add deploy keys to grant read/write access to this repository. %{link_start}What are deploy keys?%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/deploy_keys/index'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("Add deploy keys to grant read/write access to this repository. %{link_start}What are deploy keys?%{link_end}"), tag_pair(link, :link_start, :link_end)) .settings-content = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| - c.with_body do diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index c633088b26a..bb4295779cd 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -1,30 +1,30 @@ -= gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f| += gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input" } do |f| = form_errors(@deploy_keys.new_key) - .form-group.row + .form-group %h4.gl-my-0= s_('DeployKeys|Add new deploy key') - .form-group.row + .form-group = f.label :title, class: "label-bold" - = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { testid: 'deploy-key-title-field' } - .form-group.row + = f.text_field :title, class: 'form-control gl-form-input gl-form-input-xl', required: true, data: { testid: 'deploy-key-title-field' } + .form-group = f.label :key, class: "label-bold" - = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true, data: { testid: 'deploy-key-field' } - .form-group.row - %p.light.gl-mb-0 + = f.text_area :key, class: 'form-control gl-form-input gl-form-input-xl gl-h-auto!', rows: 5, required: true, data: { testid: 'deploy-key-field' } + .form-text.text-muted = _('Paste a public key here.') = link_to _('How do I generate it?'), help_page_path("user/ssh") = f.fields_for :deploy_keys_projects do |deploy_keys_project_form| - .form-group.row + .form-group = deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'), help_text: _('Allow this key to push to this repository') - .form-group.row + .form-group = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' - = f.gitlab_ui_datepicker :expires_at, data: { testid: 'deploy-key-expires-at-field' }, value: f.object.expires_at - %p.form-text.text-muted= ssh_key_expires_field_description + .gl-form-input-xl + = f.gitlab_ui_datepicker :expires_at, data: { testid: 'deploy-key-expires-at-field' }, value: f.object.expires_at + .form-text.text-muted= ssh_key_expires_field_description - .form-group.row.gl-mb-0 + .form-group.gl-mb-0 = f.submit _("Add key"), data: { testid: "add-deploy-key-button"}, pajamas_button: true = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-3 js-toggle-button' }) do = _('Cancel') diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 8821804ce6b..bb7e0d774cc 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -1,7 +1,6 @@ %p - - group_deploy_tokens_help_link_url = help_page_path('user/project/deploy_tokens/index.md') - - group_deploy_tokens_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_deploy_tokens_help_link_url } - = s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/deploy_tokens/index.md'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}'), tag_pair(link, :link_start, :link_end)) = gitlab_ui_form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: true do |f| diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml index 9c82d5685f8..25c277ea0ea 100644 --- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml @@ -7,7 +7,7 @@ .input-group = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { qa_selector: 'deploy_token_user_field' } .input-group-append - = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left') + = 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.md') } - link_end = "</a>".html_safe @@ -17,7 +17,7 @@ .input-group = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { qa_selector: 'deploy_token_field' } .input-group-append - = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left') + = 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 diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index 4101a456f32..d309a335166 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -10,7 +10,7 @@ .input-group %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true, data: { qa_selector: 'application_id_field' } } .input-group-append - = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default") + = clipboard_button(target: "#application_id", title: _("Copy ID"), category: :primary, size: :medium) %tr %td = _('Secret') diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 75f678dea5c..b05befce24e 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -21,7 +21,7 @@ .js-markdown-editor{ data: { render_markdown_path: preview_url, markdown_docs_path: help_page_path('user/markdown'), quick_actions_docs_path: help_page_path('user/project/quick_actions'), - qa_selector: 'issuable_form_description_field', + testid: 'issuable-form-description-field', form_field_placeholder: placeholder, autofocus: 'false', form_field_classes: 'js-gfm-input markdown-area note-textarea rspec-issuable-form-description' } } diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml deleted file mode 100644 index aaba9697fea..00000000000 --- a/app/views/shared/groups/_empty_state.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -.row.gl-align-items-center.gl-justify-content-center - .order-md-2 - = custom_icon("icon_empty_groups") - - .text-content.order-md-1{ class: 'gl-m-0!' } - %h4= s_("GroupsEmptyState|A group is a collection of several projects.") - %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.") - %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.") diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml index 1971c2da913..e8cb93f8037 100644 --- a/app/views/shared/hook_logs/_content.html.haml +++ b/app/views/shared/hook_logs/_content.html.haml @@ -1,6 +1,6 @@ %span.gl-display-flex.gl-align-items-center - %h4 - POST + %h5 + = gl_badge_tag "POST", { size: :sm }, { variant: :info } = hook_log.url = gl_badge_tag hook_log.trigger.singularize.titleize, { size: :sm }, { class: 'gl-ml-3' } @@ -16,19 +16,28 @@ - c.with_body do = _('Error: %{error}') % { error: hook_log.internal_error_message } -%h4= _('Response') -= render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } +%span.gl-display-flex.gl-align-items-center + %h3 + = _('Response') + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + %pre.gl-mt-3 - :escaped - #{hook_log.response_body} + - if hook_log.response_body.blank? + = s_('Webhooks|Response body is empty') + - else + :escaped + #{hook_log.response_body} -%h5= _('Headers') +%h4= _('Headers') %pre - - hook_log.response_headers.each do |k, v| - <span class="gl-font-weight-bold">#{k}:</span> #{v} - %br + - if hook_log.response_headers.blank? + = s_('Webhooks|Response headers data is empty') + - else + - hook_log.response_headers.each do |k, v| + <span class="gl-font-weight-bold">#{k}:</span> #{v} + %br -%h4.gl-mt-6= _('Request') +%h3.gl-mt-6= _('Request') %pre - if hook_log.oversize? = _('Request data is too large') @@ -36,7 +45,7 @@ :escaped #{Gitlab::Json.pretty_generate(hook_log.request_data)} -%h5= _('Headers') +%h4= _('Headers') %pre - hook_log.request_headers.each do |k, v| <span class="gl-font-weight-bold">#{k}:</span> #{v} diff --git a/app/views/shared/icons/_icon_empty_groups.svg b/app/views/shared/icons/_icon_empty_groups.svg deleted file mode 100644 index cf378145e59..00000000000 --- a/app/views/shared/icons/_icon_empty_groups.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="249" height="368" viewBox="891 156 249 368" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="131" height="162" rx="10"/><mask id="e" x="0" y="0" width="131" height="162" fill="#fff"><use xlink:href="#a"/></mask><path d="M223.616 127.958V108.96c0-4.416-3.584-8-8.005-8h-23.985c-2.778 0-5.98 2.014-7.18 4.5l-5.07 10.5h-49.763c-5.527 0-9.996 4.475-9.996 9.997v53.005c0 5.513 4.475 9.997 9.996 9.997h84.01c5.525 0 9.994-4.477 9.994-9.998v-51.004z" id="b"/><mask id="f" x="0" y="0" width="104" height="88" fill="#fff"><use xlink:href="#b"/></mask><path d="M47 25h.996C53.52 25 58 29.472 58 34.99v20.02C58 60.526 53.52 65 47.996 65H10.004C4.48 65 0 60.528 0 55.01V34.99C0 29.474 4.48 25 10.004 25H11v-7c0-9.94 8.06-18 18-18s18 8.06 18 18v7zm-6 0H17v-7c0-6.627 5.373-12 12-12s12 5.373 12 12v7z" id="c"/><mask id="g" x="0" y="0" width="58" height="65" fill="#fff"><use xlink:href="#c"/></mask><path d="M0 10.008C0 4.48 4.476 0 10 0h218c5.523 0 10 4.473 10 10.008v140.94c0 5.53-4.062 11.882-9.08 14.196l-100.84 46.5c-5.015 2.31-13.142 2.312-18.16 0l-100.84-46.5C4.064 162.832 0 156.484 0 150.95V10.007z" id="d"/><mask id="h" x="0" y="0" width="238" height="213.417" fill="#fff"><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(891 156)"><g transform="rotate(8 -266.528 490.3)"><use stroke="#E5E5E5" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#a"/><rect fill="#FC8A51" x="20" y="31" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="60" y="31" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="36" y="31" width="20" height="4" rx="2"/><rect fill="#6B4FBB" x="20" y="65" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="44" y="65" width="20" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="80" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="80" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="48" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="60" y="80" width="12" height="4" rx="2"/><rect fill="#6B4FBB" x="52" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="68" y="48" width="12" height="4" rx="2"/></g><use stroke="#B5A7DD" mask="url(#f)" stroke-width="8" fill="#FFF" transform="rotate(5 171.616 144.96)" xlink:href="#b"/><path d="M58 132c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#C1E7D0"/><path d="M90.143 132c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M74.686 133.875l-3.18-3.18c-.29-.29-.77-.296-1.06-.005l-1.55 1.55c-.287.287-.29.766.004 1.06l4.92 4.92c.504.504 1.32.504 1.823 0l.654-.653 7.804-7.804c.3-.3.29-.77-.005-1.067l-1.578-1.58c-.302-.3-.775-.298-1.068-.004l-6.764 6.763z" fill="#31AF64"/><path d="M4 66c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18S4 75.94 4 66z" fill="#D5ECF7"/><path d="M36.143 66c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M22 55.714c5.68 0 10.286 4.605 10.286 10.286 0 5.68-4.605 10.286-10.286 10.286-3.45 0-6.505-1.7-8.37-4.307L22 66V55.714z" fill="#2D9FD8"/><g transform="rotate(-8 748.533 18.147)"><use stroke="#FDE5D8" mask="url(#g)" stroke-width="8" fill="#FFF" xlink:href="#c"/><path d="M31 46.584c1.766-.772 3-2.534 3-4.584 0-2.76-2.24-5-5-5s-5 2.24-5 5c0 2.05 1.234 3.812 3 4.584v3.42c0 1.1.895 1.996 2 1.996 1.112 0 2-.894 2-1.997v-3.42z" fill="#FC8A51"/></g><g transform="translate(0 154)"><use stroke="#E5E5E5" mask="url(#h)" stroke-width="8" fill="#FFF" xlink:href="#d"/><g opacity=".3"><path d="M141.837 104.53l-2.56-7.993-5.074-15.843c-.26-.815-1.398-.815-1.66 0l-5.074 15.843h-16.85l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33 22.16-16.33c.61-.452.866-1.25.632-1.98" fill="#A1A1A1"/><path fill="#5C5C5C" d="M119.044 122.84l8.425-26.303h-16.85l8.424 26.304"/><path fill="#787878" d="M119.044 122.84l-8.425-26.303H98.81l20.232 26.304"/><path fill="#787878" d="M119.044 122.84l8.425-26.303h11.807l-20.233 26.304"/><path d="M98.812 96.537l-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33L98.81 96.538z" fill="#A1A1A1"/><path d="M98.812 96.537h11.807l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843z" fill="#5C5C5C"/><path d="M139.277 96.537l2.56 7.993c.234.73-.022 1.528-.634 1.98l-22.16 16.33 20.234-26.303z" fill="#A1A1A1"/><path d="M139.277 96.537H127.47l5.074-15.843c.26-.815 1.398-.815 1.66 0l5.073 15.843z" fill="#5C5C5C"/></g><path d="M57 18.29c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H41c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H77c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm17 24.693c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm202 32.923c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm202 32.923c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm-202 0c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm202 32.922c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm179.023 19.555c-.988.452-1.388 1.55-.894 2.454.493.904 1.694 1.27 2.682.82l14.31-6.545c.99-.452 1.39-1.55.896-2.454-.494-.902-1.696-1.27-2.684-.817l-14.31 6.544zm-32.2 14.723c-.987.452-1.388 1.55-.894 2.454.493.904 1.695 1.27 2.683.818l14.31-6.544c.99-.45 1.39-1.55.895-2.454-.494-.903-1.695-1.27-2.683-.818l-14.31 6.544zm-32.2 14.724c-.987.45-1.387 1.55-.893 2.454.494.903 1.695 1.27 2.683.818l14.31-6.544c.99-.452 1.39-1.55.896-2.454-.495-.904-1.697-1.27-2.685-.818l-14.31 6.544zm-23.67-2.023l-12.186-5.57c-.987-.452-2.19-.086-2.683.817-.494.904-.093 2.003.895 2.454l12.185 5.573c.754.345 1.57.645 2.438.898 1.052.307 2.177-.224 2.513-1.187.335-.962-.246-1.99-1.298-2.298-.677-.197-1.302-.426-1.864-.684zM62.57 168.437c-.988-.452-2.19-.086-2.683.818-.494.903-.094 2.002.894 2.454l14.31 6.544c.988.45 2.19.085 2.683-.818.494-.904.094-2.003-.894-2.454l-14.312-6.544zm-32.2-14.723c-.988-.452-2.19-.086-2.683.818-.494.904-.093 2.003.895 2.454l14.31 6.544c.988.452 2.19.086 2.684-.818.494-.903.093-2.002-.895-2.454l-14.312-6.543z" fill="#EEE"/></g><g><path d="M104 18c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#FADFD9"/><path d="M136.143 18c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M119.43 8.994c0-.707.57-1.28 1.283-1.28h2.574c.71 0 1.284.57 1.284 1.28v10.298c0 .706-.57 1.28-1.283 1.28h-2.574c-.71 0-1.284-.57-1.284-1.28V8.994zm0 15.433c0-.71.57-1.284 1.283-1.284h2.574c.71 0 1.284.57 1.284 1.284V27c0 .71-.57 1.286-1.283 1.286h-2.574c-.71 0-1.284-.57-1.284-1.285v-2.573z" fill="#E75E40"/></g><g><path d="M213 89c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#F6D4DC"/><path d="M245.143 89c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M231 86.348l-3.603-3.602c-.288-.29-.766-.286-1.063.01l-1.578 1.578c-.3.302-.3.773-.01 1.063L228.348 89l-3.602 3.603c-.29.288-.286.766.01 1.063l1.578 1.578c.302.3.773.3 1.063.01L231 91.652l3.603 3.602c.288.29.766.286 1.063-.01l1.578-1.578c.3-.302.3-.773.01-1.063L233.652 89l3.602-3.603c.29-.288.286-.766-.01-1.063l-1.578-1.578c-.302-.3-.773-.3-1.063-.01L231 86.348z" fill="#D22852"/></g></g></svg> diff --git a/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml b/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml index db1754c1864..5aaae5eb4ec 100644 --- a/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml @@ -1,30 +1,21 @@ - pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>' - run_actions_text = html_escape(s_("ProjectService|Perform common operations on GitLab project: %{project_name}")) % { project_name: pretty_name } +- external_link_icon = sprite_icon('external-link') %p= s_("ProjectService|To configure this integration, you should:") -%ul.list-unstyled.indent-list +%ol.indent-list %li - 1. - = link_to help_page_url('user/project/integrations/mattermost_slash_commands', anchor: 'enable-custom-slash-commands-in-mattermost'), target: '_blank', rel: 'noopener noreferrer nofollow' do - Enable custom slash commands - = sprite_icon('external-link') - on your Mattermost installation. + - enable_slash_commands_link_url = help_page_url('user/project/integrations/mattermost_slash_commands', anchor: 'enable-custom-slash-commands-in-mattermost') + - enable_slash_commands_link = link_to '', enable_slash_commands_link_url, target: '_blank', rel: 'noopener noreferrer' + = safe_format(s_('MattermostService|%{link_start}Enable custom slash commands %{icon}%{link_end} on your Mattermost installation.'), tag_pair(enable_slash_commands_link, :link_start, :link_end), icon: external_link_icon) %li - 2. - = link_to help_page_url('user/project/integrations/mattermost_slash_commands', anchor: 'create-a-slash-command-in-mattermost'), target: '_blank', rel: 'noopener noreferrer nofollow' do - Add a slash command - = sprite_icon('external-link') - in your Mattermost team with the options listed below. + - create_slash_commands_link_url = help_page_url('user/project/integrations/mattermost_slash_commands', anchor: 'create-a-slash-command-in-mattermost') + - create_slash_commands_link = link_to '', create_slash_commands_link_url, target: '_blank', rel: 'noopener noreferrer' + = safe_format(s_('MattermostService|%{link_start}Add a slash command %{icon}%{link_end} in your Mattermost team with the options listed below.'), tag_pair(create_slash_commands_link, :link_start, :link_end), icon: external_link_icon) %li - 3. Paste the token into the - %strong Token - field. + = safe_format(s_('MattermostService|Paste the token into the %{strong_start}Token%{strong_end} field.'), tag_pair(tag.strong, :strong_start, :strong_end)) %li - 4. Select the - %strong Active - check box, then select - %strong Save changes - to start using GitLab inside Mattermost! + = safe_format(s_('MattermostService|Select the %{strong_start}Active%{strong_end} check box, then select %{strong_start}Save changes%{strong_end} to start using GitLab inside Mattermost!'), tag_pair(tag.strong, :strong_start, :strong_end)) %hr @@ -34,14 +25,14 @@ .col-12.input-group = text_field_tag :display_name, "GitLab / #{pretty_name}".html_safe, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#display_name', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#display_name', category: :primary, size: :medium) .form-group = label_tag :description, _('Description'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#description', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#description', category: :primary, size: :medium) .form-group = label_tag nil, s_('MattermostService|Command trigger word'), class: 'col-12 col-form-label label-bold' @@ -59,7 +50,7 @@ .col-12.input-group = text_field_tag :request_url, service_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#request_url', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#request_url', category: :primary, size: :medium) .form-group = label_tag nil, s_('MattermostService|Request method'), class: 'col-12 col-form-label label-bold' @@ -70,14 +61,14 @@ .col-12.input-group = text_field_tag :response_username, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#response_username', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#response_username', category: :primary, size: :medium) .form-group = label_tag :response_icon, s_('MattermostService|Response icon'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#response_icon', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#response_icon', category: :primary, size: :medium) .form-group = label_tag nil, _('Autocomplete'), class: 'col-12 col-form-label label-bold' @@ -88,11 +79,11 @@ .col-12.input-group = text_field_tag :autocomplete_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#autocomplete_hint', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#autocomplete_hint', category: :primary, size: :medium) .form-group = label_tag :autocomplete_description, _('Autocomplete description'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#autocomplete_description', class: 'gl-button btn-default btn-icon input-group-text') + = clipboard_button(target: '#autocomplete_description', category: :primary, size: :medium) diff --git a/app/views/shared/integrations/mattermost_slash_commands/_installation_info.html.haml b/app/views/shared/integrations/mattermost_slash_commands/_installation_info.html.haml index 38adc69dd5e..ac14e1a6cd7 100644 --- a/app/views/shared/integrations/mattermost_slash_commands/_installation_info.html.haml +++ b/app/views/shared/integrations/mattermost_slash_commands/_installation_info.html.haml @@ -1,7 +1,5 @@ .services-installation-info - unless integration.activated? - .row - .col-sm-9.offset-sm-3 - = link_to new_project_mattermost_path(@project), class: 'btn gl-button btn-lg' do - = custom_icon('mattermost_logo', size: 15) - = s_("MattermostService|Add to Mattermost") + = render Pajamas::ButtonComponent.new(href: new_project_mattermost_path(@project), button_text_classes: 'gl-display-flex gl-gap-2') do + = custom_icon('mattermost_logo') + = s_("MattermostService|Add to Mattermost") diff --git a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml index a7a650aa95d..080d4b37354 100644 --- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml @@ -3,7 +3,6 @@ .col-lg-3 %p = s_('PrometheusService|Custom metrics require Prometheus installed on a cluster with environment scope "*" OR a manually configured Prometheus to be available.') - = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 = render Pajamas::CardComponent.new(header_options: { class: 'gl-display-flex gl-align-items-center' }, body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 custom-monitored-metrics js-panel-custom-monitored-metrics', data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } }) do |c| diff --git a/app/views/shared/integrations/prometheus/_metrics.html.haml b/app/views/shared/integrations/prometheus/_metrics.html.haml index cb78faa383a..36e4c0d4b13 100644 --- a/app/views/shared/integrations/prometheus/_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_metrics.html.haml @@ -5,10 +5,9 @@ .col-lg-3 %p = s_('PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters.') - = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 - = render Pajamas::CardComponent.new(body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 js-panel-monitored-metrics', data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') }}) do |c| + = render Pajamas::CardComponent.new(body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 js-panel-monitored-metrics', data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json) }}) do |c| - c.with_header do %strong = s_('PrometheusService|Common metrics') @@ -34,5 +33,4 @@ .flash-notice .flash-text = html_escape(s_("PrometheusService|To set up automatic monitoring, add the environment variable %{variable} to exporter's queries.")) % { variable: "<code>$CI_ENVIRONMENT_SLUG</code>".html_safe } - = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md') %ul.list-unstyled.metrics-list.js-missing-var-metrics-list diff --git a/app/views/shared/integrations/slack_slash_commands/_help.html.haml b/app/views/shared/integrations/slack_slash_commands/_help.html.haml index 43a240fa6fe..defaf50efea 100644 --- a/app/views/shared/integrations/slack_slash_commands/_help.html.haml +++ b/app/views/shared/integrations/slack_slash_commands/_help.html.haml @@ -40,7 +40,7 @@ .col-12.input-group = text_field_tag :url, service_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#url', class: 'input-group-text') + = deprecated_clipboard_button(target: '#url', class: 'input-group-text') .form-group = label_tag nil, _('Method'), class: 'col-12 col-form-label label-bold' @@ -51,7 +51,7 @@ .col-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#customize_name', class: 'input-group-text') + = deprecated_clipboard_button(target: '#customize_name', class: 'input-group-text') .form-group = label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold' @@ -68,21 +68,21 @@ .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text.html_safe, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') + = deprecated_clipboard_button(target: '#autocomplete_description', class: 'input-group-text') .form-group = label_tag :autocomplete_usage_hint, _('Autocomplete usage hint'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') + = deprecated_clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') .form-group = label_tag :descriptive_label, _('Descriptive label'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :descriptive_label, _('Perform common operations on GitLab project'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#descriptive_label', class: 'input-group-text') + = deprecated_clipboard_button(target: '#descriptive_label', class: 'input-group-text') %hr diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 42f035b99aa..f8d07a2f6de 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -51,12 +51,11 @@ .gl-mt-5{ class: (is_footer ? "footer-block" : "middle-block") } - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path) .gl-mb-5 - - contribution_guidelines_start = '<strong><a href="%{url}">'.html_safe % {url: strip_tags(guide_url)} - - contribution_guidelines_end = '</a></strong>'.html_safe - = sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end }) + - contribution_guidelines = link_to('', strip_tags(guide_url)) + = safe_format(_('Please review the %{strong_start}%{contribution_guidelines_start}contribution guidelines%{contribution_guidelines_end}%{strong_end} for this project.'), tag_pair('<strong></strong>'.html_safe, :strong_start, :strong_end), tag_pair(contribution_guidelines, :contribution_guidelines_start, :contribution_guidelines_end)) - if issuable.new_record? - = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button js-reset-autosave', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button js-reset-autosave', data: { testid: 'issuable-create-button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - else = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button js-reset-autosave', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index d590c859945..86aaa5128a8 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,7 +1,7 @@ - type = local_assigns.fetch(:type) - show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) - disable_target_branch = local_assigns.fetch(:disable_target_branch, false) -- placeholder = local_assigns[:placeholder] || _('Search or filter results...') +- placeholder = local_assigns[:placeholder] || _('Search or filter results…') - block_css_class = type != :productivity_analytics ? 'row-content-block second-block' : '' .issues-filters diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 46710081307..93e1a53ccb4 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -46,7 +46,7 @@ .js-sidebar-milestone-widget-root{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } - if in_group_context_with_iterations - .block.gl-collapse-empty{ data: { testid: 'iteration_container' } }< + .block.gl-collapse-empty{ data: { testid: 'iteration-container' } }< = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:show_crm_contacts] @@ -88,11 +88,11 @@ - if is_merge_request && !moved_sidebar_enabled .sub-block.js-sidebar-source-branch .sidebar-collapsed-icon.js-dont-change-state - = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy') + = deprecated_clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy') .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) } - = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy') + = deprecated_clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy') - if show_forwarding_email && !moved_sidebar_enabled .block diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index a27bb506c87..0ffce0ac571 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -45,4 +45,4 @@ options: options, wrapper_class: 'js-sidebar-assignee-dropdown', track_label: 'edit_assignee', - trigger_source: "#{issuable_type}-assignee-dropdown" + trigger_source: "#{issuable_type}_assignee_dropdown" diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index b360fac0a55..4b07d8d0850 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -42,4 +42,4 @@ options: options, wrapper_class: 'js-sidebar-reviewer-dropdown', track_label: 'edit_reviewer', - trigger_source: "#{issuable_type}-reviewer-dropdown" + trigger_source: "#{issuable_type}_reviewer_dropdown" diff --git a/app/views/shared/issuable/_status_box.html.haml b/app/views/shared/issuable/_status_box.html.haml index cca51b48322..f2e4e22788a 100644 --- a/app/views/shared/issuable/_status_box.html.haml +++ b/app/views/shared/issuable/_status_box.html.haml @@ -1,8 +1,7 @@ - badge_text = state_name_with_icon(issuable)[0] - badge_icon = state_name_with_icon(issuable)[1] - badge_variant = issuable.open? ? :success : issuable.merged? ? :info : :danger -- badge_status_class = issuable.open? ? 'issuable-status-badge-open' : issuable.merged? ? 'issuable-status-badge-merged' : 'issuable-status-badge-closed' -- badge_classes = "js-mr-status-box issuable-status-badge gl-mr-3 gl-align-self-center #{badge_status_class} #{'gl-vertical-align-bottom' if issuable.is_a?(MergeRequest)}" +- badge_classes = "js-mr-status-box gl-mr-3 gl-align-self-center" = gl_badge_tag({ variant: badge_variant, icon: badge_icon, icon_classes: 'gl-mr-0!' }, { class: badge_classes, data: { project_path: issuable.project.path_with_namespace, iid: issuable.iid, issuable_type: 'merge_request', state: issuable.state } }) do %span.gl-display-none.gl-sm-display-block.gl-ml-2 diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml index 2dda0049c09..be6ca475f5c 100644 --- a/app/views/shared/issuable/form/_default_templates.html.haml +++ b/app/views/shared/issuable/form/_default_templates.html.haml @@ -1,5 +1,4 @@ .gl-mt-3.gl-text-secondary - - template_link_url = help_page_path('user/project/description_templates') - - template_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: template_link_url } - = s_('Promotions|Add %{link_start} description templates %{link_end} to help your contributors to communicate effectively!').html_safe % { link_start: template_link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('user/project/description_templates'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('Promotions|Add %{link_start} description templates %{link_end} to help your contributors to communicate effectively!'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 36000f3cc67..242342d365e 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -3,7 +3,7 @@ %div{ data: { testid: 'issue-title-input-field' } } = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad', dir: 'auto', data: { qa_selector: 'issuable_form_title_field' } + autocomplete: 'off', class: 'form-control pad', dir: 'auto', data: { testid: 'issuable-form-title-field' } - if issuable.respond_to?(:draft?) .gl-pt-3 diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml deleted file mode 100644 index 558287480e1..00000000000 --- a/app/views/shared/issue_type/_details_header.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- link = issue_closed_link(@issue, current_user, css_class: 'text-underline gl-reset-color!') -- badge_classes = 'issuable-status-badge gl-mr-3' - -.detail-page-header - .detail-page-header-body.gl-flex-wrap - = gl_badge_tag({ variant: :info, icon: 'issue-closed', icon_classes: 'gl-mr-0!' }, { class: "#{issue_status_visibility(issuable, status_box: :closed)} #{badge_classes} issuable-status-badge-closed" }) do - .gl-display-none.gl-sm-display-block.gl-ml-2 - = issue_closed_text(issuable, current_user) - - if link - %span.gl-pl-2.gl-sm-display-none - = "(#{link})" - = gl_badge_tag({ variant: :success, icon: 'issues', icon_classes: 'gl-mr-0!' }, { class: "#{issue_status_visibility(issuable, status_box: :open)} #{badge_classes} issuable-status-badge-open" }) do - %span.gl-display-none.gl-sm-display-block.gl-ml-2 - = _('Open') - - #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } - = issuable_meta(issuable, @project) - - = render Pajamas::ButtonComponent.new(href: '#', icon: 'chevron-double-lg-left', button_options: { class: 'gl-ml-auto gl-display-block gl-sm-display-none! js-sidebar-toggle' }) - - .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar) } diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 899b2ed832e..53fbe3dac03 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -1,3 +1,5 @@ +- show_lock_on_merge = local_assigns.fetch(:show_lock_on_merge, false) + = gitlab_ui_form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f| = form_errors(@label) @@ -21,6 +23,13 @@ .form-text.text-muted = _('Select a color from the color picker or from the presets below.') = render_suggested_colors + - if show_lock_on_merge + .form-group.row + .col-12 + = f.gitlab_ui_checkbox_component :lock_on_merge, + _('Lock label after a merge request is merged'), + help_text: label_lock_on_merge_help_text, + checkbox_options: { disabled: @label.lock_on_merge } .gl-display-flex.gl-justify-content-space-between %div - if @label.persisted? diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index 94086636d5a..0d692ee753a 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -15,4 +15,4 @@ - elsif source.request_access_enabled && can?(current_user, :request_access, source) = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), method: :post, - data: { qa_selector: 'request_access_link' } + data: { testid: 'request-access-link' } diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 1b0eeb424c2..9387d8d3ad1 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -162,10 +162,10 @@ - if milestone_ref.present? .block.reference .sidebar-collapsed-icon.js-dont-change-state - = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') + = deprecated_clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap = s_('MilestoneSidebar|Reference:') %span{ title: milestone_ref } = milestone_ref - = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') + = deprecated_clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') diff --git a/app/views/shared/packages/_no_packages.html.haml b/app/views/shared/packages/_no_packages.html.haml index 7cc8110fb6b..9f165a198d6 100644 --- a/app/views/shared/packages/_no_packages.html.haml +++ b/app/views/shared/packages/_no_packages.html.haml @@ -3,6 +3,5 @@ .text-content %h4.text-center= _('There are no packages yet') %p - - no_packages_url = help_page_path('administration/packages/index') - - no_packages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: no_packages_url } - = _('Learn how to %{no_packages_link_start}publish and share your packages%{no_packages_link_end} with GitLab.').html_safe % { no_packages_link_start: no_packages_link_start, no_packages_link_end: '</a>'.html_safe } + - link = link_to('', help_page_path('administration/packages/index'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_('Learn how to %{no_packages_link_start}publish and share your packages%{no_packages_link_end} with GitLab.'), tag_pair(link, :no_packages_link_start, :no_packages_link_end)) diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 95188cefdd1..ac5e65747d5 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -26,8 +26,8 @@ .project-cell{ class: css_class } .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } } .gl-display-flex.gl-align-items-center.gl-flex-wrap - %h2.gl-font-base.gl-line-height-20.gl-my-0 - = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document' do + %h2.gl-font-base.gl-line-height-20.gl-my-0.gl-overflow-wrap-anywhere + = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document', title: project.name do %span.namespace-name.gl-font-weight-normal - if project.namespace && !skip_namespace = project.namespace.human_name diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml index 0e5f6d844cd..bac2f1d7cb0 100644 --- a/app/views/shared/web_hooks/_hook_errors.html.haml +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -1,8 +1,5 @@ -- strong_start = '<strong>'.html_safe -- strong_end = '</strong>'.html_safe -- link_start = '<a href="%{url}">'.html_safe -- link_end = '</a>'.html_safe - +- strong = { strong_start: '<strong>'.html_safe, + strong_end: '</strong>'.html_safe } - if hook.rate_limited? - placeholders = { limit: number_with_delimiter(hook.rate_limit), root_namespace: hook.parent.root_namespace.path } @@ -14,15 +11,11 @@ = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'), variant: :danger) do |c| - c.with_body do - = s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end } + = safe_format(s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.'), strong) - elsif hook.temporarily_disabled? - - help_path = help_page_path('user/project/integrations/webhooks', anchor: 'webhook-fails-or-multiple-webhook-requests-are-triggered') - - placeholders = { strong_start: strong_start, - strong_end: strong_end, - retry_time: time_interval_in_words(hook.disabled_until - Time.now), - help_link_start: link_start % { url: help_path }, - help_link_end: link_end } + - help_link = link_to('', help_page_path('user/project/integrations/webhooks', anchor: 'webhook-fails-or-multiple-webhook-requests-are-triggered'), target: '_blank', rel: 'noopener noreferrer') + - retry_time = { retry_time: time_interval_in_words(hook.disabled_until - Time.now) } = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook fails to connect'), variant: :warning) do |c| - c.with_body do - = s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders + = safe_format(s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.'), retry_time, strong, tag_pair(help_link, :help_link_start, :help_link_end)) diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml index ae32dcea7cb..8ff41b6a1ca 100644 --- a/app/views/shared/web_hooks/_title_and_docs.html.haml +++ b/app/views/shared/web_hooks/_title_and_docs.html.haml @@ -1,4 +1,4 @@ -- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: help_page_path(hook.help_path) } +- webhooks_link = tag_pair(link_to('', help_page_path(hook.help_path), target: '_blank', rel: 'noopener noreferrer'), :webhooks_link_start, :webhooks_link_end) .settings-sticky-header .settings-sticky-header-inner @@ -6,7 +6,7 @@ = page_title - if @project - - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path(project: @project) } - %p.gl-text-secondary= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe } + - integrations_link = tag_pair(link_to('', scoped_integrations_path(project: @project)), :integrations_link_start, :integrations_link_end) + %p.gl-text-secondary= safe_format(_("%{webhooks_link_start}%{webhook_type}%{webhooks_link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{integrations_link_end} in preference to a webhook."), webhooks_link, integrations_link, webhook_type: hook.pluralized_name) - else - %p.gl-text-secondary= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe } + %p.gl-text-secondary= safe_format(_("%{webhooks_link_start}%{webhook_type}%{webhooks_link_end} enable you to send notifications to web applications in response to events in a group or project."), webhooks_link, webhook_type: hook.pluralized_name) diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml index 507fe126acb..64b4a8e1ae2 100644 --- a/app/views/users/_deletion_guidance.html.haml +++ b/app/views/users/_deletion_guidance.html.haml @@ -3,8 +3,8 @@ %ul %li %p - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") } - = _('Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + - link = link_to('', help_page_path("user/profile/account/delete_account", anchor: "associated-records")) + = safe_format(_('Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the %{link_start}user account deletion documentation.%{link_end}'), tag_pair(link, :link_start, :link_end)) - personal_projects_count = user.personal_projects.count - unless personal_projects_count == 0 %li diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index 6de9e80008e..fb9721028d5 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -5,6 +5,6 @@ - unless Feature.enabled?(:user_profile_overflow_menu_vue) = render 'middle_dot_divider', stacking: true do = s_('UserProfile|User ID: %{id}') % { id: @user.id } - = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) + = deprecated_clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) = render 'middle_dot_divider', stacking: true do = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) } diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index e2ddbb90213..a2f6b3da746 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -71,7 +71,7 @@ %p.gl-mb-4.gl-text-gray-500= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation } - if @user.status&.customized? - .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3 + .cover-status.gl-display-inline-flex.gl-align-items-baseline.gl-mb-3 = emoji_icon(@user.status.emoji, class: 'gl-mr-2') = markdown_field(@user.status, :message) = render "users/profile_basic_info" diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 60f233b8289..6ef7447b9da 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -570,23 +570,23 @@ :weight: 1 :idempotent: false :tags: [] -- :name: cronjob:metrics_global_metrics_update - :worker_name: Metrics::GlobalMetricsUpdateWorker - :feature_category: :metrics +- :name: cronjob:merge_requests_ensure_prepared + :worker_name: MergeRequests::EnsurePreparedWorker + :feature_category: :code_review_workflow :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true :tags: [] -- :name: cronjob:namespaces_in_product_marketing_emails - :worker_name: Namespaces::InProductMarketingEmailsWorker - :feature_category: :experimentation_activation +- :name: cronjob:metrics_global_metrics_update + :worker_name: Metrics::GlobalMetricsUpdateWorker + :feature_category: :metrics :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: false + :idempotent: true :tags: [] - :name: cronjob:namespaces_prune_aggregation_schedules :worker_name: Namespaces::PruneAggregationSchedulesWorker @@ -2343,6 +2343,51 @@ :weight: 1 :idempotent: true :tags: [] +- :name: bitbucket_import_advance_stage + :worker_name: Gitlab::BitbucketImport::AdvanceStageWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_import_pull_request + :worker_name: Gitlab::BitbucketImport::ImportPullRequestWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_stage_finish_import + :worker_name: Gitlab::BitbucketImport::Stage::FinishImportWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_stage_import_pull_requests + :worker_name: Gitlab::BitbucketImport::Stage::ImportPullRequestsWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_stage_import_repository + :worker_name: Gitlab::BitbucketImport::Stage::ImportRepositoryWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bitbucket_server_import_advance_stage :worker_name: Gitlab::BitbucketServerImport::AdvanceStageWorker :feature_category: :importers @@ -2469,6 +2514,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: bulk_imports_finish_project_import + :worker_name: BulkImports::FinishProjectImportWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: bulk_imports_pipeline :worker_name: BulkImports::PipelineWorker :feature_category: :importers @@ -2613,6 +2667,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: database_lock_tables + :worker_name: Database::LockTablesWorker + :feature_category: :cell + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: default :worker_name: :feature_category: :not_owned @@ -3306,15 +3369,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: pages_invalidate_domain_cache - :worker_name: Pages::InvalidateDomainCacheWorker - :feature_category: :pages - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: post_receive :worker_name: PostReceive :feature_category: :source_code_management @@ -3470,7 +3524,7 @@ :tags: [] - :name: projects_record_target_platforms :worker_name: Projects::RecordTargetPlatformsWorker - :feature_category: :groups_and_projects + :feature_category: :experimentation_activation :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -3729,6 +3783,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: users_track_namespace_visits + :worker_name: Users::TrackNamespaceVisitsWorker + :feature_category: :navigation + :has_external_dependencies: false + :urgency: :throttled + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: web_hook :worker_name: WebHookWorker :feature_category: :webhooks diff --git a/app/workers/background_migration/single_database_worker.rb b/app/workers/background_migration/single_database_worker.rb index 2f797a24468..56800c03bbb 100644 --- a/app/workers/background_migration/single_database_worker.rb +++ b/app/workers/background_migration/single_database_worker.rb @@ -45,7 +45,10 @@ module BackgroundMigration # lease on the class before giving up. See MR for more discussion. # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956 def perform(class_name, arguments = [], lease_attempts = MAX_LEASE_ATTEMPTS) - unless Feature.enabled?(:execute_background_migrations, type: :ops) + should_skip = Feature.enabled?(:disallow_database_ddl_feature_flags, type: :ops) || + Feature.disabled?(:execute_background_migrations, type: :ops) + + if should_skip # Delay execution of background migrations self.class.perform_in(BACKGROUND_MIGRATIONS_DELAY, class_name, arguments, lease_attempts) diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index f5baa220715..b937dbf298a 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -13,15 +13,5 @@ class BuildSuccessWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_processing urgency :high - def perform(build_id) - Ci::Build.find_by_id(build_id).try do |build| - stop_environment(build) if build.stops_environment? && build.stop_action_successful? - end - end - - private - - def stop_environment(build) - build.persisted_environment.fire_state_event(:stop_complete) - end + def perform(build_id); end end diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 6bce13c5ff0..83b881ee525 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -22,7 +22,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker @bulk_import.start! if @bulk_import.created? created_entities.first(next_batch_size).each do |entity| - BulkImports::CreatePipelineTrackersService.new(entity).execute! + create_tracker(entity) entity.start! @@ -51,7 +51,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker end def all_entities_failed? - entities.all? { |entity| entity.failed? } + entities.all?(&:failed?) end # A new BulkImportWorker job is enqueued to either @@ -72,4 +72,55 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker def next_batch_size [DEFAULT_BATCH_SIZE - started_entities.count, 0].max end + + def create_tracker(entity) + entity.class.transaction do + entity.pipelines.each do |pipeline| + status = skip_pipeline?(pipeline, entity) ? :skipped : :created + + entity.trackers.create!( + stage: pipeline[:stage], + pipeline_name: pipeline[:pipeline], + status: BulkImports::Tracker.state_machine.states[status].value + ) + end + end + end + + def skip_pipeline?(pipeline, entity) + return false unless entity.source_version.valid? + + minimum_version, maximum_version = pipeline.values_at(:minimum_source_version, :maximum_source_version) + + if source_version_out_of_range?(minimum_version, maximum_version, entity.source_version.without_patch) + log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) + return true + end + + false + end + + def source_version_out_of_range?(minimum_version, maximum_version, non_patch_source_version) + (minimum_version && non_patch_source_version < Gitlab::VersionInfo.parse(minimum_version)) || + (maximum_version && non_patch_source_version > Gitlab::VersionInfo.parse(maximum_version)) + end + + def log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) + logger.info( + message: 'Pipeline skipped as source instance version not compatible with pipeline', + bulk_import_entity_id: entity.id, + bulk_import_id: entity.bulk_import_id, + bulk_import_entity_type: entity.source_type, + source_full_path: entity.source_full_path, + pipeline_name: pipeline[:pipeline], + minimum_source_version: minimum_version, + maximum_source_version: maximum_version, + source_version: entity.source_version.to_s, + importer: 'gitlab_migration' + ) + end + + def logger + @logger ||= Gitlab::Import::Logger.build + end end diff --git a/app/workers/bulk_imports/finish_project_import_worker.rb b/app/workers/bulk_imports/finish_project_import_worker.rb new file mode 100644 index 00000000000..815101c89f3 --- /dev/null +++ b/app/workers/bulk_imports/finish_project_import_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module BulkImports + class FinishProjectImportWorker + include ApplicationWorker + + feature_category :importers + sidekiq_options retry: 5 + data_consistency :sticky + + idempotent! + + def perform(project_id) + project = Project.find_by_id(project_id) + return unless project + + project.after_import + end + end +end diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb index 054e7763297..5b7398cb071 100644 --- a/app/workers/click_house/events_sync_worker.rb +++ b/app/workers/click_house/events_sync_worker.rb @@ -12,6 +12,47 @@ module ClickHouse # 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? @@ -22,12 +63,15 @@ module ClickHouse metadata = { status: :processed } - # Prevent parallel jobs begin + # Prevent parallel jobs in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do - true - end + 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 } @@ -38,8 +82,51 @@ module ClickHouse 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 enabled? ClickHouse::Client.configuration.databases[:main].present? && 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_data = false + # rubocop: disable CodeReuse/ActiveRecord + Event.where(Event.arel_table[:id].gt(context.last_record_id)).each_batch(of: BATCH_SIZE) do |relation| + has_data = true + + relation.select(*EVENT_PROJECTIONS).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? + end + + context.no_more_records! if has_data == false + # rubocop: enable CodeReuse/ActiveRecord + end + end end end diff --git a/app/workers/concerns/gitlab/bitbucket_import/object_importer.rb b/app/workers/concerns/gitlab/bitbucket_import/object_importer.rb new file mode 100644 index 00000000000..26e6e2675ed --- /dev/null +++ b/app/workers/concerns/gitlab/bitbucket_import/object_importer.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + # ObjectImporter defines the base behaviour for every Sidekiq worker that + # imports a single resource such as a note or pull request. + module ObjectImporter + extend ActiveSupport::Concern + + included do + include ApplicationWorker + + data_consistency :always + + feature_category :importers + + worker_has_external_dependencies! + + sidekiq_retries_exhausted do |msg| + args = msg['args'] + jid = msg['jid'] + + # If a job is being exhausted we still want to notify the + # Gitlab::Import::AdvanceStageWorker to prevent the entire import from getting stuck + key = args.last + JobWaiter.notify(key, jid) if args.length == 3 && key && key.is_a?(String) + end + end + + def perform(project_id, hash, notify_key) + project = Project.find_by_id(project_id) + + return unless project + + if project.import_state&.canceled? + info(project.id, message: 'project import canceled') + return + end + + import(project, hash) + ensure + notify_waiter(notify_key) + end + + private + + # project - An instance of `Project` to import the data into. + # hash - A Hash containing the details of the object to import. + def import(project, hash) + info(project.id, message: 'importer started') + + importer_class.new(project, hash).execute + + info(project.id, message: 'importer finished') + rescue ActiveRecord::RecordInvalid => e + # We do not raise exception to prevent job retry + track_exception(project, e) + rescue StandardError => e + track_and_raise_exception(project, e) + end + + def notify_waiter(key) + JobWaiter.notify(key, jid) + end + + # Returns the class to use for importing the object. + def importer_class + raise NotImplementedError + end + + def info(project_id, extra = {}) + Logger.info(log_attributes(project_id, extra)) + end + + def log_attributes(project_id, extra = {}) + extra.merge( + project_id: project_id, + importer: importer_class.name + ) + end + + def track_exception(project, exception, fail_import: false) + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: importer_class.name, + exception: exception, + fail_import: fail_import + ) + end + + def track_and_raise_exception(project, exception, fail_import: false) + track_exception(project, exception, fail_import: fail_import) + + raise(exception) + end + end + end +end diff --git a/app/workers/concerns/gitlab/bitbucket_import/stage_methods.rb b/app/workers/concerns/gitlab/bitbucket_import/stage_methods.rb new file mode 100644 index 00000000000..2885cc29532 --- /dev/null +++ b/app/workers/concerns/gitlab/bitbucket_import/stage_methods.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module StageMethods + extend ActiveSupport::Concern + + included do + include ApplicationWorker + + worker_has_external_dependencies! + + feature_category :importers + + data_consistency :always + + sidekiq_options dead: false, retry: 3 + + sidekiq_retries_exhausted do |msg, e| + Gitlab::Import::ImportFailureService.track( + project_id: msg['args'][0], + exception: e, + fail_import: true + ) + end + end + + # project_id - The ID of the GitLab project to import the data into. + def perform(project_id) + info(project_id, message: 'starting stage') + + project = find_project(project_id) + + return unless project + + import(project) + + info(project_id, message: 'stage finished') + rescue StandardError => e + Gitlab::Import::ImportFailureService.track( + project_id: project_id, + exception: e, + error_source: self.class.name, + fail_import: abort_on_failure + ) + + raise(e) + 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 + + def abort_on_failure + false + end + + private + + def info(project_id, extra = {}) + Logger.info(log_attributes(project_id, extra)) + end + + def log_attributes(project_id, extra = {}) + extra.merge( + project_id: project_id, + import_stage: self.class.name + ) + end + end + end +end diff --git a/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb b/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb index b209719479b..1090d82c922 100644 --- a/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb +++ b/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb @@ -23,7 +23,7 @@ module Gitlab # If a job is being exhausted we still want to notify the # Gitlab::Import::AdvanceStageWorker to prevent the entire import from getting stuck if args.length == 3 && (key = args.last) && key.is_a?(String) - JobWaiter.notify(key, jid) + JobWaiter.notify(key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) end end end @@ -61,7 +61,7 @@ module Gitlab end def notify_waiter(key) - JobWaiter.notify(key, jid) + JobWaiter.notify(key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) end # Returns the class to use for importing the object. diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index 6cb9bd34969..e190ced5073 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -27,7 +27,7 @@ module Gitlab # If a job is being exhausted we still want to notify the # Gitlab::Import::AdvanceStageWorker to prevent the entire import from getting stuck if args.length == 3 && (key = args.last) && key.is_a?(String) - JobWaiter.notify(key, jid) + JobWaiter.notify(key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) end end end @@ -38,7 +38,7 @@ module Gitlab # client - An instance of `Gitlab::GithubImport::Client` # hash - A Hash containing the details of the object to import. def import(project, client, hash) - unless project.import_state&.in_progress? + if project.import_state&.completed? info( project.id, message: 'Project import is no longer running. Stopping worker.', diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index b40914770b5..f6feb6d1598 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -39,7 +39,7 @@ module Gitlab end def notify_waiter(key = nil) - JobWaiter.notify(key, jid) if key + JobWaiter.notify(key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) if key end def reschedule_job(project, client, hash, notify_key) diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index a5287fcfbe2..75db5589415 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -9,7 +9,7 @@ module Gitlab return unless (project = find_project(project_id)) - unless project.import_state&.in_progress? + if project.import_state&.completed? info( project_id, message: 'Project import is no longer running. Stopping worker.', diff --git a/app/workers/concerns/gitlab/import/notify_upon_death.rb b/app/workers/concerns/gitlab/import/notify_upon_death.rb new file mode 100644 index 00000000000..ae726a673c2 --- /dev/null +++ b/app/workers/concerns/gitlab/import/notify_upon_death.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# NotifyUponDeath can be included into a worker class if it should +# notify any JobWaiter instances upon being moved to the Sidekiq dead queue. +# +# Note that this will only notify the waiter upon graceful termination, a +# SIGKILL will still result in the waiter _not_ being notified. +# +# Workers including this module must have jobs passed where the last +# argument is the key to notify, as a String. +module Gitlab + module Import + module NotifyUponDeath + extend ActiveSupport::Concern + + included do + # If a job is being exhausted we still want to notify the + # Gitlab::Import::AdvanceStageWorker. This prevents the entire import from getting stuck + # just because 1 job threw too many errors. + sidekiq_retries_exhausted do |job| + args = job['args'] + jid = job['jid'] + key = args.last + + next unless args.length == 3 && key.is_a?(String) + + JobWaiter.notify(key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) + end + end + end + end +end diff --git a/app/workers/concerns/gitlab/notify_upon_death.rb b/app/workers/concerns/gitlab/notify_upon_death.rb deleted file mode 100644 index 66dc6270637..00000000000 --- a/app/workers/concerns/gitlab/notify_upon_death.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - # NotifyUponDeath can be included into a worker class if it should - # notify any JobWaiter instances upon being moved to the Sidekiq dead queue. - # - # Note that this will only notify the waiter upon graceful termination, a - # SIGKILL will still result in the waiter _not_ being notified. - # - # Workers including this module must have jobs passed where the last - # argument is the key to notify, as a String. - module NotifyUponDeath - extend ActiveSupport::Concern - - included do - # If a job is being exhausted we still want to notify the - # Gitlab::Import::AdvanceStageWorker. This prevents the entire import from getting stuck - # just because 1 job threw too many errors. - sidekiq_retries_exhausted do |job| - args = job['args'] - jid = job['jid'] - - if args.length == 3 && (key = args.last) && key.is_a?(String) - JobWaiter.notify(key, jid) - end - end - end - end -end diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb index 1bdc829418a..75798f0ab73 100644 --- a/app/workers/database/batched_background_migration/execution_worker.rb +++ b/app/workers/database/batched_background_migration/execution_worker.rb @@ -64,6 +64,8 @@ module Database attr_accessor :database_name, :migration def enabled? + return false if Feature.enabled?(:disallow_database_ddl_feature_flags, type: :ops) + Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops) end diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb index ebf63d34cbf..f73f8fd751b 100644 --- a/app/workers/database/batched_background_migration/single_database_worker.rb +++ b/app/workers/database/batched_background_migration/single_database_worker.rb @@ -27,6 +27,8 @@ module Database # :nocov: def enabled? + return false if Feature.enabled?(:disallow_database_ddl_feature_flags, type: :ops) + Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops) end diff --git a/app/workers/database/lock_tables_worker.rb b/app/workers/database/lock_tables_worker.rb new file mode 100644 index 00000000000..12c8e508ad5 --- /dev/null +++ b/app/workers/database/lock_tables_worker.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Database + class LockTablesWorker + include ApplicationWorker + + TableShouldNotBeLocked = Class.new(StandardError) + + sidekiq_options retry: false + feature_category :cell + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + idempotent! + + version 1 + + def perform(database_name, tables) + check_if_should_lock_database(database_name) + + connection = ::Gitlab::Database.database_base_models_with_gitlab_shared[database_name].connection + check_if_should_lock_tables(tables, database_name, connection) + + performed_actions = tables.map do |table_name| + lock_writes_manager(table_name, connection, database_name).lock_writes + end + + log_extra_metadata_on_done(:performed_actions, performed_actions) + end + + private + + def check_if_should_lock_database(database_name) + raise TableShouldNotBeLocked, 'GitLab is not running in multiple database mode' unless + Gitlab::Database.database_mode == Gitlab::Database::MODE_MULTIPLE_DATABASES + + raise TableShouldNotBeLocked, "database '#{database_name}' does not support locking writes on tables" unless + ::Gitlab::Database.database_base_models_with_gitlab_shared.include?(database_name) + end + + def check_if_should_lock_tables(tables, database_name, connection) + tables.each do |table_name| + unless should_lock_writes_on_table?(connection, database_name, table_name) + raise TableShouldNotBeLocked, "table '#{table_name}' should not be locked on the database '#{database_name}'" + end + end + end + + def should_lock_writes_on_table?(connection, database_name, table_name) + db_info = Gitlab::Database.all_database_connections.fetch(database_name) + table_schema = Gitlab::Database::GitlabSchema.table_schema!(table_name.to_s) + + Gitlab::Database.gitlab_schemas_for_connection(connection).exclude?(table_schema) && + db_info.lock_gitlab_schemas.include?(table_schema) + end + + def lock_writes_manager(table_name, connection, database_name) + Gitlab::Database::LockWritesManager.new( + table_name: table_name, + connection: connection, + database_name: database_name, + with_retries: !connection.transaction_open?, + logger: nil, + dry_run: false + ) + end + end +end diff --git a/app/workers/database/monitor_locked_tables_worker.rb b/app/workers/database/monitor_locked_tables_worker.rb index 66296ea1c0d..4a23d25edf4 100644 --- a/app/workers/database/monitor_locked_tables_worker.rb +++ b/app/workers/database/monitor_locked_tables_worker.rb @@ -33,6 +33,13 @@ module Database handle_lock_writes_result(tables_lock_info_per_db, result) end + tables_lock_info_per_db.each do |database_name, database_results| + next if database_results[:tables_need_lock].empty? + break if Feature.disabled?(:lock_tables_in_monitoring, type: :ops) + + LockTablesWorker.perform_async(database_name, database_results[:tables_need_lock]) + end + log_extra_metadata_on_done(:results, tables_lock_info_per_db) end diff --git a/app/workers/environments/stop_job_success_worker.rb b/app/workers/environments/stop_job_success_worker.rb index cc7d83512f3..93b743ee602 100644 --- a/app/workers/environments/stop_job_success_worker.rb +++ b/app/workers/environments/stop_job_success_worker.rb @@ -9,15 +9,15 @@ module Environments feature_category :continuous_delivery def perform(job_id, _params = {}) - Ci::Build.find_by_id(job_id).try do |build| - stop_environment(build) if build.stops_environment? && build.stop_action_successful? + Ci::Processable.find_by_id(job_id).try do |job| + stop_environment(job) if job.stops_environment? && job.stop_action_successful? end end private - def stop_environment(build) - build.persisted_environment.fire_state_event(:stop_complete) + def stop_environment(job) + job.persisted_environment.fire_state_event(:stop_complete) end end end diff --git a/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb b/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb new file mode 100644 index 00000000000..7f281352a1b --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + # AdvanceStageWorker is a worker used by the BitBucket Importer to wait for a + # number of jobs to complete, without blocking a thread. Once all jobs have + # been completed this worker will advance the import process to the next + # stage. + class AdvanceStageWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include ::Gitlab::Import::AdvanceStage + + data_consistency :delayed + + sidekiq_options dead: false, retry: 3 + + feature_category :importers + + loggable_arguments 1, 2 + + # The known importer stages and their corresponding Sidekiq workers. + STAGES = { + finish: Stage::FinishImportWorker + }.freeze + + def find_import_state(project_id) + ProjectImportState.jid_by(project_id: project_id, status: :started) + end + + private + + def next_stage_worker(next_stage) + STAGES.fetch(next_stage.to_sym) + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/import_pull_request_worker.rb b/app/workers/gitlab/bitbucket_import/import_pull_request_worker.rb new file mode 100644 index 00000000000..5b06ddf7079 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/import_pull_request_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + class ImportPullRequestWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def importer_class + Importers::PullRequestImporter + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/finish_import_worker.rb b/app/workers/gitlab/bitbucket_import/stage/finish_import_worker.rb new file mode 100644 index 00000000000..a1c5f5787be --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/finish_import_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class FinishImportWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + def import(project) + project.after_import + + Gitlab::Import::Metrics.new(:bitbucket_importer, project).track_finished_import + end + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb new file mode 100644 index 00000000000..e1f3b5ab79a --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class ImportPullRequestsWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + # project - An instance of Project. + def import(project) + waiter = importer_class.new(project).execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :finish + ) + end + + def importer_class + Importers::PullRequestsImporter + end + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_repository_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_repository_worker.rb new file mode 100644 index 00000000000..7c6503ae38f --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/import_repository_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class ImportRepositoryWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + def import(project) + importer = importer_class.new(project) + + importer.execute + + ImportPullRequestsWorker.perform_async(project.id) + end + + def importer_class + Importers::RepositoryImporter + end + + def abort_on_failure + true + end + end + end + end +end diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb index 1f17c98dff9..60e4c8fdad6 100644 --- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb +++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb @@ -25,7 +25,7 @@ module Gitlab # Gitlab::GithubGistsImport::FinishImportWorker to prevent # the entire import from getting stuck if args.length == 3 && (key = args.last) && key.is_a?(String) - JobWaiter.notify(key, jid) + JobWaiter.notify(key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) end end @@ -48,7 +48,7 @@ module Gitlab ) end - JobWaiter.notify(notify_key, jid) + JobWaiter.notify(notify_key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) end rescue StandardError => e log_and_track_error(user_id, e, github_identifiers) diff --git a/app/workers/gitlab/import/advance_stage.rb b/app/workers/gitlab/import/advance_stage.rb index 9fc03efe9d0..5d5abc88388 100644 --- a/app/workers/gitlab/import/advance_stage.rb +++ b/app/workers/gitlab/import/advance_stage.rb @@ -15,7 +15,15 @@ module Gitlab # next_stage - The name of the next stage to start when all jobs have been # completed. def perform(project_id, waiters, next_stage) - return unless import_state = find_import_state(project_id) + import_state = find_import_state(project_id) + + # If the import state is nil the project may have been deleted or the import + # may have failed or been canceled. In this case we tidy up the cache data and no + # longer attempt to advance to the next stage. + if import_state.nil? + clear_waiter_caches(waiters) + return + end new_waiters = wait_for_jobs(waiters) @@ -56,6 +64,12 @@ module Gitlab def next_stage_worker(next_stage) raise NotImplementedError end + + def clear_waiter_caches(waiters) + waiters.each_key do |key| + JobWaiter.delete_key(key) + end + end end end end diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb index eabe988dfc2..2b676238a37 100644 --- a/app/workers/gitlab/jira_import/import_issue_worker.rb +++ b/app/workers/gitlab/jira_import/import_issue_worker.rb @@ -8,9 +8,9 @@ module Gitlab data_consistency :always sidekiq_options retry: 3 - include NotifyUponDeath include Gitlab::JiraImport::QueueOptions include Gitlab::Import::DatabaseHelpers + include Gitlab::Import::NotifyUponDeath loggable_arguments 3 @@ -27,7 +27,7 @@ module Gitlab JiraImport.increment_issue_failures(project_id) ensure # ensure we notify job waiter that the job has finished - JobWaiter.notify(waiter_key, jid) if waiter_key + JobWaiter.notify(waiter_key, jid, ttl: Gitlab::Import::JOB_WAITER_TTL) if waiter_key end private diff --git a/app/workers/incident_management/close_incident_worker.rb b/app/workers/incident_management/close_incident_worker.rb index c820a8a97bf..011cb76442b 100644 --- a/app/workers/incident_management/close_incident_worker.rb +++ b/app/workers/incident_management/close_incident_worker.rb @@ -25,7 +25,7 @@ module IncidentManagement private def user - @user ||= User.alert_bot + @user ||= Users::Internal.alert_bot end def close_incident(incident) diff --git a/app/workers/incident_management/process_alert_worker_v2.rb b/app/workers/incident_management/process_alert_worker_v2.rb index 04c02d17704..12434671527 100644 --- a/app/workers/incident_management/process_alert_worker_v2.rb +++ b/app/workers/incident_management/process_alert_worker_v2.rb @@ -32,7 +32,7 @@ module IncidentManagement def create_issue_for(alert) AlertManagement::CreateAlertIssueService - .new(alert, User.alert_bot) + .new(alert, Users::Internal.alert_bot) .execute end diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb index e6d0261b7f1..e1233a449e7 100644 --- a/app/workers/loose_foreign_keys/cleanup_worker.rb +++ b/app/workers/loose_foreign_keys/cleanup_worker.rb @@ -12,18 +12,23 @@ module LooseForeignKeys idempotent! def perform + connection_name, base_model = current_connection_name_and_base_model + modification_tracker, turbo_mode = initialize_modification_tracker_for(connection_name) + # Add small buffer on MAX_RUNTIME to account for single long running # query or extra worker time after the cleanup. - lock_ttl = ModificationTracker::MAX_RUNTIME + 20.seconds + lock_ttl = modification_tracker.max_runtime + 10.seconds in_lock(self.class.name.underscore, ttl: lock_ttl, retries: 0) do stats = {} - connection_name, base_model = current_connection_name_and_base_model - Gitlab::Database::SharedModel.using_connection(base_model.connection) do - stats = ProcessDeletedRecordsService.new(connection: base_model.connection).execute + stats = ProcessDeletedRecordsService.new( + connection: base_model.connection, + modification_tracker: modification_tracker + ).execute stats[:connection] = connection_name + stats[:turbo_mode] = turbo_mode end log_extra_metadata_on_done(:stats, stats) @@ -41,5 +46,16 @@ module LooseForeignKeys connections_with_name = Gitlab::Database.database_base_models_with_gitlab_shared.to_a # this will never be empty connections_with_name[minutes_since_epoch % connections_with_name.count] end + + def initialize_modification_tracker_for(connection_name) + turbo_mode = turbo_mode?(connection_name) + modification_tracker ||= turbo_mode ? TurboModificationTracker.new : ModificationTracker.new + [modification_tracker, turbo_mode] + end + + def turbo_mode?(connection_name) + %w[main ci].include?(connection_name) && + Feature.enabled?(:"loose_foreign_keys_turbo_mode_#{connection_name}", type: :ops) + end end end diff --git a/app/workers/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb index 2e6ce0005fc..677a1be25ca 100644 --- a/app/workers/members_destroyer/unassign_issuables_worker.rb +++ b/app/workers/members_destroyer/unassign_issuables_worker.rb @@ -8,7 +8,7 @@ module MembersDestroyer sidekiq_options retry: 3 - ENTITY_TYPES = %w(Group Project).freeze + ENTITY_TYPES = %w[Group Project].freeze queue_namespace :unassign_issuables feature_category :user_management diff --git a/app/workers/merge_requests/ensure_prepared_worker.rb b/app/workers/merge_requests/ensure_prepared_worker.rb new file mode 100644 index 00000000000..6dfe888f408 --- /dev/null +++ b/app/workers/merge_requests/ensure_prepared_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MergeRequests + class EnsurePreparedWorker + include ApplicationWorker + include CronjobQueue + + feature_category :code_review_workflow + idempotent! + deduplicate :until_executed + data_consistency :sticky + + JOBS_PER_10_SECONDS = 5 + + def perform + return unless Feature.enabled?(:ensure_merge_requests_prepared) + + scope = MergeRequest.recently_unprepared + + iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope) + + index = 0 + iterator.each_batch(of: JOBS_PER_10_SECONDS) do |merge_requests| + index += 1 + + NewMergeRequestWorker.bulk_perform_in_with_contexts(index * 10.seconds, + merge_requests, + arguments_proc: ->(merge_request) { [merge_request.id, merge_request.author_id] }, + context_proc: ->(merge_request) { { project: merge_request.project } } + ) + end + end + end +end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 3fcd7a3ad7a..a0594b15e31 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -29,3 +29,5 @@ class MergeWorker # rubocop:disable Scalability/IdempotentWorker .execute(merge_request) end end + +MergeWorker.prepend_mod diff --git a/app/workers/metrics/global_metrics_update_worker.rb b/app/workers/metrics/global_metrics_update_worker.rb index 326403a2f8f..9b196f7213f 100644 --- a/app/workers/metrics/global_metrics_update_worker.rb +++ b/app/workers/metrics/global_metrics_update_worker.rb @@ -16,9 +16,7 @@ module Metrics LEASE_TIMEOUT = 2.minutes - def perform - try_obtain_lease { ::Metrics::GlobalMetricsUpdateService.new.execute } - end + def perform; end def lease_timeout LEASE_TIMEOUT diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb deleted file mode 100644 index 470fba1227d..00000000000 --- a/app/workers/namespaces/in_product_marketing_emails_worker.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Namespaces - class InProductMarketingEmailsWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - - feature_category :experimentation_activation - urgency :low - - def perform - return if paid_self_managed_instance? - return if setting_disabled? - - Namespaces::InProductMarketingEmailsService.send_for_all_tracks_and_intervals - end - - private - - def paid_self_managed_instance? - false - end - - def setting_disabled? - !Gitlab::CurrentSettings.in_product_marketing_emails_enabled - end - end -end - -Namespaces::InProductMarketingEmailsWorker.prepend_mod_with('Namespaces::InProductMarketingEmailsWorker') diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index 74239c5d968..e2e738f79a5 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -2,22 +2,23 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include NewIssuable data_consistency :always - sidekiq_options retry: 3 - include NewIssuable idempotent! deduplicate :until_executed feature_category :code_review_workflow urgency :high + worker_resource_boundary :cpu weight 2 def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) + return if issuable.prepared? MergeRequests::AfterCreateService .new(project: issuable.target_project, current_user: user) diff --git a/app/workers/pages/invalidate_domain_cache_worker.rb b/app/workers/pages/invalidate_domain_cache_worker.rb deleted file mode 100644 index 1700b681b94..00000000000 --- a/app/workers/pages/invalidate_domain_cache_worker.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Pages - class InvalidateDomainCacheWorker - include Gitlab::EventStore::Subscriber - - idempotent! - - feature_category :pages - - def handle_event(event) - domain_ids(event).each do |domain_id| - ::Gitlab::Pages::CacheControl - .for_domain(domain_id) - .clear_cache - end - - event.data.values_at( - :root_namespace_id, - :old_root_namespace_id, - :new_root_namespace_id - ).compact.uniq.each do |namespace_id| - ::Gitlab::Pages::CacheControl - .for_namespace(namespace_id) - .clear_cache - end - end - - def domain_ids(event) - ids = PagesDomain.ids_for_project(event.data[:project_id]) - - ids << event.data[:domain_id] if event.data[:domain_id] - - ids - end - end -end diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb index de0bda82573..5f8316d184d 100644 --- a/app/workers/personal_access_tokens/expiring_worker.rb +++ b/app/workers/personal_access_tokens/expiring_worker.rb @@ -29,9 +29,21 @@ module PersonalAccessTokens # rubocop: enable CodeReuse/ActiveRecord - notification_service.access_token_about_to_expire(user, token_names) + message = if user.project_bot? + notification_service.resource_access_tokens_about_to_expire(user, token_names) - Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" + "Notifying Bot User resource owners about expiring tokens" + else + notification_service.access_token_about_to_expire(user, token_names) + + "Notifying User about expiring tokens" + end + + Gitlab::AppLogger.info( + message: message, + class: self.class, + user_id: user.id + ) expiring_user_tokens.each_batch do |expiring_tokens| expiring_tokens.update_all(expire_notification_delivered: true) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 4971dc3775f..5345714a010 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -164,7 +164,7 @@ class PostReceive user: user, property: 'source_code_pushes', label: metric_path, - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_path).to_context] + context: [Gitlab::Usage::MetricDefinition.context_for(metric_path).to_context] ) end end diff --git a/app/workers/projects/inactive_projects_deletion_cron_worker.rb b/app/workers/projects/inactive_projects_deletion_cron_worker.rb index 31fdb3d9615..f16415af830 100644 --- a/app/workers/projects/inactive_projects_deletion_cron_worker.rb +++ b/app/workers/projects/inactive_projects_deletion_cron_worker.rb @@ -22,7 +22,7 @@ module Projects return unless ::Gitlab::CurrentSettings.delete_inactive_projects? @start_time ||= ::Gitlab::Metrics::System.monotonic_time - admin_bot = ::User.admin_bot + admin_bot = ::Users::Internal.admin_bot return unless admin_bot diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb index 9ebc52f77d3..bbe0c63cfd1 100644 --- a/app/workers/projects/record_target_platforms_worker.rb +++ b/app/workers/projects/record_target_platforms_worker.rb @@ -6,9 +6,9 @@ module Projects include ExclusiveLeaseGuard LEASE_TIMEOUT = 1.hour.to_i - APPLE_PLATFORM_LANGUAGES = %w(swift objective-c).freeze + APPLE_PLATFORM_LANGUAGES = %w[swift objective-c].freeze - feature_category :groups_and_projects + feature_category :experimentation_activation data_consistency :always deduplicate :until_executed urgency :low diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb index 87566bff467..33c54f07521 100644 --- a/app/workers/users/deactivate_dormant_users_worker.rb +++ b/app/workers/users/deactivate_dormant_users_worker.rb @@ -15,7 +15,7 @@ module Users return unless ::Gitlab::CurrentSettings.current_application_settings.deactivate_dormant_users - admin_bot = User.admin_bot + admin_bot = Users::Internal.admin_bot return unless admin_bot deactivate_users(User.dormant, admin_bot) diff --git a/app/workers/users/track_namespace_visits_worker.rb b/app/workers/users/track_namespace_visits_worker.rb new file mode 100644 index 00000000000..5b2a7b7d0fa --- /dev/null +++ b/app/workers/users/track_namespace_visits_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Users + class TrackNamespaceVisitsWorker + include ApplicationWorker + + feature_category :navigation + data_consistency :delayed + urgency :throttled + idempotent! + + GROUPS = 'groups' + PROJECTS = 'projects' + + def perform(entity_type, entity_id, user_id, time) + return unless entity_id && user_id + + case entity_type + when GROUPS + unless GroupVisit.visited_around?(entity_id: entity_id, user_id: user_id, time: time) + GroupVisit.create!(entity_id: entity_id, user_id: user_id, visited_at: time) + end + when PROJECTS + unless ProjectVisit.visited_around?(entity_id: entity_id, user_id: user_id, time: time) + ProjectVisit.create!(entity_id: entity_id, user_id: user_id, visited_at: time) + end + end + end + end +end diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb index 58084405769..71d2c398ca7 100644 --- a/app/workers/x509_issuer_crl_check_worker.rb +++ b/app/workers/x509_issuer_crl_check_worker.rb @@ -18,7 +18,7 @@ class X509IssuerCrlCheckWorker def perform @logger = Gitlab::GitLogger.build - X509Issuer.all.find_each do |issuer| + X509Issuer.with_crl_url.find_each do |issuer| with_context(related_class: X509IssuerCrlCheckWorker) do update_certificates(issuer) end |