diff options
Diffstat (limited to 'app')
1882 files changed, 27240 insertions, 16194 deletions
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 d15c8e6e703..85b3c994e02 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 @@ -32,7 +32,6 @@ export default { i18n: { emptyField: __('Never'), expired: __('Expired'), - header: __('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), modalMessage: __( 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', ), @@ -45,7 +44,6 @@ export default { 'initialActiveAccessTokens', 'noActiveTokensMessage', 'showRole', - 'information', ], data() { return { @@ -74,12 +72,6 @@ export default { return FIELDS.filter(({ key }) => !ignoredFields.includes(key)); }, - header() { - return sprintf(this.$options.i18n.header, { - accessTokenTypePlural: this.accessTokenTypePlural, - totalAccessTokens: this.activeAccessTokens.length, - }); - }, modalMessage() { return sprintf(this.$options.i18n.modalMessage, { accessTokenType: this.accessTokenType, @@ -114,65 +106,66 @@ export default { <template> <dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.EVENT_SUCCESS]="onSuccess"> - <div class="gl-pt-6"> - <h5>{{ header }}</h5> - - <p v-if="information" data-testid="information-section"> - {{ information }} - </p> - - <gl-table - data-testid="active-tokens" - :empty-text="noActiveTokensMessage" - :fields="filteredFields" - :items="activeAccessTokens" - :per-page="$options.PAGE_SIZE" - :current-page="currentPage" - :sort-compare="sortingChanged" - show-empty + <div> + <div + class="gl-new-card-body gl-px-0 gl-overflow-hidden gl-bg-gray-10 gl-border-l gl-border-r gl-border-b gl-rounded-bottom-base gl-mb-5 gl-md-mb-0" > - <template #cell(createdAt)="{ item: { createdAt } }"> - <user-date :date="createdAt" /> - </template> + <gl-table + data-testid="active-tokens" + :empty-text="noActiveTokensMessage" + :fields="filteredFields" + :items="activeAccessTokens" + :per-page="$options.PAGE_SIZE" + :current-page="currentPage" + :sort-compare="sortingChanged" + show-empty + stacked="sm" + > + <template #cell(createdAt)="{ item: { createdAt } }"> + <user-date :date="createdAt" /> + </template> - <template #head(lastUsedAt)="{ label }"> - <span>{{ label }}</span> - <gl-link :href="$options.lastUsedHelpLink" - ><gl-icon name="question-o" /><span class="gl-sr-only">{{ - s__('AccessTokens|The last time a token was used') - }}</span></gl-link - > - </template> + <template #head(lastUsedAt)="{ label }"> + <span>{{ label }}</span> + <gl-link :href="$options.lastUsedHelpLink" + ><gl-icon name="question-o" /><span class="gl-sr-only">{{ + s__('AccessTokens|The last time a token was used') + }}</span></gl-link + > + </template> - <template #cell(lastUsedAt)="{ item: { lastUsedAt } }"> - <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" /> - <template v-else> {{ $options.i18n.emptyField }}</template> - </template> + <template #cell(lastUsedAt)="{ item: { lastUsedAt } }"> + <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" /> + <template v-else> {{ $options.i18n.emptyField }}</template> + </template> - <template #cell(expiresAt)="{ item: { expiresAt, expired, expiresSoon } }"> - <template v-if="expiresAt"> - <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span> - <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" /> + <template #cell(expiresAt)="{ item: { expiresAt, expired, expiresSoon } }"> + <template v-if="expiresAt"> + <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span> + <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" /> + </template> + <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{ + $options.i18n.emptyField + }}</span> </template> - <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{ - $options.i18n.emptyField - }}</span> - </template> - <template #cell(action)="{ item: { revokePath } }"> - <gl-button - v-if="revokePath" - category="tertiary" - :aria-label="$options.i18n.revokeButton" - :data-confirm="modalMessage" - data-confirm-btn-variant="danger" - data-qa-selector="revoke_button" - data-method="put" - :href="revokePath" - icon="remove" - /> - </template> - </gl-table> + <template #cell(action)="{ item: { revokePath } }"> + <gl-button + v-if="revokePath" + category="tertiary" + :title="$options.i18n.revokeButton" + :aria-label="$options.i18n.revokeButton" + :data-confirm="modalMessage" + data-confirm-btn-variant="danger" + data-qa-selector="revoke_button" + data-method="put" + :href="revokePath" + icon="remove" + class="has-tooltip" + /> + </template> + </gl-table> + </div> <gl-pagination v-if="showPagination" v-model="currentPage" @@ -183,6 +176,7 @@ export default { :label-next-page="__('Go to next page')" :label-prev-page="__('Go to previous page')" align="center" + class="gl-mt-5" /> </div> </dom-element-listener> diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue index 02159d4d524..4b51b4333aa 100644 --- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue +++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue @@ -93,6 +93,12 @@ export default { this.form.querySelectorAll('input[type=checkbox]').forEach((el) => { el.checked = false; }); + document.querySelectorAll('.js-token-card').forEach((el) => { + el.querySelector('.js-add-new-token-form').style.display = ''; + el.querySelector('.js-toggle-button').style.display = 'block'; + el.querySelector('.js-token-count').innerText = + parseInt(el.querySelector('.js-token-count').innerText, 10) + 1; + }); }, }, }; @@ -105,23 +111,35 @@ export default { @[$options.EVENT_SUCCESS]="onSuccess" > <div ref="container" data-testid="access-token-section" data-qa-selector="access_token_section"> - <template v-if="newToken"> + <gl-alert + v-if="newToken" + variant="success" + data-testid="success-message" + @dismiss="newToken = null" + > <input-copy-toggle-visibility :copy-button-title="copyButtonTitle" :label="label" :label-for="$options.tokenInputId" :value="newToken" :form-input-group-props="formInputGroupProps" + readonly + size="lg" + class="gl-mb-0" > <template #description> {{ $options.i18n.description }} </template> </input-copy-toggle-visibility> - <hr /> - </template> + </gl-alert> <template v-if="errors"> - <gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null"> + <gl-alert + :title="alertDangerTitle" + variant="danger" + data-testid="error-message" + @dismiss="errors = null" + > <ul class="gl-m-0"> <li v-for="error in errors" :key="error"> {{ error }} diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue index 23803e82476..756d761ec97 100644 --- a/app/assets/javascripts/access_tokens/components/token.vue +++ b/app/assets/javascripts/access_tokens/components/token.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; @@ -20,6 +21,11 @@ export default { type: String, required: true, }, + size: { + type: String, + required: false, + default: null, + }, }, computed: { formInputGroupProps() { @@ -39,6 +45,8 @@ export default { :form-input-group-props="formInputGroupProps" :value="token" :copy-button-title="copyButtonTitle" + readonly + :size="size" > <template #description> <slot name="input-description"></slot> diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue index 88119ed8a84..af26bf85941 100644 --- a/app/assets/javascripts/access_tokens/components/tokens_app.vue +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -88,6 +88,7 @@ export default { :input-label="$options.i18n[tokenType].label" :copy-button-title="$options.i18n[tokenType].copyButtonTitle" :data-testid="$options.htmlAttributes[tokenType].containerTestId" + size="md" > <template #title> <div class="settings-sticky-header"> diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 510f118bbb5..4e0acaa74da 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -20,7 +20,6 @@ export const initAccessTokenTableApp = () => { const { accessTokenType, accessTokenTypePlural, - information, initialActiveAccessTokens: initialActiveAccessTokensJson, noActiveTokensMessage: noTokensMessage, } = el.dataset; @@ -39,7 +38,6 @@ export const initAccessTokenTableApp = () => { provide: { accessTokenType, accessTokenTypePlural, - information, initialActiveAccessTokens, noActiveTokensMessage, showRole, 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 a9fb692b299..c1ec46cfc50 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import { createAlert } from '~/alert'; diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue index c403adbbf60..020c121d1e4 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/token.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlFilteredSearchToken } from '@gitlab/ui'; diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js index 560834a26ae..978e1b98437 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/index.js +++ b/app/assets/javascripts/add_context_commits_modal/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters'; import * as actions from './actions'; 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 57d5d46ceb4..92478e10289 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue @@ -95,10 +95,12 @@ export default { return; } - axios - .put(this.report.updatePath, this.form) - .then(this.handleResponse) - .catch(this.handleError); + // 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); }, handleResponse({ data }) { this.toggleActionsDrawer(); 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 b229dd9e993..f24e491a745 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 @@ -14,6 +14,13 @@ export default { ListItem, AbuseCategory, }, + i18n: { + updatedAt: __('Updated %{timeAgo}'), + createdAt: __('Created %{timeAgo}'), + deletedUser: s__('AbuseReports|Deleted user'), + row: s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}'), + rowWithCount: s__('AbuseReports|%{reportedUser} reported for %{category} by %{count} users'), + }, props: { report: { type: Object, @@ -25,18 +32,24 @@ export default { const { sort } = queryToObject(window.location.search); const { createdAt, updatedAt } = this.report; const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort) - ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt } - : { template: __('Created %{timeAgo}'), timeAgo: createdAt }; + ? { template: this.$options.i18n.updatedAt, timeAgo: updatedAt } + : { template: this.$options.i18n.createdAt, timeAgo: createdAt }; return sprintf(template, { timeAgo: getTimeago().format(timeAgo) }); }, title() { - const { reportedUser, category, reporter } = this.report; - const template = s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}'); - return sprintf(template, { - reportedUser: reportedUser?.name || s__('AbuseReports|Deleted user'), - reporter: reporter?.name || s__('AbuseReports|Deleted user'), + const { reportedUser, category, reporter, count } = this.report; + + const reportedUserName = reportedUser?.name || this.$options.i18n.deletedUser; + const reporterName = reporter?.name || this.$options.i18n.deletedUser; + + const i18nRowCount = count > 1 ? this.$options.i18n.rowWithCount : this.$options.i18n.row; + + return sprintf(i18nRowCount, { + reportedUser: reportedUserName, + reporter: reporterName, category, + count, }); }, }, @@ -55,11 +68,7 @@ export default { </gl-link> </template> <template #left-secondary> - <abuse-category - :category="report.category" - class="gl-mt-2 gl-mb-3" - data-testid="abuse-report-category" - /> + <abuse-category :category="report.category" class="gl-mt-2 gl-mb-3" /> </template> <template #right-secondary> diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue index b1eb5371a35..bab0fe6dd7d 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue @@ -4,43 +4,52 @@ import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_ba import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { FILTERED_SEARCH_TOKENS, - DEFAULT_SORT, - SORT_OPTIONS, - isValidSortKey, + DEFAULT_SORT_STATUS_OPEN, + DEFAULT_SORT_STATUS_CLOSED, } from '~/admin/abuse_reports/constants'; -import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils'; + +import { + buildFilteredSearchCategoryToken, + isValidStatus, + isOpenStatus, + isValidSortKey, + sortOptions, +} from '~/admin/abuse_reports/utils'; export default { name: 'AbuseReportsFilteredSearchBar', components: { FilteredSearchBar }, - sortOptions: SORT_OPTIONS, inject: ['categories'], data() { return { initialFilterValue: [], - initialSortBy: DEFAULT_SORT, + initialSortBy: DEFAULT_SORT_STATUS_OPEN, }; }, computed: { tokens() { return [...FILTERED_SEARCH_TOKENS, buildFilteredSearchCategoryToken(this.categories)]; }, + query() { + return queryToObject(window.location.search); + }, + currentSortOptions() { + return sortOptions(this.query.status); + }, }, created() { - const query = queryToObject(window.location.search); + const { query } = this; // Backend shows open reports by default if status param is not specified. // To match that behavior, update the current URL to include status=open - // query when no status query is specified on load. + // query when no status is specified on load. if (!isValidStatus(query.status)) { query.status = 'open'; updateHistory({ url: setUrlParams(query), replace: true }); } - const sort = this.currentSortKey(); - if (sort) { - this.initialSortBy = query.sort; - } + const sortKey = this.currentSortKey(); + this.initialSortBy = sortKey; const tokens = this.tokens .filter((token) => query[token.type]) @@ -56,9 +65,13 @@ export default { }, methods: { currentSortKey() { - const { sort } = queryToObject(window.location.search); + const { status, sort } = this.query; - return isValidSortKey(sort) ? sort : undefined; + if (!isValidSortKey(status, sort) || !sort) { + return isOpenStatus(status) ? DEFAULT_SORT_STATUS_OPEN : DEFAULT_SORT_STATUS_CLOSED; + } + + return sort; }, handleFilter(tokens) { let params = tokens.reduce((accumulator, token) => { @@ -76,6 +89,7 @@ export default { }, {}); const sort = this.currentSortKey(); + if (sort) { params = { ...params, sort }; } @@ -83,7 +97,7 @@ export default { redirectTo(setUrlParams(params, window.location.href, true)); // eslint-disable-line import/no-deprecated }, handleSort(sort) { - const { page, ...query } = queryToObject(window.location.search); + const { page, ...query } = this.query; redirectTo(setUrlParams({ ...query, sort }, window.location.href, true)); // eslint-disable-line import/no-deprecated }, @@ -101,7 +115,7 @@ export default { :search-input-placeholder="__('Filter reports')" :initial-filter-value="initialFilterValue" :initial-sort-by="initialSortBy" - :sort-options="$options.sortOptions" + :sort-options="currentSortOptions" data-testid="abuse-reports-filtered-search-bar" @onFilter="handleFilter" @onSort="handleSort" diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js index acb79293dfb..8b14745543e 100644 --- a/app/assets/javascripts/admin/abuse_reports/constants.js +++ b/app/assets/javascripts/admin/abuse_reports/constants.js @@ -7,10 +7,9 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import { s__, __ } from '~/locale'; -const STATUS_OPTIONS = [ - { value: 'closed', title: __('Closed') }, - { value: 'open', title: __('Open') }, -]; +export const STATUS_OPEN = { value: 'open', title: __('Open') }; + +const STATUS_OPTIONS = [{ value: 'closed', title: __('Closed') }, STATUS_OPEN]; export const FILTERED_SEARCH_TOKEN_USER = { type: 'user', @@ -39,30 +38,39 @@ export const FILTERED_SEARCH_TOKEN_STATUS = { operators: OPERATORS_IS, }; -export const DEFAULT_SORT = 'created_at_desc'; -export const SORT_UPDATED_AT = Object.freeze({ +export const DEFAULT_SORT_STATUS_OPEN = 'number_of_reports_desc'; +export const DEFAULT_SORT_STATUS_CLOSED = 'created_at_desc'; + +export const SORT_UPDATED_AT = { id: 20, title: __('Updated date'), sortDirection: { descending: 'updated_at_desc', ascending: 'updated_at_asc', }, -}); -const SORT_CREATED_AT = Object.freeze({ +}; + +const SORT_CREATED_AT = { id: 10, title: __('Created date'), sortDirection: { - descending: DEFAULT_SORT, + descending: DEFAULT_SORT_STATUS_CLOSED, ascending: 'created_at_asc', }, -}); +}; + +const SORT_NUMBER_OF_REPORTS = { + id: 30, + title: __('Number of Reports'), + sortDirection: { + descending: DEFAULT_SORT_STATUS_OPEN, + }, +}; -export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT]; +export const SORT_OPTIONS_STATUS_CLOSED = [SORT_CREATED_AT, SORT_UPDATED_AT]; -export const isValidSortKey = (key) => - SORT_OPTIONS.some( - (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key, - ); +// when filtered for status=open reports, add an additional sorting option -> number of reports +export const SORT_OPTIONS_STATUS_OPEN = [SORT_NUMBER_OF_REPORTS, ...SORT_OPTIONS_STATUS_CLOSED]; export const FILTERED_SEARCH_TOKEN_CATEGORY = { type: 'category', @@ -74,9 +82,9 @@ export const FILTERED_SEARCH_TOKEN_CATEGORY = { }; export const FILTERED_SEARCH_TOKENS = [ + FILTERED_SEARCH_TOKEN_STATUS, FILTERED_SEARCH_TOKEN_USER, FILTERED_SEARCH_TOKEN_REPORTER, - FILTERED_SEARCH_TOKEN_STATUS, ]; export const ABUSE_CATEGORIES = { diff --git a/app/assets/javascripts/admin/abuse_reports/index.js b/app/assets/javascripts/admin/abuse_reports/index.js index dbc466af2d2..e4174e6c851 100644 --- a/app/assets/javascripts/admin/abuse_reports/index.js +++ b/app/assets/javascripts/admin/abuse_reports/index.js @@ -19,6 +19,7 @@ export const initAbuseReportsApp = () => { return new Vue({ el, + name: 'AbuseReportsAppRoot', provide: { categories }, render: (createElement) => createElement(AbuseReportsApp, { diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js index d30e8fb0ae5..a3d05e4dcb3 100644 --- a/app/assets/javascripts/admin/abuse_reports/utils.js +++ b/app/assets/javascripts/admin/abuse_reports/utils.js @@ -1,4 +1,10 @@ -import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants'; +import { + FILTERED_SEARCH_TOKEN_CATEGORY, + FILTERED_SEARCH_TOKEN_STATUS, + STATUS_OPEN, + SORT_OPTIONS_STATUS_OPEN, + SORT_OPTIONS_STATUS_CLOSED, +} from './constants'; export const buildFilteredSearchCategoryToken = (categories) => { const options = categories.map((c) => ({ value: c, title: c })); @@ -7,3 +13,13 @@ export const buildFilteredSearchCategoryToken = (categories) => { export const isValidStatus = (status) => FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status); + +export const isOpenStatus = (status) => status === STATUS_OPEN.value; + +export const sortOptions = (status) => + isOpenStatus(status) ? SORT_OPTIONS_STATUS_OPEN : SORT_OPTIONS_STATUS_CLOSED; + +export const isValidSortKey = (status, key) => + sortOptions(status).some( + (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key, + ); diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue index 667ab4c34f5..55bffe0a340 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue @@ -1,5 +1,5 @@ <script> -import { GlPagination } from '@gitlab/ui'; +import { GlButton, GlCard, GlIcon, GlPagination } from '@gitlab/ui'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import { createAlert, VARIANT_DANGER } from '~/alert'; @@ -15,6 +15,9 @@ export default { name: 'BroadcastMessagesBase', NEW_BROADCAST_MESSAGE, components: { + GlButton, + GlCard, + GlIcon, GlPagination, MessageForm, MessagesTable, @@ -36,6 +39,10 @@ export default { }, i18n: { + title: s__('BroadcastMessages|Messages'), + addTitle: s__('BroadcastMessages|Add new message'), + emptyMessage: s__('BroadcastMessages|No broadcast messages defined yet.'), + addButton: s__('BroadcastMessages|Add new message'), deleteError: s__( 'BroadcastMessages|There was an issue deleting this message, please try again later.', ), @@ -49,6 +56,7 @@ export default { ...message, disable_delete: false, })), + showAddForm: false, }; }, @@ -75,7 +83,12 @@ export default { buildPageUrl(newPage) { return buildUrlWithCurrentLocation(`?page=${newPage}`); }, - + toggleAddForm() { + this.showAddForm = !this.showAddForm; + }, + closeAddForm() { + this.showAddForm = false; + }, async deleteMessage(messageId) { const index = this.visibleMessages.findIndex((m) => m.id === messageId); if (!index === -1) return; @@ -101,17 +114,48 @@ export default { <template> <div> - <message-form :broadcast-message="$options.NEW_BROADCAST_MESSAGE" /> - <messages-table - v-if="hasVisibleMessages" - :messages="visibleMessages" - @delete-message="deleteMessage" - /> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-overflow-hidden gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title">{{ $options.i18n.title }}</h3> + <div class="gl-new-card-count"> + <gl-icon name="messages" class="gl-mr-2" /> + {{ messagesCount }} + </div> + </div> + <gl-button v-if="!showAddForm" size="small" @click="toggleAddForm">{{ + $options.i18n.addButton + }}</gl-button> + </template> + + <div v-if="showAddForm" class="gl-new-card-add-form gl-m-3"> + <h4 class="gl-mt-0">{{ $options.i18n.addTitle }}</h4> + <message-form + :broadcast-message="$options.NEW_BROADCAST_MESSAGE" + @close-add-form="closeAddForm" + /> + </div> + + <messages-table + v-if="hasVisibleMessages" + :messages="visibleMessages" + @delete-message="deleteMessage" + /> + <div v-else-if="!showAddForm" class="gl-new-card-empty gl-px-5 gl-py-4"> + {{ $options.i18n.emptyMessage }} + </div> + </gl-card> + <gl-pagination v-model="currentPage" :total-items="totalMessages" :link-gen="buildPageUrl" align="center" + class="gl-mt-5" /> </div> </template> 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 42a959e1b89..109df943c42 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -69,8 +69,13 @@ export default { dismissableDescription: s__('BroadcastMessages|Allow users to dismiss the broadcast message'), target: s__('BroadcastMessages|Target broadcast message'), targetRoles: s__('BroadcastMessages|Target roles'), + targetRolesRequired: s__('BroadcastMessages|Select at least one role.'), + targetRolesValidationMsg: s__('BroadcastMessages|One or more roles is required.'), targetPath: s__('BroadcastMessages|Target Path'), - targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome'), + targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome.'), + targetPathWithRolesReminder: s__( + 'BroadcastMessages|Leave blank to target all group and project pages.', + ), startsAt: s__('BroadcastMessages|Starts at'), endsAt: s__('BroadcastMessages|Ends at'), add: s__('BroadcastMessages|Add broadcast message'), @@ -110,6 +115,7 @@ export default { endsAt: new Date(this.broadcastMessage.endsAt.getTime()), renderedMessage: '', showInCli: this.broadcastMessage.showInCli, + isValidated: false, }; }, computed: { @@ -138,6 +144,18 @@ export default { this.targetSelected === TARGET_ROLES || this.targetSelected === TARGET_ALL_MATCHING_PATH ); }, + targetPathDescription() { + const defaultDescription = this.$options.i18n.targetPathDescription; + + if (this.showTargetRoles) { + return `${defaultDescription} ${this.$options.i18n.targetPathWithRolesReminder}`; + } + + return defaultDescription; + }, + targetRolesValid() { + return !this.showTargetRoles || this.targetAccessLevels.length > 0; + }, formPayload() { return JSON.stringify({ message: this.message, @@ -172,8 +190,17 @@ export default { this.targetSelected = this.initialTarget(); }, methods: { + closeForm() { + this.$emit('close-add-form'); + }, async onSubmit() { this.loading = true; + this.isValidated = true; + + if (!this.targetRolesValid) { + this.loading = false; + return; + } const success = await this.submitForm(); if (success) { @@ -182,7 +209,6 @@ export default { this.loading = false; } }, - async submitForm() { const requestMethod = this.isAddForm ? 'post' : 'patch'; @@ -197,7 +223,6 @@ export default { } return true; }, - async renderPreview() { try { const res = await axios.post(this.previewPath, this.formPayload, FORM_HEADERS); @@ -206,7 +231,6 @@ export default { this.renderedMessage = ''; } }, - initialTarget() { if (this.targetAccessLevels.length > 0) { return TARGET_ROLES; @@ -238,6 +262,7 @@ export default { id="message-textarea" v-model="message" size="sm" + autofocus :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS" :placeholder="$options.i18n.messagePlaceholder" data-testid="message-input" @@ -293,6 +318,9 @@ export default { <gl-form-group v-show="showTargetRoles" :label="$options.i18n.targetRoles" + :label-description="$options.i18n.targetRolesRequired" + :invalid-feedback="$options.i18n.targetRolesValidationMsg" + :state="!isValidated || targetRolesValid" data-testid="target-roles-checkboxes" > <gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" /> @@ -306,7 +334,7 @@ export default { > <gl-form-input id="target-path-input" v-model="targetPath" /> <gl-form-text> - {{ $options.i18n.targetPathDescription }} + {{ targetPathDescription }} </gl-form-text> </gl-form-group> @@ -325,11 +353,14 @@ export default { :loading="loading" :disabled="messageBlank" data-testid="submit-button" - class="gl-mr-2" + class="js-no-auto-disable gl-mr-2" > {{ isAddForm ? $options.i18n.add : $options.i18n.update }} </gl-button> - <gl-button v-if="!isAddForm" :href="messagesPath" data-testid="cancel-button"> + <gl-button v-if="isAddForm" @click="closeForm"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button v-else :href="messagesPath" data-testid="cancel-button"> {{ $options.i18n.cancel }} </gl-button> </div> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue index c95d4c96ea9..924b6e7451b 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue @@ -1,7 +1,7 @@ <script> -import { GlBroadcastMessage, GlButton, GlTableLite } from '@gitlab/ui'; +import { GlBroadcastMessage, GlButton, GlTableLite, GlModal, GlModalDirective } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { formatDate } from '~/lib/utils/datetime/date_format_utility'; const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!'; @@ -12,13 +12,31 @@ export default { GlBroadcastMessage, GlButton, GlTableLite, + GlModal, }, directives: { SafeHtml, + GlModal: GlModalDirective, }, i18n: { + title: s__('BroadcastMessages|Delete broadcast message'), edit: __('Edit'), delete: __('Delete'), + modalMessage: s__('BroadcastMessages|Do you really want to delete this broadcast message?'), + }, + modal: { + actionPrimary: { + text: s__('BroadcastMessages|Delete message'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, }, props: { messages: { @@ -81,6 +99,7 @@ export default { :items="messages" :fields="$options.fields" :tbody-tr-attr="{ 'data-testid': 'message-row' }" + class="gl-mt-n1 gl-mb-n2" stacked="md" > <template #cell(preview)="{ item: { message, theme, broadcast_type, dismissable } }"> @@ -104,17 +123,25 @@ export default { :href="edit_path" data-testid="edit-message" /> - <gl-button + v-gl-modal="`delete-message-${id}`" class="gl-ml-3" icon="remove" - variant="danger" :aria-label="$options.i18n.delete" rel="nofollow" :disabled="disable_delete" :data-testid="`delete-message-${id}`" - @click="$emit('delete-message', id)" /> + <gl-modal + :title="$options.i18n.title" + :action-primary="$options.modal.actionPrimary" + :action-secondary="$options.modal.actionSecondary" + :modal-id="`delete-message-${id}`" + size="sm" + @primary="$emit('delete-message', id)" + > + {{ $options.i18n.modalMessage }} + </gl-modal> </template> </gl-table-lite> </template> diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue index 134498af348..6610a0caec5 100644 --- a/app/assets/javascripts/admin/deploy_keys/components/table.vue +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -1,5 +1,14 @@ <script> -import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState, GlModal } from '@gitlab/ui'; +import { + GlCard, + GlTable, + GlButton, + GlPagination, + GlIcon, + GlLoadingIcon, + GlEmptyState, + GlModal, +} from '@gitlab/ui'; import { __ } from '~/locale'; import Api, { DEFAULT_PER_PAGE } from '~/api'; @@ -15,7 +24,7 @@ export default { newDeployKeyButtonText: __('New deploy key'), emptyStateTitle: __('No public deploy keys'), emptyStateDescription: __( - 'Deploy keys grant read/write access to all repositories in your instance', + 'Deploy keys grant read/write access to all repositories in your instance, start by creating a new one above.', ), delete: __('Delete deploy key'), edit: __('Edit deploy key'), @@ -37,10 +46,12 @@ export default { { key: 'fingerprint_sha256', label: __('Fingerprint (SHA256)'), + tdClass: 'gl-md-max-w-26', }, { key: 'fingerprint', label: __('Fingerprint (MD5)'), + tdClass: 'gl-md-max-w-26', }, { key: 'projects', @@ -75,10 +86,12 @@ export default { csrf, DEFAULT_PER_PAGE, components: { + GlCard, GlTable, GlButton, GlPagination, TimeAgoTooltip, + GlIcon, GlLoadingIcon, GlEmptyState, GlModal, @@ -177,85 +190,106 @@ export default { </script> <template> - <div> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-5"> - <h4 class="gl-m-0"> - {{ $options.i18n.pageTitle }} - </h4> - <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{ - $options.i18n.newDeployKeyButtonText - }}</gl-button> - </div> - <template v-if="shouldShowTable"> - <gl-table - :busy="loading" - :items="items" - :fields="$options.fields" - stacked="lg" - data-testid="deploy-keys-list" - > - <template #table-busy> - <gl-loading-icon size="lg" class="gl-my-5" /> - </template> + <gl-card + class="gl-new-card gl-overflow-hidden" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-overflow-hidden gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title">{{ $options.i18n.pageTitle }}</h3> + <span class="gl-new-card-count"> + <gl-icon name="key" class="gl-mr-2" /> + {{ totalItems }} + </span> + </div> + <div class="gl-new-card-actions"> + <gl-button size="small" :href="createPath" data-testid="new-deploy-key-button">{{ + $options.i18n.newDeployKeyButtonText + }}</gl-button> + </div> + </template> - <template #cell(projects)="{ item: { projects } }"> - <a - v-for="project in projects" - :key="project.id" - :href="projectHref(project)" - class="gl-display-block" - >{{ project.name_with_namespace }}</a - > - </template> + <gl-table + v-if="shouldShowTable" + :busy="loading" + :items="items" + :fields="$options.fields" + stacked="md" + data-testid="deploy-keys-list" + class="gl-mt-n1 gl-mb-n2" + > + <template #table-busy> + <gl-loading-icon size="sm" class="gl-my-5" /> + </template> - <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }"> - <span v-if="fingerprint_sha256" class="monospace">{{ fingerprint_sha256 }}</span> - </template> + <template #cell(projects)="{ item: { projects } }"> + <a + v-for="project in projects" + :key="project.id" + :href="projectHref(project)" + class="gl-display-block" + >{{ project.name_with_namespace }}</a + > + </template> + <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }"> + <div + v-if="fingerprint_sha256" + class="gl-font-monospace gl-text-truncate" + :title="fingerprint_sha256" + > + {{ fingerprint_sha256 }} + </div> + </template> - <template #cell(fingerprint)="{ item: { fingerprint } }"> - <span v-if="fingerprint" class="monospace">{{ fingerprint }}</span> - </template> + <template #cell(fingerprint)="{ item: { fingerprint } }"> + <div v-if="fingerprint" class="gl-font-monospace gl-text-truncate" :title="fingerprint"> + {{ fingerprint }} + </div> + </template> - <template #cell(created)="{ item: { created } }"> - <time-ago-tooltip :time="created" /> - </template> + <template #cell(created)="{ item: { created } }"> + <time-ago-tooltip :time="created" /> + </template> - <template #head(actions)="{ label }"> - <span class="gl-sr-only">{{ label }}</span> - </template> + <template #head(actions)="{ label }"> + <span class="gl-sr-only">{{ label }}</span> + </template> - <template #cell(actions)="{ item: { id } }"> - <gl-button - icon="pencil" - :aria-label="$options.i18n.edit" - :href="editHref(id)" - class="gl-mr-2" - /> - <gl-button - variant="danger" - icon="remove" - :aria-label="$options.i18n.delete" - @click="handleDeleteClick(id)" - /> - </template> - </gl-table> - <gl-pagination - v-if="!loading" - v-model="page" - :per-page="$options.DEFAULT_PER_PAGE" - :total-items="totalItems" - :next-text="$options.i18n.pagination.next" - :prev-text="$options.i18n.pagination.prev" - align="center" - /> - </template> + <template #cell(actions)="{ item: { id } }"> + <gl-button + icon="pencil" + size="small" + :aria-label="$options.i18n.edit" + :href="editHref(id)" + class="gl-mr-2" + /> + <gl-button + variant="danger" + category="secondary" + icon="remove" + size="small" + :aria-label="$options.i18n.delete" + @click="handleDeleteClick(id)" + /> + </template> + </gl-table> <gl-empty-state v-else :svg-path="emptyStateSvgPath" + :svg-height="150" :title="$options.i18n.emptyStateTitle" :description="$options.i18n.emptyStateDescription" - :primary-button-text="$options.i18n.newDeployKeyButtonText" - :primary-button-link="createPath" + /> + <gl-pagination + v-if="!loading" + v-model="page" + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :next-text="$options.i18n.pagination.next" + :prev-text="$options.i18n.pagination.prev" + align="center" + class="gl-mt-5" /> <gl-modal :modal-id="$options.modal.id" @@ -273,5 +307,5 @@ export default { </form> {{ $options.i18n.modal.body }} </gl-modal> - </div> + </gl-card> </template> diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue index 347d5f0229c..87325b07144 100644 --- a/app/assets/javascripts/admin/statistics_panel/components/app.vue +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlCard, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import statisticsLabels from '../constants'; diff --git a/app/assets/javascripts/admin/statistics_panel/store/index.js b/app/assets/javascripts/admin/statistics_panel/store/index.js index ece9e6419dd..67d2f83c788 100644 --- a/app/assets/javascripts/admin/statistics_panel/store/index.js +++ b/app/assets/javascripts/admin/statistics_panel/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js index d81690e8f4c..1125c1e35ee 100644 --- a/app/assets/javascripts/admin/topics/index.js +++ b/app/assets/javascripts/admin/topics/index.js @@ -1,13 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import showToast from '~/vue_shared/plugins/global_toast'; import RemoveAvatar from './components/remove_avatar.vue'; import MergeTopics from './components/merge_topics.vue'; -const toasts = document.querySelectorAll('.js-toast-message'); -toasts.forEach((toast) => showToast(toast.dataset.message)); - Vue.use(VueApollo); const apolloProvider = new VueApollo({ diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue index af09c7618e2..f62c41e32d6 100644 --- a/app/assets/javascripts/admin/users/components/actions/activate.vue +++ b/app/assets/javascripts/admin/users/components/actions/activate.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 2060528c7a0..5b13bd177ae 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index 36dcde619cf..966a4c4b291 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -19,7 +20,7 @@ const messageHtml = ` <p>${sprintf( s__('AdminUsers|Learn more about %{link_start}banned users.%{link_end}'), { - link_start: `<a href="${helpPagePath('user/admin_area/moderate_users', { + link_start: `<a href="${helpPagePath('administration/moderate_users', { anchor: 'ban-a-user', })}" target="_blank">`, link_end: '</a>', diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue index 534e1c76b8f..f288fb22d80 100644 --- a/app/assets/javascripts/admin/users/components/actions/block.vue +++ b/app/assets/javascripts/admin/users/components/actions/block.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue index 40911131d6d..dfbee2ab4db 100644 --- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue +++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index 83aa78c9f03..455f35aa8c1 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue index 7f786991709..454690af63a 100644 --- a/app/assets/javascripts/admin/users/components/actions/reject.vue +++ b/app/assets/javascripts/admin/users/components/actions/reject.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue index f84c7594f87..ca81f6400b7 100644 --- a/app/assets/javascripts/admin/users/components/actions/unban.vue +++ b/app/assets/javascripts/admin/users/components/actions/unban.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index 064f05ef8b1..c92728711bc 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue index 039ab3d651e..f19da56fdcd 100644 --- a/app/assets/javascripts/admin/users/components/actions/unlock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index 3860831169e..38b861a430a 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -36,9 +36,6 @@ export const i18n = { }, }; -const bodyTrClass = - 'gl-border-1 gl-border-t-solid gl-border-b-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-blue-200'; - export default { i18n, typeSet, @@ -85,20 +82,23 @@ export default { { key: 'active', label: __('Status'), + tdClass: 'gl-vertical-align-middle!', }, { key: 'name', label: s__('AlertsIntegrations|Integration Name'), + tdClass: 'gl-vertical-align-middle!', }, { key: 'type', label: __('Type'), + tdClass: 'gl-vertical-align-middle!', formatter: (value) => (value === typeSet.prometheus ? capitalize(value) : value), }, { key: 'actions', - thClass: `gl-text-center`, - tdClass: `gl-text-center`, + thClass: 'gl-text-right', + tdClass: 'gl-text-right gl-vertical-align-middle!', label: __('Actions'), }, ], @@ -127,12 +127,6 @@ export default { this.observer.observe(this.$el); }, methods: { - tbodyTrClass(item) { - return { - [bodyTrClass]: this.integrations?.length, - 'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id, - }; - }, trackPageViews() { const { category, action } = trackAlertIntegrationsViewsOptions; Tracking.event(category, action); @@ -160,7 +154,6 @@ export default { :fields="$options.fields" :busy="loading" stacked="md" - :tbody-tr-class="tbodyTrClass" show-empty > <template #cell(active)="{ item }"> @@ -187,7 +180,7 @@ export default { </template> <template #cell(actions)="{ item }"> - <gl-button-group class="gl-ml-3"> + <gl-button-group class="gl-ml-3 gl-mt-n2 gl-mb-n2"> <gl-button icon="settings" :aria-label="$options.i18n.editIntegration" @@ -204,17 +197,14 @@ export default { </template> <template #table-busy> - <gl-loading-icon size="lg" color="dark" class="mt-3" /> + <gl-loading-icon size="sm" /> </template> <template #empty> - <div - class="gl-border-t-solid gl-border-b-solid gl-border-1 gl-border gl-border-gray-100 mt-n3 gl-px-5" - > - <p class="gl-text-gray-400 gl-py-3 gl-my-3">{{ $options.i18n.emptyState }}</p> - </div> + <p class="gl-new-card-empty gl-text-center gl-mb-0">{{ $options.i18n.emptyState }}</p> </template> </gl-table> + <gl-modal modal-id="deleteIntegration" :title="$options.i18n.deleteIntegration" 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 033f48827f1..56740e436ca 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -387,307 +387,309 @@ export default { </script> <template> - <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset"> - <gl-tabs v-model="activeTabIndex"> - <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3"> - <gl-form-group - v-if="isCreating" - id="integration-type" - :label=" - getLabelWithStepNumber( - $options.integrationSteps.selectType, - $options.i18n.integrationFormSteps.selectType.label, - ) - " - label-for="integration-type" - > - <gl-form-select - v-model="integrationForm.type" - :disabled="isSelectDisabled" - class="gl-max-w-full" - data-qa-selector="integration_type_dropdown" - :options="integrationTypesOptions" - /> - - <alert-settings-form-help-block - v-if="!canAddIntegration" - disabled="true" - class="gl-display-inline-block gl-my-4" - :message="$options.i18n.integrationFormSteps.selectType.enterprise" - :link="pricingLink" - data-testid="multi-integrations-not-supported" - /> - </gl-form-group> - <div class="gl-mt-3"> + <div class="gl-new-card-add-form gl-py-0 gl-m-3"> + <gl-form @submit.prevent="submit" @reset.prevent="reset"> + <gl-tabs v-model="activeTabIndex"> + <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3"> <gl-form-group - v-if="isHttp" + v-if="isCreating" + id="integration-type" :label=" getLabelWithStepNumber( - $options.integrationSteps.nameIntegration, - $options.i18n.integrationFormSteps.nameIntegration.label, + $options.integrationSteps.selectType, + $options.i18n.integrationFormSteps.selectType.label, ) " - label-for="name-integration" - :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error" - :state="validationState.name" + label-for="integration-type" > - <gl-form-input - id="name-integration" - ref="integrationName" - v-model="integrationForm.name" - type="text" - :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" - data-qa-selector="integration_name_field" - @input="validateName" + <gl-form-select + v-model="integrationForm.type" + :disabled="isSelectDisabled" + class="gl-max-w-full" + data-qa-selector="integration_type_dropdown" + :options="integrationTypesOptions" + autofocus /> - </gl-form-group> - <gl-form-group - v-if="!isNone" - :label=" - getLabelWithStepNumber( - isHttp - ? $options.integrationSteps.enableHttpIntegration - : $options.integrationSteps.enablePrometheusIntegration, - $options.i18n.integrationFormSteps.enableIntegration.label, - ) - " - > - <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span> - - <gl-toggle - id="enable-integration" - v-model="integrationForm.active" - :is-loading="loading" - :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" - data-qa-selector="active_toggle_container" - class="gl-mt-4 gl-font-weight-normal" + <alert-settings-form-help-block + v-if="!canAddIntegration" + disabled="true" + class="gl-display-inline-block gl-my-4" + :message="$options.i18n.integrationFormSteps.selectType.enterprise" + :link="pricingLink" + data-testid="multi-integrations-not-supported" /> </gl-form-group> - <template v-if="showMappingBuilder"> + <div class="gl-mt-3"> <gl-form-group - data-testid="sample-payload-section" + v-if="isHttp" :label=" getLabelWithStepNumber( - $options.integrationSteps.customizeMapping, - $options.i18n.integrationFormSteps.mapFields.label, + $options.integrationSteps.nameIntegration, + $options.i18n.integrationFormSteps.nameIntegration.label, ) " - label-for="sample-payload" - class="gl-mb-0!" - :invalid-feedback="samplePayload.error" + label-for="name-integration" + :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error" + :state="validationState.name" > - <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span> - - <gl-form-textarea - id="sample-payload" - v-model="samplePayload.json" - :disabled="canEditPayload" - :state="isSampePayloadValid" - :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder" - class="gl-my-3" - :debounce="$options.JSON_VALIDATE_DELAY" - rows="6" - max-rows="10" - @input="validateJson" + <gl-form-input + id="name-integration" + ref="integrationName" + v-model="integrationForm.name" + type="text" + :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" + data-qa-selector="integration_name_field" + @input="validateName" /> </gl-form-group> + <gl-form-group + v-if="!isNone" + :label=" + getLabelWithStepNumber( + isHttp + ? $options.integrationSteps.enableHttpIntegration + : $options.integrationSteps.enablePrometheusIntegration, + $options.i18n.integrationFormSteps.enableIntegration.label, + ) + " + > + <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span> + + <gl-toggle + id="enable-integration" + v-model="integrationForm.active" + :is-loading="loading" + :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" + data-qa-selector="active_toggle_container" + class="gl-mt-4 gl-font-weight-normal" + /> + </gl-form-group> + <template v-if="showMappingBuilder"> + <gl-form-group + data-testid="sample-payload-section" + :label=" + getLabelWithStepNumber( + $options.integrationSteps.customizeMapping, + $options.i18n.integrationFormSteps.mapFields.label, + ) + " + label-for="sample-payload" + class="gl-mb-0!" + :invalid-feedback="samplePayload.error" + > + <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span> + + <gl-form-textarea + id="sample-payload" + v-model="samplePayload.json" + :disabled="canEditPayload" + :state="isSampePayloadValid" + :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder" + class="gl-my-3" + :debounce="$options.JSON_VALIDATE_DELAY" + rows="6" + max-rows="10" + @input="validateJson" + /> + </gl-form-group> + + <gl-button + v-if="canEditPayload" + v-gl-modal.resetPayloadModal + data-testid="payload-action-btn" + :disabled="!integrationForm.active" + class="gl-mt-3" + > + {{ $options.i18n.integrationFormSteps.mapFields.editPayload }} + </gl-button> + + <gl-button + v-else + data-testid="payload-action-btn" + :class="{ 'gl-mt-3': samplePayload.error }" + :disabled="!canParseSamplePayload" + :loading="samplePayload.loading" + @click="parseSamplePayload" + > + {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }} + </gl-button> + <gl-modal + modal-id="resetPayloadModal" + :title="$options.i18n.integrationFormSteps.mapFields.resetHeader" + :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk" + ok-variant="danger" + @ok="resetPayloadAndMappingConfirmed = true" + > + {{ $options.i18n.integrationFormSteps.mapFields.resetBody }} + </gl-modal> + + <div class="gl-mt-5"> + <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span> + <mapping-builder + :parsed-payload="parsedPayload" + :saved-mapping="mapping" + :alert-fields="alertFields" + @onMappingUpdate="updateMapping" + /> + </div> + </template> + </div> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> <gl-button - v-if="canEditPayload" - v-gl-modal.resetPayloadModal - data-testid="payload-action-btn" - :disabled="!integrationForm.active" - class="gl-mt-3" + :disabled="!canSubmitForm" + variant="confirm" + class="js-no-auto-disable" + data-testid="integration-form-submit" + @click="submit(false)" > - {{ $options.i18n.integrationFormSteps.mapFields.editPayload }} + {{ $options.i18n.saveIntegration }} </gl-button> <gl-button - v-else - data-testid="payload-action-btn" - :class="{ 'gl-mt-3': samplePayload.error }" - :disabled="!canParseSamplePayload" - :loading="samplePayload.loading" - @click="parseSamplePayload" + :disabled="!canSubmitForm" + variant="confirm" + category="secondary" + class="gl-ml-3 js-no-auto-disable" + data-qa-selector="save_and_create_alert_button" + @click="submit(true)" > - {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }} + {{ $options.i18n.saveAndTestIntegration }} </gl-button> - <gl-modal - modal-id="resetPayloadModal" - :title="$options.i18n.integrationFormSteps.mapFields.resetHeader" - :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk" - ok-variant="danger" - @ok="resetPayloadAndMappingConfirmed = true" - > - {{ $options.i18n.integrationFormSteps.mapFields.resetBody }} - </gl-modal> - - <div class="gl-mt-5"> - <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span> - <mapping-builder - :parsed-payload="parsedPayload" - :saved-mapping="mapping" - :alert-fields="alertFields" - @onMappingUpdate="updateMapping" - /> - </div> - </template> - </div> - <div class="gl-display-flex gl-justify-content-start gl-py-3"> - <gl-button - :disabled="!canSubmitForm" - variant="confirm" - class="js-no-auto-disable" - data-testid="integration-form-submit" - @click="submit(false)" - > - {{ $options.i18n.saveIntegration }} - </gl-button> - - <gl-button - :disabled="!canSubmitForm" - variant="confirm" - category="secondary" - class="gl-ml-3 js-no-auto-disable" - data-testid="integration-form-test-and-submit" - data-qa-selector="save_and_create_alert_button" - @click="submit(true)" - > - {{ $options.i18n.saveAndTestIntegration }} - </gl-button> - - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ - $options.i18n.cancelAndClose - }}</gl-button> - </div> - </gl-tab> - - <gl-tab - :title="$options.i18n.integrationTabs.viewCredentials" - :disabled="isCreating" - class="gl-mt-3" - > - <alert-settings-form-help-block - :message="viewCredentialsHelpMsg" - :link="$options.incidentManagementDocsLink" - /> - - <gl-form-group id="integration-webhook"> - <div class="gl-my-4"> - <span class="gl-font-weight-bold"> - {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }} - </span> - - <gl-form-input-group id="url" readonly :value="integrationForm.url"> - <template #append> - <clipboard-button - :text="integrationForm.url || ''" - :title="$options.i18n.copy" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - </div> - - <div class="gl-my-4"> - <span class="gl-font-weight-bold"> - {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }} - </span> - <gl-form-input-group - id="authorization-key" - class="gl-mb-3" - readonly - :value="integrationForm.token" - > - <template #append> - <clipboard-button - :text="integrationForm.token || ''" - :title="$options.i18n.copy" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ + $options.i18n.cancelAndClose + }}</gl-button> </div> - </gl-form-group> - - <div class="gl-display-flex gl-justify-content-start gl-py-3"> - <gl-button v-gl-modal.authKeyModal variant="danger"> - {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} - </gl-button> - - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> - {{ $options.i18n.cancelAndClose }} - </gl-button> - </div> - - <gl-modal - modal-id="authKeyModal" - :title="$options.i18n.integrationFormSteps.setupCredentials.reset" - :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset" - ok-variant="danger" - @ok="resetAuthKey" + </gl-tab> + + <gl-tab + :title="$options.i18n.integrationTabs.viewCredentials" + :disabled="isCreating" + class="gl-mt-3" > - {{ $options.i18n.integrationFormSteps.restKeyInfo.label }} - </gl-modal> - </gl-tab> - - <gl-tab - :title="$options.i18n.integrationTabs.sendTestAlert" - :disabled="isCreating" - class="gl-mt-3" - > - <gl-form-group id="test-integration" :invalid-feedback="testPayload.error"> <alert-settings-form-help-block - :message="$options.i18n.integrationFormSteps.testPayload.help" - :link="alertsUsageUrl" + :message="viewCredentialsHelpMsg" + :link="$options.incidentManagementDocsLink" /> - <gl-form-textarea - id="test-payload" - v-model="testPayload.json" - :state="isTestPayloadValid" - :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder" - class="gl-my-3" - :debounce="$options.JSON_VALIDATE_DELAY" - rows="6" - max-rows="10" - data-qa-selector="test_payload_field" - @input="validateJson(false)" - /> - </gl-form-group> - <div class="gl-display-flex gl-justify-content-start gl-py-3"> - <gl-button - v-gl-modal="testAlertModal" - :disabled="!isTestPayloadValid" - :loading="loading" - data-testid="send-test-alert" - variant="confirm" - class="js-no-auto-disable" - data-qa-selector="send_test_alert_button" - @click="isFormDirty ? null : sendTestAlert()" + <gl-form-group id="integration-webhook"> + <div class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }} + </span> + + <gl-form-input-group id="url" readonly :value="integrationForm.url"> + <template #append> + <clipboard-button + :text="integrationForm.url || ''" + :title="$options.i18n.copy" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + </div> + + <div class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }} + </span> + + <gl-form-input-group + id="authorization-key" + class="gl-mb-3" + readonly + :value="integrationForm.token" + > + <template #append> + <clipboard-button + :text="integrationForm.token || ''" + :title="$options.i18n.copy" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + </div> + </gl-form-group> + + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button v-gl-modal.authKeyModal variant="danger"> + {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} + </gl-button> + + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> + {{ $options.i18n.cancelAndClose }} + </gl-button> + </div> + + <gl-modal + modal-id="authKeyModal" + :title="$options.i18n.integrationFormSteps.setupCredentials.reset" + :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset" + ok-variant="danger" + @ok="resetAuthKey" > - {{ $options.i18n.send }} - </gl-button> - - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> - {{ $options.i18n.cancelAndClose }} - </gl-button> - </div> - - <gl-modal - :modal-id="$options.testAlertModalId" - :title="$options.i18n.integrationFormSteps.testPayload.modalTitle" - :action-primary="$options.primaryProps" - :action-secondary="$options.secondaryProps" - :action-cancel="$options.cancelProps" - @primary="saveAndSendTestAlert" - @secondary="sendTestAlert" + {{ $options.i18n.integrationFormSteps.restKeyInfo.label }} + </gl-modal> + </gl-tab> + + <gl-tab + :title="$options.i18n.integrationTabs.sendTestAlert" + :disabled="isCreating" + class="gl-mt-3" > - {{ $options.i18n.integrationFormSteps.testPayload.modalBody }} - </gl-modal> - </gl-tab> - </gl-tabs> - </gl-form> + <gl-form-group id="test-integration" :invalid-feedback="testPayload.error"> + <alert-settings-form-help-block + :message="$options.i18n.integrationFormSteps.testPayload.help" + :link="alertsUsageUrl" + /> + + <gl-form-textarea + id="test-payload" + v-model="testPayload.json" + :state="isTestPayloadValid" + :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder" + class="gl-my-3" + :debounce="$options.JSON_VALIDATE_DELAY" + rows="6" + max-rows="10" + data-qa-selector="test_payload_field" + @input="validateJson(false)" + /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button + v-gl-modal="testAlertModal" + :disabled="!isTestPayloadValid" + :loading="loading" + 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 }} + </gl-button> + + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> + {{ $options.i18n.cancelAndClose }} + </gl-button> + </div> + + <gl-modal + :modal-id="$options.testAlertModalId" + :title="$options.i18n.integrationFormSteps.testPayload.modalTitle" + :action-primary="$options.primaryProps" + :action-secondary="$options.secondaryProps" + :action-cancel="$options.cancelProps" + @primary="saveAndSendTestAlert" + @secondary="sendTestAlert" + > + {{ $options.i18n.integrationFormSteps.testPayload.modalBody }} + </gl-modal> + </gl-tab> + </gl-tabs> + </gl-form> + </div> </template> 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 cc8913c2f45..e4fc37f9760 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlButton, GlCard, GlTabs, GlTab, GlIcon } from '@gitlab/ui'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; @@ -43,8 +43,10 @@ export default { AlertSettingsForm, GlAlert, GlButton, + GlCard, GlTabs, GlTab, + GlIcon, }, inject: { projectPath: { @@ -364,39 +366,58 @@ export default { {{ $options.i18n.integrationCreated.successMsg }} </gl-alert> - <integrations-list - :integrations="integrations" - :loading="loading" - @edit-integration="editIntegration" - @delete-integration="deleteIntegration" - /> - <gl-button - v-if="canAddIntegration && !formVisible" - category="secondary" - variant="confirm" - data-testid="add-integration-btn" - data-qa-selector="add_integration_button" - class="gl-mt-3" - @click="setFormVisibility(true)" + <gl-card + class="gl-new-card gl-mt-2" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0 gl-overflow-hidden" > - {{ $options.i18n.addNewIntegration }} - </gl-button> - <alert-settings-form - v-if="formVisible" - :loading="isUpdating" - :can-add-integration="canAddIntegration" - :alert-fields="alertFields" - :tab-index="tabIndex" - @create-new-integration="createNewIntegration" - @update-integration="updateIntegration" - @reset-token="resetToken" - @clear-current-integration="clearCurrentIntegration" - @test-alert-payload="testAlertPayload" - @save-and-test-alert-payload="saveAndTestAlertPayload" - /> + <template #header> + <div class="gl-new-card-title-wrapper"> + <h5 class="gl-new-card-title"> + {{ $options.i18n.card.title }} + <span class="gl-new-card-count"> + <gl-icon name="warning" class="gl-mr-2" /> + {{ integrations.length }} + </span> + </h5> + </div> + <div class="gl-new-card-actions"> + <gl-button + v-if="canAddIntegration && !formVisible" + size="small" + data-testid="add-integration-btn" + data-qa-selector="add_integration_button" + @click="setFormVisibility(true)" + > + {{ $options.i18n.addNewIntegration }} + </gl-button> + </div> + </template> + + <alert-settings-form + v-if="formVisible" + :loading="isUpdating" + :can-add-integration="canAddIntegration" + :alert-fields="alertFields" + :tab-index="tabIndex" + @create-new-integration="createNewIntegration" + @update-integration="updateIntegration" + @reset-token="resetToken" + @clear-current-integration="clearCurrentIntegration" + @test-alert-payload="testAlertPayload" + @save-and-test-alert-payload="saveAndTestAlertPayload" + /> + + <integrations-list + :integrations="integrations" + :loading="loading" + @edit-integration="editIntegration" + @delete-integration="deleteIntegration" + /> + </gl-card> </gl-tab> <gl-tab :title="$options.i18n.settingsTabs.integrationSettings"> - <alerts-form class="gl-pt-3" data-testid="alert-integration-settings-tab" /> + <alerts-form class="gl-pt-3" /> </gl-tab> </gl-tabs> </template> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index 218b09cb1b6..a5f18fda542 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -1,6 +1,9 @@ import { s__, __ } from '~/locale'; export const i18n = { + card: { + title: s__('AlertSettings|Active alerts'), + }, integrationTabs: { configureDetails: s__('AlertSettings|Configure details'), viewCredentials: s__('AlertSettings|View credentials'), diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index 39da3484dfe..84ee8f41b11 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { getCookie, setCookie } from '~/lib/utils/common_utils'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 92649477922..a69909a68dd 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { OPERATORS_IS, diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue index 1e158baa925..38f9936c7c1 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue @@ -218,11 +218,11 @@ export default { <span data-testid="vsa-stage-header-duration">{{ data.label }}</span> </template> <template #head(end_event)="data"> - <span data-testid="vsa-stage-header-last-event">{{ data.label }}</span> + <span>{{ data.label }}</span> </template> <template #cell(title)="{ item }"> <div data-testid="vsa-stage-event"> - <div v-if="item.id" data-testid="vsa-stage-content"> + <div v-if="item.id"> <p class="gl-m-0"> <gl-link data-testid="vsa-stage-event-link" @@ -240,15 +240,10 @@ export default { <span class="icon-branch gl-text-gray-400"> <gl-icon name="commit" :size="14" /> </span> - <gl-link - class="commit-sha" - :href="item.commitUrl" - data-testid="vsa-stage-event-build-sha" - >{{ item.shortSha }}</gl-link - > + <gl-link class="commit-sha" :href="item.commitUrl">{{ item.shortSha }}</gl-link> </p> <p class="gl-m-0"> - <span data-testid="vsa-stage-event-build-author-and-date"> + <span> <gl-link class="gl-text-black-normal" :href="item.url">{{ item.date }}</gl-link> {{ s__('ByAuthor|by') }} <gl-link @@ -259,7 +254,7 @@ export default { </span> </p> </div> - <div v-else data-testid="vsa-stage-content"> + <div v-else> <h5 class="gl-font-weight-bold gl-my-1" data-testid="vsa-stage-event-title"> <gl-link class="gl-text-black-normal" :href="item.url">{{ itemTitle(item) }}</gl-link> </h5> diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/index.js b/app/assets/javascripts/analytics/cycle_analytics/store/index.js index 76e3e835016..c54d4eea7f1 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/index.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/index.js @@ -6,6 +6,7 @@ */ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters'; import * as actions from './actions'; diff --git a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue index fd966425920..593de1dcee7 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue @@ -40,7 +40,7 @@ export default { return this.devopsScoreMetrics.averageScore === undefined; }, }, - devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_reports'), + devopsReportDocsPath: helpPagePath('administration/analytics/dev_ops_reports'), tableHeaderFields: [ { key: 'title', diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue index a14b0bafecf..f47e0ccbbf2 100644 --- a/app/assets/javascripts/analytics/shared/components/daterange.vue +++ b/app/assets/javascripts/analytics/shared/components/daterange.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDaterangePicker } from '@gitlab/ui'; import { n__, __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 95da3b3cf49..185cdaa1c99 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -86,6 +86,7 @@ const Api = { freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id', serviceDataIncrementCounterPath: '/api/:version/usage_data/increment_counter', + serviceDataInternalEventPath: '/api/:version/usage_data/track_event', serviceDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', @@ -910,6 +911,20 @@ const Api = { return axios.post(url, { event }, { headers }); }, + trackInternalEvent(event) { + if (!gon.current_user_id || !gon.features?.usageDataApi) { + return null; + } + const url = Api.buildUrl(this.serviceDataInternalEventPath); + const headers = { + 'Content-Type': 'application/json', + }; + + const { data = {} } = { ...window.gl?.snowplowStandardContext }; + const { project_id, namespace_id } = data; + return axios.post(url, { event, project_id, namespace_id }, { headers }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index f9edebb9141..e7d066efe13 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -9,7 +9,7 @@ const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all'; const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups'; const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations'; -const axiosGet = (url, query, options, callback) => { +const axiosGet = (url, query, options, callback, axiosOptions = {}) => { return axios .get(url, { params: { @@ -17,6 +17,7 @@ const axiosGet = (url, query, options, callback) => { per_page: DEFAULT_PER_PAGE, ...options, }, + ...axiosOptions, }) .then(({ data, headers }) => { callback(data); @@ -25,14 +26,20 @@ const axiosGet = (url, query, options, callback) => { }); }; -export function getGroups(query, options, callback = () => {}) { +export function getGroups(query, options, callback = () => {}, axiosOptions = {}) { const url = buildApiUrl(GROUPS_PATH); - return axiosGet(url, query, options, callback); + return axiosGet(url, query, options, callback, axiosOptions); } -export function getDescendentGroups(parentGroupId, query, options, callback = () => {}) { +export function getDescendentGroups( + parentGroupId, + query, + options, + callback = () => {}, + axiosOptions = {}, +) { const url = buildApiUrl(DESCENDANT_GROUPS_PATH.replace(':id', parentGroupId)); - return axiosGet(url, query, options, callback); + return axiosGet(url, query, options, callback, axiosOptions); } export function updateGroup(groupId, data = {}) { diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue index f23a6fbcaa0..d3b914ea8aa 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue @@ -1,6 +1,6 @@ <script> import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui'; -import { Mousetrap } from '~/lib/mousetrap'; +import { Mousetrap, MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap'; import { __ } from '~/locale'; import Tracking from '~/tracking'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -10,7 +10,6 @@ import { PRINT_BUTTON_ACTION, TRACKING_LABEL_PREFIX, RECOVERY_CODE_DOWNLOAD_FILENAME, - COPY_KEYBOARD_SHORTCUT, } from '../constants'; export const i18n = { @@ -62,14 +61,14 @@ export default { created() { this.$options.mousetrap = new Mousetrap(); - this.$options.mousetrap.bind(COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy); + this.$options.mousetrap.bind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy); }, beforeDestroy() { if (!this.$options.mousetrap) { return; } - this.$options.mousetrap.unbind(COPY_KEYBOARD_SHORTCUT); + this.$options.mousetrap.unbind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT); }, methods: { handleButtonClick(action) { diff --git a/app/assets/javascripts/authentication/two_factor_auth/constants.js b/app/assets/javascripts/authentication/two_factor_auth/constants.js index 35fc49c88b2..8ca188c293f 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/constants.js +++ b/app/assets/javascripts/authentication/two_factor_auth/constants.js @@ -7,5 +7,3 @@ export const TRACKING_LABEL_PREFIX = '2fa_recovery_codes_'; export const RECOVERY_CODE_DOWNLOAD_FILENAME = 'gitlab-recovery-codes.txt'; export const SUCCESS_QUERY_PARAM = 'two_factor_auth_enabled_successfully'; - -export const COPY_KEYBOARD_SHORTCUT = 'mod+c'; diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 8bef972cc58..31531c90b94 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 1a80030c7e6..2be59f00773 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import { escape, debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert, VARIANT_INFO } from '~/alert'; @@ -28,6 +29,11 @@ export default { type: Boolean, required: true, }, + inModal: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -119,16 +125,28 @@ export default { exampleUrl, }); }, + cancelButtonType() { + return this.isEditing ? 'button' : 'reset'; + }, + saveText() { + return this.isEditing ? s__('Badges|Save changes') : s__('Badges|Add badge'); + }, + }, + mounted() { + // declared here to make it cancel-able + this.debouncedPreview = debounce(function search() { + this.renderBadge(); + }, badgePreviewDelayInMilliseconds); }, methods: { ...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']), - debouncedPreview: debounce(function preview() { - this.renderBadge(); - }, badgePreviewDelayInMilliseconds), - onCancel() { - this.stopEditing(); + updatePreview() { + this.debouncedPreview(); }, onSubmit() { + this.debouncedPreview.cancel(); + this.renderBadge(); + const form = this.$el; if (!form.checkValidity()) { this.wasValidated = true; @@ -161,6 +179,7 @@ export default { variant: VARIANT_INFO, }); this.wasValidated = false; + this.$emit('close-add-form'); }) .catch((error) => { createAlert({ @@ -171,6 +190,17 @@ export default { throw error; }); }, + closeForm() { + this.$refs.form.reset(); + this.$emit('close-add-form'); + }, + handleCancel() { + if (this.isEditing) { + this.stopEditing(); + } else { + this.closeForm(); + } + }, }, safeHtmlConfig: { ALLOW_TAGS: ['a', 'code'] }, }; @@ -178,12 +208,13 @@ export default { <template> <form + ref="form" :class="{ 'was-validated': wasValidated }" class="gl-mt-3 gl-mb-3 needs-validation" novalidate @submit.prevent.stop="onSubmit" > - <gl-form-group :label="s__('Badges|Name')" label-for="badge-name"> + <gl-form-group :label="s__('Badges|Name')" label-for="badge-name" class="gl-max-w-48"> <gl-form-input id="badge-name" v-model="name" data-qa-selector="badge_name_field" /> </gl-form-group> @@ -195,9 +226,9 @@ export default { v-model="linkUrl" data-qa-selector="badge_link_url_field" type="URL" - class="form-control gl-form-input" + class="form-control gl-form-input gl-max-w-80" required - @input="debouncedPreview" + @input="updatePreview" /> <div class="invalid-feedback">{{ s__('Badges|Enter a valid URL') }}</div> <span class="form-text text-muted">{{ badgeLinkUrlExample }}</span> @@ -211,9 +242,9 @@ export default { v-model="imageUrl" data-qa-selector="badge_image_url_field" type="URL" - class="form-control gl-form-input" + class="form-control gl-form-input gl-max-w-80" required - @input="debouncedPreview" + @input="updatePreview" /> <div class="invalid-feedback">{{ s__('Badges|Enter a valid URL') }}</div> <span class="form-text text-muted">{{ badgeImageUrlExample }}</span> @@ -235,29 +266,23 @@ export default { </p> </div> - <div v-if="isEditing" class="row-content-block"> - <gl-button class="btn-cancel gl-mr-4" data-testid="cancelEditing" @click="onCancel"> - {{ __('Cancel') }} - </gl-button> + <div v-if="!inModal" class="form-group" data-testid="action-buttons"> <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary" - data-testid="saveEditing" + data-qa-selector="add_badge_button" + class="gl-mr-3" > - {{ s__('Badges|Save changes') }} + {{ saveText }} </gl-button> - </div> - <div v-else class="form-group"> <gl-button - :loading="isSaving" - type="submit" - variant="confirm" - category="primary" - data-qa-selector="add_badge_button" + :type="cancelButtonType" + data-qa-selector="cancel_badge_button" + @click="handleCancel" > - {{ s__('Badges|Add badge') }} + {{ __('Cancel') }} </gl-button> </div> </form> diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index 76625fe9a60..b69890572eb 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -1,15 +1,43 @@ <script> -import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { GROUP_BADGE } from '../constants'; -import BadgeListRow from './badge_list_row.vue'; +import { + GlBadge, + GlLoadingIcon, + GlTable, + GlPagination, + GlButton, + GlModalDirective, +} from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapState } from 'vuex'; +import { __, s__ } from '~/locale'; +import { GROUP_BADGE, PROJECT_BADGE, INITIAL_PAGE, PAGE_SIZE } from '../constants'; +import Badge from './badge.vue'; export default { + PAGE_SIZE, + INITIAL_PAGE, name: 'BadgeList', components: { - BadgeListRow, - GlLoadingIcon, + Badge, GlBadge, + GlLoadingIcon, + GlTable, + GlPagination, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + i18n: { + emptyGroupMessage: s__('Badges|This group has no badges, start by creating a new one above.'), + emptyProjectMessage: s__( + 'Badges|This project has no badges, start by creating a new one above.', + ), + }, + data() { + return { + currentPage: INITIAL_PAGE, + }; }, computed: { ...mapState(['badges', 'isLoading', 'kind']), @@ -19,28 +47,123 @@ export default { isGroupBadge() { return this.kind === GROUP_BADGE; }, + showPagination() { + return this.badges.length > PAGE_SIZE; + }, + emptyMessage() { + return this.isGroupBadge + ? this.$options.i18n.emptyGroupMessage + : this.$options.i18n.emptyProjectMessage; + }, + fields() { + return [ + { + key: 'name', + label: __('Name'), + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'badge', + label: __('Badge'), + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'url', + label: __('URL'), + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'actions', + label: __('Actions'), + thClass: 'gl-text-right', + tdClass: 'gl-text-right', + }, + ]; + }, + }, + methods: { + ...mapActions(['editBadge', 'updateBadgeInModal']), + badgeKindText(item) { + if (item.kind === PROJECT_BADGE) { + return s__('Badges|Project Badge'); + } + + return s__('Badges|Group Badge'); + }, + canEditBadge(item) { + return item.kind === this.kind; + }, }, }; </script> <template> - <div class="card"> - <div class="card-header"> - {{ s__('Badges|Your badges') }} - <gl-badge v-show="!isLoading" size="sm">{{ badges.length }}</gl-badge> - </div> - <gl-loading-icon v-show="isLoading" size="lg" class="card-body" /> - <div v-if="hasNoBadges" class="card-body"> - <span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span> - <span v-else>{{ s__('Badges|This project has no badges') }}</span> - </div> - <div v-else class="card-body" data-qa-selector="badge_list_content"> - <badge-list-row - v-for="badge in badges" - :key="badge.id" - :badge="badge" - data-qa-selector="badge_list_row" - :data-qa-badge-name="badge.name" + <div> + <gl-loading-icon v-show="isLoading" size="md" /> + <div data-qa-selector="badge_list_content"> + <gl-table + :empty-text="emptyMessage" + :fields="fields" + :items="badges" + :per-page="$options.PAGE_SIZE" + :current-page="currentPage" + stacked="md" + show-empty + data-qa-selector="badge_list" + > + <template #cell(name)="{ item }"> + <label class="label-bold str-truncated mb-0">{{ item.name }}</label> + <gl-badge size="sm">{{ badgeKindText(item) }}</gl-badge> + </template> + + <template #cell(badge)="{ item }"> + <badge :image-url="item.renderedImageUrl" :link-url="item.renderedLinkUrl" /> + </template> + + <template #cell(url)="{ item }"> + {{ item.linkUrl }} + </template> + + <template #cell(actions)="{ item }"> + <div v-if="canEditBadge(item)" class="table-action-buttons" data-testid="badge-actions"> + <gl-button + v-gl-modal.edit-badge-modal + :disabled="item.isDeleting" + class="gl-mr-3" + variant="default" + icon="pencil" + size="medium" + :aria-label="__('Edit')" + data-testid="edit-badge-button" + @click="editBadge(item)" + /> + <gl-button + v-gl-modal.delete-badge-modal + :disabled="item.isDeleting" + category="secondary" + variant="danger" + icon="remove" + size="medium" + :aria-label="__('Delete')" + data-testid="delete-badge" + @click="updateBadgeInModal(item)" + /> + <gl-loading-icon v-show="item.isDeleting" size="sm" :inline="true" /> + </div> + </template> + </gl-table> + + <gl-pagination + v-if="showPagination" + v-model="currentPage" + :per-page="$options.PAGE_SIZE" + :total-items="badges.length" + :prev-text="__('Prev')" + :next-text="__('Next')" + :label-next-page="__('Go to next page')" + :label-prev-page="__('Go to previous page')" + align="center" + class="gl-mt-5" /> </div> </div> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue deleted file mode 100644 index 4c2b700c7ff..00000000000 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlLoadingIcon, GlButton, GlModalDirective, GlBadge } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import { PROJECT_BADGE } from '../constants'; -import Badge from './badge.vue'; - -export default { - name: 'BadgeListRow', - components: { - Badge, - GlLoadingIcon, - GlButton, - GlBadge, - }, - directives: { - GlModal: GlModalDirective, - }, - props: { - badge: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState(['kind']), - badgeKindText() { - if (this.badge.kind === PROJECT_BADGE) { - return s__('Badges|Project Badge'); - } - - return s__('Badges|Group Badge'); - }, - canEditBadge() { - return this.badge.kind === this.kind; - }, - }, - methods: { - ...mapActions(['editBadge', 'updateBadgeInModal']), - }, -}; -</script> - -<template> - <div class="gl-responsive-table-row-layout gl-responsive-table-row"> - <badge - :image-url="badge.renderedImageUrl" - :link-url="badge.renderedLinkUrl" - class="table-section section-30" - /> - <div class="table-section section-30"> - <label class="label-bold str-truncated mb-0">{{ badge.name }}</label> - <gl-badge size="sm">{{ badgeKindText }}</gl-badge> - </div> - <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span> - <div class="table-section section-10 table-button-footer"> - <div v-if="canEditBadge" class="table-action-buttons"> - <gl-button - :disabled="badge.isDeleting" - class="gl-mr-3" - variant="default" - icon="pencil" - size="medium" - :aria-label="__('Edit')" - @click="editBadge(badge)" - /> - <gl-button - v-gl-modal.delete-badge-modal - :disabled="badge.isDeleting" - variant="danger" - icon="remove" - size="medium" - :aria-label="__('Delete')" - data-testid="delete-badge" - @click="updateBadgeInModal(badge)" - /> - <gl-loading-icon v-show="badge.isDeleting" size="sm" :inline="true" /> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 09f997d73aa..f0d354c6378 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -1,5 +1,6 @@ <script> -import { GlSprintf, GlModal } from '@gitlab/ui'; +import { GlButton, GlCard, GlModal, GlIcon, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { createAlert, VARIANT_INFO } from '~/alert'; import { __, s__ } from '~/locale'; @@ -13,17 +14,34 @@ export default { Badge, BadgeForm, BadgeList, + GlButton, + GlCard, GlModal, + GlIcon, GlSprintf, }, i18n: { + title: s__('Badges|Your badges'), + addButton: s__('Badges|Add badge'), + addFormTitle: s__('Badges|Add new badge'), deleteModalText: s__( 'Badges|You are going to delete this badge. Deleted badges %{strongStart}cannot%{strongEnd} be restored.', ), }, + data() { + return { + addFormVisible: false, + }; + }, computed: { - ...mapState(['badgeInModal', 'isEditing']), - primaryProps() { + ...mapState(['badges', 'badgeInModal', 'isEditing']), + saveProps() { + return { + text: __('Save changes'), + attributes: { category: 'primary', variant: 'confirm' }, + }; + }, + deleteProps() { return { text: __('Delete badge'), attributes: { category: 'primary', variant: 'danger' }, @@ -37,7 +55,16 @@ export default { }, methods: { ...mapActions(['deleteBadge']), - onSubmitModal() { + showAddForm() { + this.addFormVisible = !this.addFormVisible; + }, + closeAddForm() { + this.addFormVisible = false; + }, + onSubmitEditModal() { + this.$refs.editForm.onSubmit(); + }, + onSubmitDeleteModal() { this.deleteBadge(this.badgeInModal) .then(() => { createAlert({ @@ -58,12 +85,54 @@ export default { <template> <div class="badge-settings"> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-overflow-hidden gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title">{{ $options.i18n.title }}</h3> + <span class="gl-new-card-count"> + <gl-icon name="labels" class="gl-mr-2" /> + {{ badges.length }} + </span> + </div> + <div class="gl-new-card-actions"> + <gl-button + v-if="!addFormVisible" + size="small" + data-testid="show-badge-add-form" + @click="showAddForm" + >{{ $options.i18n.addButton }}</gl-button + > + </div> + </template> + + <div v-if="addFormVisible" class="gl-new-card-add-form gl-m-5"> + <h4 class="gl-mt-0">{{ $options.i18n.addFormTitle }}</h4> + <badge-form :is-editing="false" @close-add-form="closeAddForm" /> + </div> + + <badge-list /> + </gl-card> + + <gl-modal + modal-id="edit-badge-modal" + :title="s__('Badges|Edit badge')" + :action-primary="saveProps" + :action-cancel="cancelProps" + @primary="onSubmitEditModal" + > + <badge-form ref="editForm" :is-editing="true" :in-modal="true" data-testid="edit-badge" /> + </gl-modal> + <gl-modal modal-id="delete-badge-modal" :title="s__('Badges|Delete badge?')" - :action-primary="primaryProps" + :action-primary="deleteProps" :action-cancel="cancelProps" - @primary="onSubmitModal" + @primary="onSubmitDeleteModal" > <div class="well"> <badge @@ -79,10 +148,5 @@ export default { </gl-sprintf> </p> </gl-modal> - - <badge-form v-show="isEditing" :is-editing="true" data-testid="edit-badge" /> - - <badge-form v-show="!isEditing" :is-editing="false" data-testid="add-new-badge" /> - <badge-list v-show="!isEditing" /> </div> </template> diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js index 709436abca6..56b45abbe6b 100644 --- a/app/assets/javascripts/badges/constants.js +++ b/app/assets/javascripts/badges/constants.js @@ -8,3 +8,5 @@ export const PLACEHOLDERS = [ 'default_branch', 'commit_sha', ]; +export const INITIAL_PAGE = 1; +export const PAGE_SIZE = 10; diff --git a/app/assets/javascripts/badges/store/index.js b/app/assets/javascripts/badges/store/index.js index 848deb2baa7..4894a1b7755 100644 --- a/app/assets/javascripts/badges/store/index.js +++ b/app/assets/javascripts/badges/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue index 74917da6426..62fd77ed534 100644 --- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue +++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import imageDiff from '~/diffs/mixins/image_diff'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index b78874d372c..f231db33b1e 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,5 +1,6 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import NoteableNote from '~/notes/components/noteable_note.vue'; diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue index 0cd093823bc..d7a8c09565a 100644 --- a/app/assets/javascripts/batch_comments/components/drafts_count.vue +++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -1,5 +1,6 @@ <script> import { GlBadge } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; export default { diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index ca9cb03ca37..42fc85cc5fb 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlDisclosureDropdown, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import PreviewItem from './preview_item.vue'; diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 71560c7de3a..8806550ceb5 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -1,5 +1,6 @@ <script> import { GlSprintf, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { sprintf, __ } from '~/locale'; diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index cc52285dd81..00bb9250403 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SET_REVIEW_BAR_RENDERED } from '~/batch_comments/stores/modules/batch_comments/mutation_types'; diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index 96889f0059c..72116b1eb7f 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; +import { GlDropdown, GlButton, GlIcon, GlForm, GlFormCheckbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import { createAlert } from '~/alert'; @@ -16,12 +17,16 @@ export default { GlButton, GlIcon, GlForm, - GlFormGroup, GlFormCheckbox, MarkdownEditor, ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'), + SummarizeMyReview: () => + import('ee_component/batch_comments/components/summarize_my_review.vue'), }, mixins: [glFeatureFlagsMixin()], + inject: { + canSummarize: { default: false }, + }, data() { return { isSubmitting: false, @@ -68,7 +73,7 @@ export default { // whenever a item in the autocomplete dropdown is clicked const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler; this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => { - if (!e.composedPath().includes(this.$el)) { + if (!e.target.closest('.atwho-container')) { originalClickOutHandler(e); } }; @@ -107,6 +112,9 @@ export default { this.isSubmitting = false; }, + updateNote(note) { + this.noteData.note = note; + }, }, restrictedToolbarItems: ['full-screen'], }; @@ -128,35 +136,45 @@ export default { <gl-icon class="dropdown-chevron" name="chevron-up" /> </template> <gl-form data-testid="submit-gl-form" @submit.prevent="submitReview"> - <gl-form-group label-for="review-note-body" label-class="gl-mb-2"> - <template #label> + <div class="gl-display-flex gl-mb-4 gl-align-items-center"> + <label for="review-note-body" class="gl-mb-0"> {{ __('Summary comment (optional)') }} - </template> - <div class="common-note-form gfm-form"> - <markdown-editor - ref="markdownEditor" - v-model="noteData.note" - :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" - class="js-no-autosize" - :is-submitting="isSubmitting" - :render-markdown-path="getNoteableData.preview_note_path" - :markdown-docs-path="getNotesData.markdownDocsPath" - :form-field-props="formFieldProps" - enable-autocomplete - :autocomplete-data-sources="autocompleteDataSources" - :disabled="isSubmitting" - :restricted-tool-bar-items="$options.restrictedToolbarItems" - :force-autosize="false" - :autosave-key="autosaveKey" - supports-quick-actions - @input="$emit('input', $event)" - @keydown.meta.enter="submitReview" - @keydown.ctrl.enter="submitReview" - /> - </div> - </gl-form-group> + </label> + <summarize-my-review + v-if="canSummarize" + :id="getNoteableData.id" + class="gl-ml-auto" + @input="updateNote" + /> + </div> + <div class="common-note-form gfm-form"> + <markdown-editor + ref="markdownEditor" + v-model="noteData.note" + :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" + class="js-no-autosize" + :is-submitting="isSubmitting" + :render-markdown-path="getNoteableData.preview_note_path" + :markdown-docs-path="getNotesData.markdownDocsPath" + :form-field-props="formFieldProps" + enable-autocomplete + :autocomplete-data-sources="autocompleteDataSources" + :disabled="isSubmitting" + :restricted-tool-bar-items="$options.restrictedToolbarItems" + :force-autosize="false" + :autosave-key="autosaveKey" + supports-quick-actions + @input="$emit('input', $event)" + @keydown.meta.enter="submitReview" + @keydown.ctrl.enter="submitReview" + /> + </div> <template v-if="getNoteableData.current_user.can_approve"> - <gl-form-checkbox v-model="noteData.approve" data-testid="approve_merge_request"> + <gl-form-checkbox + v-model="noteData.approve" + data-testid="approve_merge_request" + class="gl-mt-4" + > {{ __('Approve merge request') }} </gl-form-checkbox> <approval-password @@ -167,7 +185,7 @@ export default { data-testid="approve_password" /> </template> - <div class="gl-display-flex gl-justify-content-start gl-mt-5"> + <div class="gl-display-flex gl-justify-content-start gl-mt-4"> <gl-button :loading="isSubmitting" variant="confirm" diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js index bf9769ff359..fe3e27a253c 100644 --- a/app/assets/javascripts/batch_comments/index.js +++ b/app/assets/javascripts/batch_comments/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import store from '~/mr_notes/stores'; -export const initReviewBar = ({ editorAiActions = [] } = {}) => { +export const initReviewBar = () => { const el = document.getElementById('js-review-bar'); if (!el) return; @@ -21,7 +23,7 @@ export const initReviewBar = ({ editorAiActions = [] } = {}) => { }, provide: { newCommentTemplatePath: el.dataset.newCommentTemplatePath, - editorAiActions, + canSummarize: parseBoolean(el.dataset.canSummarize), }, computed: { ...mapGetters('batchComments', ['draftsCount']), diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index bec360e3b2e..fddb843bb52 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { sprintf, s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/batch_comments/stores/index.js b/app/assets/javascripts/batch_comments/stores/index.js index 08dc9ea70f8..2574d125746 100644 --- a/app/assets/javascripts/batch_comments/stores/index.js +++ b/app/assets/javascripts/batch_comments/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import batchComments from './modules/batch_comments'; diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 1d36661ee63..871b1279ce6 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -8,6 +8,7 @@ import initCopyAsGFM from './markdown/copy_as_gfm'; import './quick_submit'; import './requires_input'; import initPageShortcuts from './shortcuts'; +import { initToastMessages } from './toasts'; import './toggler_behavior'; import './preview_markdown'; @@ -21,6 +22,8 @@ initCopyToClipboard(); initPageShortcuts(); initCollapseSidebarOnWindowResize(); +initToastMessages(); + window.requestIdleCallback( () => { // Check if we have to Load GFM Input diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index bd13bcb35fc..689f2f0898e 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -399,6 +399,12 @@ export const ISSUABLE_CHANGE_LABEL = { defaultKeys: ['l'], }; +export const ISSUABLE_COPY_REF = { + id: 'issuables.copyIssuableRef', + description: __('Copy reference'), + defaultKeys: ['c r'], // eslint-disable-line @gitlab/require-i18n-strings +}; + export const ISSUE_MR_CHANGE_ASSIGNEE = { id: 'issuesMRs.changeAssignee', description: __('Change assignee'), @@ -430,6 +436,13 @@ export const MR_GO_TO_FILE = { customizable: false, }; +export const MR_TOGGLE_FILE_BROWSER = { + id: 'mergeRequests.toggleFileBrowser', + description: __('Toggle file browser'), + defaultKeys: ['f'], + customizable: false, +}; + export const MR_NEXT_UNRESOLVED_DISCUSSION = { id: 'mergeRequests.nextUnresolvedDiscussion', description: __('Next unresolved discussion'), @@ -599,7 +612,12 @@ const PROJECT_FILES_SHORTCUTS_GROUP = { const ISSUABLE_SHORTCUTS_GROUP = { id: 'issuables', name: __('Epics, issues, and merge requests'), - keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL], + keybindings: [ + ISSUABLE_COMMENT_OR_REPLY, + ISSUABLE_EDIT_DESCRIPTION, + ISSUABLE_CHANGE_LABEL, + ISSUABLE_COPY_REF, + ], }; const ISSUE_MR_SHORTCUTS_GROUP = { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue index 38384157007..b69a9b690b3 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { getModifierKey } from '~/constants'; import { __, s__ } from '~/locale'; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 0c882ff9ea2..b0e515ac19d 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -14,6 +14,7 @@ import { ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, MR_COPY_SOURCE_BRANCH_NAME, + ISSUABLE_COPY_REF, } from './keybindings'; import Shortcuts from './shortcuts'; @@ -21,15 +22,24 @@ export default class ShortcutsIssuable extends Shortcuts { constructor() { super(); - this.inMemoryButton = document.createElement('button'); - this.clipboardInstance = new ClipboardJS(this.inMemoryButton); - this.clipboardInstance.on('success', () => { + this.branchInMemoryButton = document.createElement('button'); + this.branchClipboardInstance = new ClipboardJS(this.branchInMemoryButton); + this.branchClipboardInstance.on('success', () => { toast(s__('GlobalShortcuts|Copied source branch name to clipboard.')); }); - this.clipboardInstance.on('error', () => { + this.branchClipboardInstance.on('error', () => { toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.')); }); + this.refInMemoryButton = document.createElement('button'); + this.refClipboardInstance = new ClipboardJS(this.refInMemoryButton); + this.refClipboardInstance.on('success', () => { + toast(s__('GlobalShortcuts|Copied reference to clipboard.')); + }); + this.refClipboardInstance.on('error', () => { + toast(s__('GlobalShortcuts|Unable to copy the reference at this time.')); + }); + this.bindCommands([ [ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')], [ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')], @@ -37,6 +47,7 @@ export default class ShortcutsIssuable extends Shortcuts { [ISSUABLE_COMMENT_OR_REPLY, ShortcutsIssuable.replyWithSelectedText], [ISSUABLE_EDIT_DESCRIPTION, ShortcutsIssuable.editIssue], [MR_COPY_SOURCE_BRANCH_NAME, () => this.copyBranchName()], + [ISSUABLE_COPY_REF, () => this.copyIssuableRef()], ]); /** @@ -163,9 +174,20 @@ export default class ShortcutsIssuable extends Shortcuts { const branchName = button?.dataset.clipboardText; if (branchName) { - this.inMemoryButton.dataset.clipboardText = branchName; + this.branchInMemoryButton.dataset.clipboardText = branchName; + + this.branchInMemoryButton.dispatchEvent(new CustomEvent('click')); + } + } + + async copyIssuableRef() { + const refButton = document.querySelector('.js-copy-reference'); + const copiedRef = refButton?.dataset.clipboardText; + + if (copiedRef) { + this.refInMemoryButton.dataset.clipboardText = copiedRef; - this.inMemoryButton.dispatchEvent(new CustomEvent('click')); + this.refInMemoryButton.dispatchEvent(new CustomEvent('click')); } } } diff --git a/app/assets/javascripts/behaviors/toasts.js b/app/assets/javascripts/behaviors/toasts.js new file mode 100644 index 00000000000..b6ac78cb540 --- /dev/null +++ b/app/assets/javascripts/behaviors/toasts.js @@ -0,0 +1,9 @@ +export async function initToastMessages() { + const toasts = document.querySelectorAll('.js-toast-message'); + if (!toasts.length) { + return; + } + + const { default: showToast } = await import('~/vue_shared/plugins/global_toast'); + toasts.forEach((toast) => showToast(toast.dataset.message)); +} diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index f032e2e7fb8..cb9997b7c54 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -3,7 +3,11 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; import BlobContentError from './blob_content_error.vue'; -import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants'; +import { + BLOB_RENDER_EVENT_LOAD, + BLOB_RENDER_EVENT_SHOW_SOURCE, + RICH_BLOB_VIEWER, +} from './constants'; export default { name: 'BlobContent', @@ -47,6 +51,9 @@ export default { default: false, }, }, + data() { + return { richContentLoaded: false }; + }, computed: { viewer() { switch (this.activeViewer.type) { @@ -59,13 +66,18 @@ export default { viewerError() { return this.activeViewer.renderError; }, + isContentLoaded() { + return this.activeViewer.type === RICH_BLOB_VIEWER + ? !this.loading && this.richContentLoaded + : !this.loading; + }, }, BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE, }; </script> <template> - <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="!loading"> + <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="isContentLoaded"> <gl-loading-icon v-if="loading" size="lg" color="dark" class="my-4 mx-auto" /> <template v-else> @@ -87,6 +99,7 @@ export default { :type="activeViewer.fileType" :hide-line-numbers="hideLineNumbers" data-qa-selector="blob_viewer_file_content" + @richContentLoaded="richContentLoaded = true" /> </template> </div> diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue index ee8bd23f844..d59e357877d 100644 --- a/app/assets/javascripts/blob/components/table_contents.vue +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -25,7 +25,6 @@ export default { } else if (blobViewerAttr('data-loaded') === 'true') { this.isHidden = false; this.generateHeaders(); - this.observer.disconnect(); } }); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js deleted file mode 100644 index e0ecfca75f5..00000000000 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ /dev/null @@ -1,202 +0,0 @@ -import $ from 'jquery'; - -import Api from '~/api'; -import initPopover from '~/blob/suggest_gitlab_ci_yml'; -import { createAlert } from '~/alert'; -import { __ } from '~/locale'; -import toast from '~/vue_shared/plugins/global_toast'; - -import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; -import DockerfileSelector from './template_selectors/dockerfile_selector'; -import GitignoreSelector from './template_selectors/gitignore_selector'; -import LicenseSelector from './template_selectors/license_selector'; - -export default class FileTemplateMediator { - constructor({ editor, currentAction, projectId }) { - this.editor = editor; - this.currentAction = currentAction; - this.projectId = projectId; - - this.initTemplateSelectors(); - this.initDomElements(); - this.initDropdowns(); - this.initPageEvents(); - this.cacheFileContents(); - } - - initTemplateSelectors() { - // Order dictates template type dropdown item order - this.templateSelectors = [ - GitignoreSelector, - BlobCiYamlSelector, - DockerfileSelector, - LicenseSelector, - ].map((TemplateSelectorClass) => new TemplateSelectorClass({ mediator: this })); - } - - initDomElements() { - const $templatesMenu = $('.template-selectors-menu'); - const $undoMenu = $templatesMenu.find('.template-selectors-undo-menu'); - const $fileEditor = $('.file-editor'); - - this.$templatesMenu = $templatesMenu; - this.$undoMenu = $undoMenu; - this.$undoBtn = $undoMenu.find('button'); - this.$templateSelectors = $templatesMenu.find('.template-selector-dropdowns-wrap'); - this.$filenameInput = $fileEditor.find('.js-file-path-name-input'); - this.$fileContent = $fileEditor.find('#file-content'); - this.$commitForm = $fileEditor.find('form'); - this.$navLinks = $fileEditor.find('.nav-links'); - } - - initDropdowns() { - if (this.currentAction !== 'create') { - this.hideTemplateSelectorMenu(); - } - - this.displayMatchedTemplateSelector(); - } - - initPageEvents() { - this.listenForFilenameInput(); - this.listenForPreviewMode(); - } - - listenForFilenameInput() { - this.$filenameInput.on('keyup blur', () => { - this.displayMatchedTemplateSelector(); - }); - } - - listenForPreviewMode() { - this.$navLinks.on('click', 'a', (e) => { - const urlPieces = e.target.href.split('#'); - const hash = urlPieces[1]; - if (hash === 'preview') { - this.hideTemplateSelectorMenu(); - } else if (hash === 'editor' && this.templateSelectors.find((sel) => sel.dropdown !== null)) { - this.showTemplateSelectorMenu(); - } - }); - } - - selectTemplateFile(selector, query, data) { - const self = this; - const { name } = selector.config; - const suggestCommitChanges = document.querySelector('.js-suggest-gitlab-ci-yml-commit-changes'); - - selector.renderLoading(); - - this.fetchFileTemplate(selector.config.type, query, data) - .then((file) => { - this.setEditorContent(file); - this.setFilename(name); - selector.renderLoaded(); - - toast(__(`${query} template applied`), { - action: { - text: __('Undo'), - onClick: (e, toastObj) => { - self.restoreFromCache(); - toastObj.hide(); - }, - }, - }); - - if (suggestCommitChanges) { - initPopover(suggestCommitChanges); - } - }) - .catch((err) => - createAlert({ - message: __(`An error occurred while fetching the template: ${err}`), - }), - ); - } - - displayMatchedTemplateSelector() { - const currentInput = this.getFilename(); - const matchedSelector = this.templateSelectors.find((sel) => - sel.config.pattern.test(currentInput), - ); - const currentSelector = this.templateSelectors.find((sel) => !sel.isHidden()); - - if (matchedSelector) { - if (currentSelector) { - currentSelector.hide(); - } - matchedSelector.show(); - this.showTemplateSelectorMenu(); - } else { - this.hideTemplateSelectorMenu(); - } - } - - fetchFileTemplate(type, query, data = {}) { - return new Promise((resolve) => { - const resolveFile = (file) => resolve(file); - - Api.projectTemplate(this.projectId, type, query, data, resolveFile); - }); - } - - setEditorContent(file) { - if (!file && file !== '') return; - - const newValue = file.content || file; - - this.editor.setValue(newValue, 1); - - this.editor.focus(); - - this.editor.navigateFileStart(); - } - - hideTemplateSelectorMenu() { - this.$templatesMenu.hide(); - } - - showTemplateSelectorMenu() { - this.$templatesMenu.show(); - this.cacheToggleText(); - } - - cacheToggleText() { - this.cachedToggleText = this.getTemplateSelectorToggleText(); - } - - cacheFileContents() { - this.cachedContent = this.editor.getValue(); - this.cachedFilename = this.getFilename(); - } - - restoreFromCache() { - this.setEditorContent(this.cachedContent); - this.setFilename(this.cachedFilename); - this.setTemplateSelectorToggleText(); - } - - getTemplateSelectorToggleText() { - return this.$templateSelectors - .find('.js-template-selector-wrap:visible .dropdown-toggle-text') - .text(); - } - - setTemplateSelectorToggleText() { - return this.$templateSelectors - .find('.js-template-selector-wrap:visible .dropdown-toggle-text') - .text(this.cachedToggleText); - } - - getFilename() { - return this.$filenameInput.val(); - } - - setFilename(name) { - const input = this.$filenameInput.get(0); - if (name !== undefined && input.value !== name) { - input.value = name; - input.dispatchEvent(new Event('change')); - } - } -} diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js deleted file mode 100644 index 4f970d657c2..00000000000 --- a/app/assets/javascripts/blob/file_template_selector.js +++ /dev/null @@ -1,105 +0,0 @@ -import $ from 'jquery'; -import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; - -export default class FileTemplateSelector { - constructor(mediator) { - this.mediator = mediator; - this.$dropdown = null; - this.$wrapper = null; - - this.dropdown = null; - this.wrapper = null; - } - - init() { - const cfg = this.config; - - this.$dropdown = $(cfg.dropdown); - this.$wrapper = $(cfg.wrapper); - - this.dropdown = document.querySelector(cfg.dropdown); - this.wrapper = document.querySelector(cfg.wrapper); - - this.dropdownIcon = this.wrapper.querySelector('.dropdown-menu-toggle-icon'); - this.loadingIcon = loadingIconForLegacyJS({ classes: ['gl-display-none'] }); - this.dropdown.appendChild(this.loadingIcon); - this.dropdownToggleText = this.wrapper.querySelector('.dropdown-toggle-text'); - - this.initDropdown(); - this.selectInitialTemplate(); - } - - selectInitialTemplate() { - const template = this.dropdown.dataset.selected; - - if (!template) { - return; - } - - this.mediator.selectTemplateFile(this, template); - } - - show() { - if (this.dropdown === null) { - this.init(); - } - - this.wrapper.classList.remove('hidden'); - - /** - * We set the focus on the dropdown that was just shown. This is done so that, after selecting - * a template type, the template selector immediately receives the focus. - * This improves the UX of the tour as the suggest_gitlab_ci_yml popover requires its target to - * be have the focus to appear. This way, users don't have to interact with the template - * selector to actually see the first hint: it is shown as soon as the selector becomes visible. - * We also need a timeout here, otherwise the template type selector gets stuck and can not be - * closed anymore. - */ - setTimeout(() => { - this.dropdown.focus(); - }, 0); - } - - hide() { - if (this.dropdown !== null) { - this.wrapper.classList.add('hidden'); - } - } - - isHidden() { - return !this.wrapper || this.wrapper.classList.contains('hidden'); - } - - getToggleText() { - return this.dropdownToggleText.textContent; - } - - setToggleText(text) { - this.dropdownToggleText.textContent = text; - } - - renderLoading() { - this.loadingIcon.classList.remove('gl-display-none'); - this.dropdownIcon.classList.add('gl-display-none'); - } - - renderLoaded() { - this.loadingIcon.classList.add('gl-display-none'); - this.dropdownIcon.classList.remove('gl-display-none'); - } - - reportSelection(options) { - const { query, e, data } = options; - e.preventDefault(); - return this.mediator.selectTemplateFile(this, query, data); - } - - reportSelectionName(options) { - const opts = options; - opts.query = options.selectedObj.name; - opts.data = options.selectedObj; - opts.data.source_template_project_id = options.selectedObj.project_id; - - this.reportSelection(opts); - } -} diff --git a/app/assets/javascripts/blob/filepath_form/components/filepath_form.vue b/app/assets/javascripts/blob/filepath_form/components/filepath_form.vue new file mode 100644 index 00000000000..7a3e31a642b --- /dev/null +++ b/app/assets/javascripts/blob/filepath_form/components/filepath_form.vue @@ -0,0 +1,63 @@ +<script> +import { GlFormInput } from '@gitlab/ui'; +import TemplateSelector from '~/blob/filepath_form/components/template_selector.vue'; + +export default { + components: { + GlFormInput, + TemplateSelector, + }, + props: { + templates: { + type: Object, + required: true, + }, + initialTemplate: { + type: String, + required: false, + default: undefined, + }, + inputOptions: { + type: Object, + required: true, + }, + suggestCiYmlData: { + type: Object, + required: false, + default: undefined, + }, + }, + data() { + return { + filename: this.inputOptions.value || '', + showTemplateSelector: true, + }; + }, + beforeMount() { + const navLinksElement = document.querySelector('.file-editor .nav-links'); + navLinksElement?.addEventListener('click', (e) => { + this.showTemplateSelector = e.target.href.split('#')[1] !== 'preview'; + }); + }, + methods: { + onTemplateSelected(data) { + this.$emit('template-selected', data); + }, + }, +}; +</script> +<template> + <div + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-w-full gl-lg-w-auto gl-gap-3 gl-mr-3" + > + <gl-form-input v-model="filename" v-bind="inputOptions" /> + <template-selector + v-if="showTemplateSelector" + :filename="filename" + :templates="templates" + :initial-template="initialTemplate" + :suggest-ci-yml-data="suggestCiYmlData" + @selected="onTemplateSelected" + /> + </div> +</template> diff --git a/app/assets/javascripts/blob/filepath_form/components/template_selector.vue b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue new file mode 100644 index 00000000000..51c69590796 --- /dev/null +++ b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue @@ -0,0 +1,161 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import SuggestGitlabCiYml from '~/blob/suggest_gitlab_ci_yml/components/popover.vue'; +import { __ } from '~/locale'; + +const templateSelectors = [ + { + key: 'gitignore_names', + name: '.gitignore', + pattern: /(.gitignore)/, + type: 'gitignores', + }, + { + key: 'gitlab_ci_ymls', + name: '.gitlab-ci.yml', + pattern: /(.gitlab-ci.yml)/, + type: 'gitlab_ci_ymls', + }, + { + key: 'dockerfile_names', + name: __('Dockerfile'), + pattern: /(Dockerfile)/, + type: 'dockerfiles', + }, + { + key: 'licenses', + name: 'LICENSE', + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + type: 'licenses', + }, +]; + +export default { + name: 'TemplateSelector', + components: { + SuggestGitlabCiYml, + GlCollapsibleListbox, + }, + props: { + filename: { + type: String, + required: true, + }, + templates: { + type: Object, + required: true, + }, + initialTemplate: { + type: String, + required: false, + default: undefined, + }, + suggestCiYmlData: { + type: Object, + required: false, + default: undefined, + }, + }, + data() { + return { + loading: false, + searchTerm: '', + selectedTemplate: undefined, + types: templateSelectors, + }; + }, + computed: { + activeType() { + return templateSelectors.find((selector) => selector.pattern.test(this.filename)); + }, + activeTemplatesList() { + return this.templates[this.activeType?.key]; + }, + selectedTemplateKey() { + return this.selectedTemplate?.key; + }, + dropdownToggleText() { + return this.selectedTemplate?.name || this.$options.i18n.templateSelectorTxt; + }, + dropdownItems() { + return Object.entries(this.activeTemplatesList) + .map(([key, items]) => ({ + text: key, + options: items + .filter((item) => item.name.toLowerCase().includes(this.searchTerm)) + .map((item) => ({ + text: item.name, + value: item.key, + })), + })) + .filter((group) => group.options.length > 0); + }, + templateItems() { + return Object.values(this.activeTemplatesList).reduce((acc, items) => [...acc, ...items], []); + }, + showDropdown() { + return this.activeType && this.templateItems.length > 0; + }, + showPopover() { + return this.activeType?.key === 'gitlab_ci_ymls' && this.suggestCiYmlData; + }, + }, + beforeMount() { + if (this.activeType) this.applyTemplate(this.initialTemplate); + }, + methods: { + applyTemplate(templateKey) { + this.selectedTemplate = this.templateItems.find((item) => item.key === templateKey); + if (this.selectedTemplate) { + this.loading = true; + this.$emit('selected', { + template: this.selectedTemplate, + type: this.activeType, + clearSelectedTemplate: this.clearSelectedTemplate, + stopLoading: this.stopLoading, + }); + } + }, + stopLoading() { + this.loading = false; + }, + clearSelectedTemplate() { + this.selectedTemplate = undefined; + }, + onSearch(searchTerm) { + this.searchTerm = searchTerm.trim().toLowerCase(); + }, + }, + i18n: { + templateSelectorTxt: __('Apply a template'), + searchPlaceholder: __('Filter'), + }, +}; +</script> +<template> + <div v-if="showDropdown"> + <suggest-gitlab-ci-yml + v-if="showPopover" + target="template-selector" + :track-label="suggestCiYmlData.trackLabel" + :dismiss-key="suggestCiYmlData.dismissKey" + :merge-request-path="suggestCiYmlData.mergeRequestPath" + :human-access="suggestCiYmlData.humanAccess" + /> + <gl-collapsible-listbox + id="template-selector" + searchable + block + class="gl-font-regular" + data-testid="template-selector" + data-qa-selector="template_selector" + :toggle-text="dropdownToggleText" + :search-placeholder="$options.i18n.searchPlaceholder" + :items="dropdownItems" + :selected="selectedTemplateKey" + :loading="loading" + @select="applyTemplate" + @search="onSearch" + /> + </div> +</template> diff --git a/app/assets/javascripts/blob/filepath_form/index.js b/app/assets/javascripts/blob/filepath_form/index.js new file mode 100644 index 00000000000..bcb285ddf34 --- /dev/null +++ b/app/assets/javascripts/blob/filepath_form/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import FilepathForm from './components/filepath_form.vue'; + +const getPopoverData = (el) => ({ + trackLabel: el.dataset.trackLabel, + dismissKey: el.dataset.dismissKey, + mergeRequestPath: el.dataset.mergeRequestPath, + humanAccess: el.dataset.humanAccess, +}); + +const getInputOptions = (el) => { + const { testid, qa_selector: qaSelector, ...options } = JSON.parse(el.dataset.inputOptions); + return { + ...options, + 'data-testid': testid, + }; +}; + +export default ({ onTemplateSelected }) => { + const el = document.getElementById('js-template-selectors-menu'); + + const suggestCiYmlEl = document.querySelector('.js-suggest-gitlab-ci-yml'); + const suggestCiYmlData = suggestCiYmlEl ? getPopoverData(suggestCiYmlEl) : undefined; + + return new Vue({ + el, + render(h) { + return h(FilepathForm, { + props: { + suggestCiYmlData, + inputOptions: getInputOptions(el), + templates: JSON.parse(el.dataset.templates), + initialTemplate: el.dataset.selected, + }, + on: { + 'template-selected': onTemplateSelected, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/blob/filepath_form_mediator.js b/app/assets/javascripts/blob/filepath_form_mediator.js new file mode 100644 index 00000000000..c26b8ea842b --- /dev/null +++ b/app/assets/javascripts/blob/filepath_form_mediator.js @@ -0,0 +1,105 @@ +import $ from 'jquery'; + +import Api from '~/api'; +import initPopover from '~/blob/suggest_gitlab_ci_yml'; +import { createAlert } from '~/alert'; +import { __, sprintf } from '~/locale'; +import toast from '~/vue_shared/plugins/global_toast'; +import mountFilepathForm from '~/blob/filepath_form'; + +export default class FilepathFormMediator { + constructor({ editor, currentAction, projectId }) { + this.editor = editor; + this.currentAction = currentAction; + this.projectId = projectId; + + this.initFilepathForm(); + this.initDomElements(); + this.cacheFileContents(); + } + + initFilepathForm() { + const handleTemplateSelect = ({ template, type, clearSelectedTemplate, stopLoading }) => { + this.selectTemplateFile(template, type, clearSelectedTemplate, stopLoading); + }; + mountFilepathForm({ action: this.currentAction, onTemplateSelected: handleTemplateSelect }); + } + + initDomElements() { + const $fileEditor = $('.file-editor'); + + this.$filenameInput = $fileEditor.find('.js-file-path-name-input'); + } + + selectTemplateFile(template, type, clearSelectedTemplate, stopLoading) { + const self = this; + const suggestCommitChanges = document.querySelector('.js-suggest-gitlab-ci-yml-commit-changes'); + + this.fetchFileTemplate(type.type, template.key, template) + .then((file) => { + this.setEditorContent(file); + this.setFilename(type.name); + + toast(sprintf(__('%{templateType} template applied'), { templateType: template.key }), { + action: { + text: __('Undo'), + onClick: (e, toastObj) => { + clearSelectedTemplate(); + self.restoreFromCache(); + toastObj.hide(); + }, + }, + }); + + if (suggestCommitChanges) { + initPopover(suggestCommitChanges); + } + }) + .catch((err) => + createAlert({ + message: sprintf(__('An error occurred while fetching the template: %{err}'), { err }), + }), + ) + .finally(() => stopLoading()); + } + + fetchFileTemplate(type, query, data = {}) { + return new Promise((resolve) => { + const resolveFile = (file) => resolve(file); + + Api.projectTemplate(this.projectId, type, query, data, resolveFile); + }); + } + + setEditorContent(file) { + if (!file && file !== '') return; + + const newValue = file.content || file; + + this.editor.setValue(newValue, 1); + + this.editor.focus(); + + this.editor.navigateFileStart(); + } + + cacheFileContents() { + this.cachedContent = this.editor.getValue(); + } + + restoreFromCache() { + this.setEditorContent(this.cachedContent); + } + + getFilename() { + return this.$filenameInput.val(); + } + + setFilename(name) { + const input = this.$filenameInput.get(0); + if (name !== undefined && input.value !== name) { + input.value = name; + input.dispatchEvent(new Event('input')); + } + } +} diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/legacy_template_selector.js index 59b7f82c10e..b712cc63fc9 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/legacy_template_selector.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; -export default class TemplateSelector { +export default class LegacyTemplateSelector { constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) { this.pattern = pattern; this.editor = editor; diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js index 1ec204b4034..4258d16b69f 100644 --- a/app/assets/javascripts/blob/line_highlighter.js +++ b/app/assets/javascripts/blob/line_highlighter.js @@ -153,7 +153,7 @@ LineHighlighter.prototype.highlightRange = function (range) { const results = []; const ref = range[0] <= range[1] ? range : range.reverse(); - for (let lineNumber = ref[0]; lineNumber <= ref[1]; lineNumber += 1) { + for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) { results.push(this.highlightLine(lineNumber)); } diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index e0b0857f7b4..8d37d272e50 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -23,7 +23,9 @@ const popoverStates = { ), }, }; + export default { + name: 'SuggestGitlabCiYml', dismissTrackValue: 10, clickTrackValue: 'click_button', components: { diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js deleted file mode 100644 index 0cdfd153675..00000000000 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ /dev/null @@ -1,30 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import FileTemplateSelector from '../file_template_selector'; - -export default class BlobCiYamlSelector extends FileTemplateSelector { - constructor({ mediator }) { - super(mediator); - this.config = { - key: 'gitlab-ci-yaml', - name: '.gitlab-ci.yml', - pattern: /(.gitlab-ci.yml)/, - type: 'gitlab_ci_ymls', - dropdown: '.js-gitlab-ci-yml-selector', - wrapper: '.js-gitlab-ci-yml-selector-wrap', - }; - } - - initDropdown() { - // maybe move to super class as well - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.$dropdown.data('data'), - filterable: true, - selectable: true, - search: { - fields: ['name'], - }, - clicked: (options) => this.reportSelectionName(options), - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js deleted file mode 100644 index b48b3d6bec3..00000000000 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ /dev/null @@ -1,31 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { __ } from '~/locale'; -import FileTemplateSelector from '../file_template_selector'; - -export default class DockerfileSelector extends FileTemplateSelector { - constructor({ mediator }) { - super(mediator); - this.config = { - key: 'dockerfile', - name: __('Dockerfile'), - pattern: /(Dockerfile)/, - type: 'dockerfiles', - dropdown: '.js-dockerfile-selector', - wrapper: '.js-dockerfile-selector-wrap', - }; - } - - initDropdown() { - // maybe move to super class as well - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.$dropdown.data('data'), - filterable: true, - selectable: true, - search: { - fields: ['name'], - }, - clicked: (options) => this.reportSelectionName(options), - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js deleted file mode 100644 index 50a11692e98..00000000000 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ /dev/null @@ -1,29 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import FileTemplateSelector from '../file_template_selector'; - -export default class BlobGitignoreSelector extends FileTemplateSelector { - constructor({ mediator }) { - super(mediator); - this.config = { - key: 'gitignore', - name: '.gitignore', - pattern: /(.gitignore)/, - type: 'gitignores', - dropdown: '.js-gitignore-selector', - wrapper: '.js-gitignore-selector-wrap', - }; - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.$dropdown.data('data'), - filterable: true, - selectable: true, - search: { - fields: ['name'], - }, - clicked: (options) => this.reportSelectionName(options), - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js deleted file mode 100644 index e7fabf18ea1..00000000000 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ /dev/null @@ -1,46 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import FileTemplateSelector from '../file_template_selector'; - -export default class BlobLicenseSelector extends FileTemplateSelector { - constructor({ mediator }) { - super(mediator); - this.config = { - key: 'license', - name: 'LICENSE', - pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, - type: 'licenses', - dropdown: '.js-license-selector', - wrapper: '.js-license-selector-wrap', - }; - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.$dropdown.data('data'), - filterable: true, - selectable: true, - search: { - fields: ['name'], - }, - clicked: (options) => { - const { e } = options; - const el = options.$el; - const query = options.selectedObj; - - const data = { - project: this.$dropdown.data('project'), - fullname: this.$dropdown.data('fullname'), - source_template_project_id: query.project_id, - }; - - this.reportSelection({ - query: query.id, - el, - e, - data, - }); - }, - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 7e667409556..b3bd23e49f8 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { createAlert } from '~/alert'; import { setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; @@ -10,9 +9,6 @@ const initPopovers = () => { if (suggestEl) { const commitButton = document.querySelector('#commit-changes'); - - initPopover(suggestEl); - if (commitButton) { const { dismissKey, humanAccess } = suggestEl.dataset; const urlParams = new URLSearchParams(window.location.search); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index f021553ae98..007fbd29e82 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -8,7 +8,7 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { insertFinalNewline } from '~/lib/utils/text_utility'; -import TemplateSelectorMediator from '../blob/file_template_mediator'; +import FilepathFormMediator from '~/blob/filepath_form_mediator'; import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; export default class EditBlob { @@ -25,7 +25,7 @@ export default class EditBlob { } this.initModePanesAndLinks(); - this.initFileSelectors(); + this.initFilepathForm(); this.initSoftWrap(); this.editor.focus(); } @@ -56,7 +56,6 @@ export default class EditBlob { configureMonacoEditor() { const editorEl = document.getElementById('editor'); - const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name'); const fileContentEl = document.getElementById('file-content'); const form = document.querySelector('.js-edit-blob-form'); @@ -64,7 +63,6 @@ export default class EditBlob { this.editor = rootEditor.createInstance({ el: editorEl, - blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); this.editor.use([ @@ -73,10 +71,6 @@ export default class EditBlob { { definition: FileTemplateExtension }, ]); - fileNameEl.addEventListener('change', () => { - this.editor.updateModelLanguage(fileNameEl.value); - }); - form.addEventListener('submit', () => { fileContentEl.value = insertFinalNewline(this.editor.getValue()); }); @@ -92,13 +86,22 @@ export default class EditBlob { }); } - initFileSelectors() { + initFilepathForm() { const { currentAction, projectId } = this.options; - this.fileTemplateMediator = new TemplateSelectorMediator({ + this.filepathFormMediator = new FilepathFormMediator({ currentAction, editor: this.editor, projectId, }); + this.initFilepathListeners(); + } + + initFilepathListeners() { + const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name'); + this.editor.updateModelLanguage(fileNameEl.value); + fileNameEl.addEventListener('input', () => { + this.editor.updateModelLanguage(fileNameEl.value); + }); } initModePanesAndLinks() { diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index 985b9798b36..f103feecab2 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -7,12 +7,14 @@ import { GlCollapsibleListbox, GlIcon, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { createListMutations, listsQuery, BoardType, ListType } from 'ee_else_ce/boards/constants'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; +import { setError } from '../graphql/cache_updates'; import { getListByTypeId } from '../boards_util'; export default { @@ -70,6 +72,12 @@ export default { skip() { return !this.isApolloBoard; }, + error(error) { + setError({ + error, + message: s__('Boards|An error occurred while fetching labels. Please try again.'), + }); + }, }, }, computed: { @@ -102,36 +110,43 @@ export default { }, methods: { ...mapActions(['createList', 'fetchLabels', 'highlightList']), - createListApollo({ labelId }) { - return this.$apollo.mutate({ - mutation: createListMutations[this.issuableType].mutation, - variables: { - labelId, - boardId: this.boardId, - }, - update: ( - store, - { - data: { - boardListCreate: { list }, + async createListApollo({ labelId }) { + try { + await this.$apollo.mutate({ + mutation: createListMutations[this.issuableType].mutation, + variables: { + labelId, + boardId: this.boardId, + }, + update: ( + store, + { + data: { + boardListCreate: { list }, + }, }, + ) => { + const sourceData = store.readQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + }); + const data = produce(sourceData, (draftData) => { + draftData[this.boardType].board.lists.nodes.push(list); + }); + store.writeQuery({ + query: listsQuery[this.issuableType].query, + variables: this.listQueryVariables, + data, + }); + this.$emit('highlight-list', list.id); }, - ) => { - const sourceData = store.readQuery({ - query: listsQuery[this.issuableType].query, - variables: this.listQueryVariables, - }); - const data = produce(sourceData, (draftData) => { - draftData[this.boardType].board.lists.nodes.push(list); - }); - store.writeQuery({ - query: listsQuery[this.issuableType].query, - variables: this.listQueryVariables, - data, - }); - this.$emit('highlight-list', list.id); - }, - }); + }); + } catch (error) { + setError({ + error, + message: s__('Boards|An error occurred while creating the list. Please try again.'), + }); + } }, addList() { if (!this.selectedLabel) { diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index ca8299ddf80..1cfa35ffd91 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -6,10 +7,11 @@ import BoardContent from '~/boards/components/board_content.vue'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import BoardTopBar from '~/boards/components/board_top_bar.vue'; import eventHub from '~/boards/eventhub'; -import { listsQuery } from 'ee_else_ce/boards/constants'; -import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; +import { listsQuery, FilterFields } from 'ee_else_ce/boards/constants'; +import { formatBoardLists, filterVariables, FiltersInfo } from 'ee_else_ce/boards/boards_util'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import errorQuery from '../graphql/client/error.query.graphql'; +import { setError } from '../graphql/cache_updates'; export default { i18n: { @@ -34,12 +36,12 @@ export default { ], data() { return { + boardListsApollo: {}, activeListId: '', boardId: this.initialBoardId, filterParams: { ...this.initialFilterParams }, addColumnFormVisible: false, isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), - apolloError: null, error: null, }; }, @@ -74,8 +76,11 @@ export default { const { lists } = data[this.boardType].board; return formatBoardLists(lists); }, - error() { - this.apolloError = this.$options.i18n.fetchError; + error(error) { + setError({ + error, + message: this.$options.i18n.fetchError, + }); }, }, error: { @@ -87,7 +92,6 @@ export default { computed: { ...mapGetters(['isSidebarOpen']), listQueryVariables() { - if (this.filterParams.groupBy) delete this.filterParams.groupBy; return { ...(this.isIssueBoard && { isGroup: this.isGroupBoard, @@ -95,7 +99,7 @@ export default { }), fullPath: this.fullPath, boardId: this.boardId, - filters: this.filterParams, + filters: this.formattedFilterParams, }; }, isSwimlanesOn() { @@ -110,6 +114,15 @@ export default { activeList() { return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined; }, + formattedFilterParams() { + if (this.filterParams.groupBy) delete this.filterParams.groupBy; + return filterVariables({ + filters: this.filterParams, + issuableType: this.issuableType, + filterInfo: FiltersInfo, + filterFields: FilterFields, + }); + }, }, created() { window.addEventListener('popstate', refreshCurrentPage); @@ -132,7 +145,6 @@ export default { }, setFilters(filters) { const filterParams = { ...filters }; - if (filterParams.groupBy) delete filterParams.groupBy; this.filterParams = filterParams; }, }, @@ -151,13 +163,12 @@ export default { @toggleSwimlanes="isShowingEpicsSwimlanes = $event" /> <board-content - v-if="!isApolloBoard || boardListsApollo" :board-id="boardId" :add-column-form-visible="addColumnFormVisible" :is-swimlanes-on="isSwimlanesOn" - :filter-params="filterParams" + :filter-params="formattedFilterParams" :board-lists-apollo="boardListsApollo" - :apollo-error="apolloError || error" + :apollo-error="error" :list-query-variables="listQueryVariables" @setActiveList="setActiveId" @setAddColumnFormVisibility="addColumnFormVisible = $event" diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 18495f285da..05865dc7305 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; @@ -113,8 +114,8 @@ export default { this.$apollo.mutate({ mutation: setActiveBoardItemMutation, variables: { - boardItem: this.item, - isIssue: this.isIssueBoard, + boardItem: this.isActive ? null : this.item, + isIssue: this.isActive ? undefined : this.isIssueBoard, }, }); }, diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 6036f0c359c..692ca6bf59b 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -8,6 +8,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { sortBy } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -306,7 +307,7 @@ export default { </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> <span v-if="shouldRenderEpicCountables" data-testid="epic-countables"> - <gl-tooltip :target="() => $refs.countBadge" data-testid="epic-countables-tooltip"> + <gl-tooltip :target="() => $refs.countBadge"> <p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0"> {{ __('Epics') }} • <span class="gl-font-weight-normal"> diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index 19eddbfdd68..8034819732a 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -1,5 +1,6 @@ <script> import { GlDisclosureDropdown } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; import { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 2ee0b4593d6..bcd7db8dcb4 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions, mapState } from 'vuex'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import { isListDraggable } from '../boards_util'; diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 14c781f588f..3c2659b00c9 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -3,8 +3,10 @@ import { GlAlert } from '@gitlab/ui'; import { sortBy } from 'lodash'; import produce from 'immer'; import Draggable from 'vuedraggable'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; +import { s__ } from '~/locale'; import { defaultSortableOptions } from '~/sortable/constants'; import { DraggableItemTypes, @@ -13,6 +15,7 @@ import { updateListQueries, } from 'ee_else_ce/boards/constants'; import { calculateNewPosition } from 'ee_else_ce/boards/boards_util'; +import { setError } from '../graphql/cache_updates'; import BoardColumn from './board_column.vue'; export default { @@ -122,7 +125,14 @@ export default { this.highlightedLists = this.highlightedLists.filter((id) => id !== listId); }, flashAnimationDuration); }, - updateListPosition({ + dismissError() { + if (this.isApolloBoard) { + setError({ message: null, captureError: false }); + } else { + this.unsetError(); + } + }, + async updateListPosition({ item: { dataset: { listId: movedListId, draggableItemType }, }, @@ -153,7 +163,7 @@ export default { const targetPosition = this.boardListsById[displacedListId].position; try { - this.$apollo.mutate({ + await this.$apollo.mutate({ mutation: updateListQueries[this.issuableType].mutation, variables: { listId: movedListId, @@ -195,8 +205,11 @@ export default { }, }, }); - } catch { - // handle error + } catch (error) { + setError({ + error, + message: s__('Boards|An error occurred while moving the list. Please try again.'), + }); } }, }, @@ -209,7 +222,7 @@ export default { data-qa-selector="boards_list" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0" > - <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="unsetError"> + <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="dismissError"> {{ errorToDisplay }} </gl-alert> <component diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 1b97214ff8b..5e1e46dd198 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,12 +1,13 @@ <script> import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; -import { __, sprintf } from '~/locale'; -import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; +import { __, s__, sprintf } from '~/locale'; +import SidebarTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -18,6 +19,7 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { setError } from '../graphql/cache_updates'; export default { components: { @@ -26,7 +28,7 @@ export default { SidebarAssigneesWidget, SidebarDateWidget, SidebarConfidentialityWidget, - BoardSidebarTimeTracker, + SidebarTimeTracker, SidebarLabelsWidget, SidebarSubscriptionsWidget, SidebarDropdownWidget, @@ -74,6 +76,9 @@ export default { isApolloBoard: { default: false, }, + timeTrackingLimitToHours: { + default: false, + }, }, inheritAttrs: false, apollo: { @@ -94,6 +99,12 @@ export default { skip() { return !this.isApolloBoard; }, + error(error) { + setError({ + error, + message: s__('Boards|An error occurred while selecting the card. Please try again.'), + }); + }, }, }, computed: { @@ -250,7 +261,15 @@ export default { data-testid="iteration-edit" /> </div> - <board-sidebar-time-tracker /> + <sidebar-time-tracker + :can-add-time-entries="canUpdate" + :can-set-time-estimate="canUpdate" + :full-path="projectPathForActiveIssue" + :issuable-id="activeBoardIssuable.id" + :issuable-iid="activeBoardIssuable.iid" + :limit-to-hours="timeTrackingLimitToHours" + :show-collapsed="false" + /> <sidebar-date-widget :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index b5d3613ca27..91dd5c81f77 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,5 +1,6 @@ <script> import { pickBy, isEmpty, mapValues } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { getIdFromGraphQLId, isGid, convertToGraphQLId } from '~/graphql_shared/utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -342,7 +343,8 @@ export default { ); }, formattedFilterParams() { - const filtersCopy = { ...this.filterParams }; + const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); + const filtersCopy = convertObjectPropsToCamelCase(rawFilterParams, {}); if (this.filterParams?.iterationId) { filtersCopy.iterationId = convertToGraphQLId( TYPENAME_ITERATION, diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 9ea801dc9a2..4986c3780e5 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index b4249c63b4d..67bfcfb9d97 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,13 +1,15 @@ <script> import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { STATUS_CLOSED } from '~/issues/constants'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, s__ } from '~/locale'; import { defaultSortableOptions } from '~/sortable/constants'; import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { @@ -49,6 +51,7 @@ export default { mixins: [Tracking.mixin(), glFeatureFlagMixin()], inject: [ 'isEpicBoard', + 'isIssueBoard', 'isGroupBoard', 'disabled', 'fullPath', @@ -122,6 +125,12 @@ export default { context: { isSingleRequest: true, }, + error(error) { + setError({ + error, + message: s__('Boards|An error occurred while fetching a list. Please try again.'), + }); + }, }, toList: { query() { @@ -142,8 +151,16 @@ export default { context: { isSingleRequest: true, }, - error() { - // handle error + error(error) { + setError({ + error, + message: sprintf( + s__('Boards|An error occurred while moving the %{issuableType}. Please try again.'), + { + issuableType: this.isEpicBoard ? 'epic' : 'issue', + }, + ), + }); }, }, }, @@ -442,8 +459,16 @@ export default { }, }, }); - } catch { - // handle error + } catch (error) { + setError({ + error, + message: sprintf( + s__('Boards|An error occurred while moving the %{issuableType}. Please try again.'), + { + issuableType: this.isEpicBoard ? 'epic' : 'issue', + }, + ), + }); } }, updateCacheAfterMovingItem({ issuableMoveList, fromListId, toListId, newIndex, cache }) { @@ -494,56 +519,69 @@ export default { }); } }, - moveToPosition(positionInList, oldIndex, item) { - this.$apollo.mutate({ - mutation: listIssuablesQueries[this.issuableType].moveMutation, - variables: { - ...moveItemVariables({ - iid: item.iid, - epicId: item.id, - fromListId: this.currentList.id, - toListId: this.currentList.id, - isIssue: !this.isEpicBoard, - boardId: this.boardId, - itemToMove: item, - }), - positionInList, - withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, - }, - optimisticResponse: { - issuableMoveList: { - issuable: item, - errors: [], + async moveToPosition(positionInList, oldIndex, item) { + try { + await this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].moveMutation, + variables: { + ...moveItemVariables({ + iid: item.iid, + epicId: item.id, + fromListId: this.currentList.id, + toListId: this.currentList.id, + isIssue: !this.isEpicBoard, + boardId: this.boardId, + itemToMove: item, + }), + positionInList, + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, }, - }, - update: (cache, { data: { issuableMoveList } }) => { - const { issuable } = issuableMoveList; - removeItemFromList({ - query: listIssuablesQueries[this.issuableType].query, - variables: { ...this.listQueryVariables, id: this.currentList.id }, - boardType: this.boardType, - id: issuable.id, - issuableType: this.issuableType, - cache, - }); - if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) { - const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1; - addItemToList({ + optimisticResponse: { + issuableMoveList: { + issuable: item, + errors: [], + }, + }, + update: (cache, { data: { issuableMoveList } }) => { + const { issuable } = issuableMoveList; + removeItemFromList({ query: listIssuablesQueries[this.issuableType].query, variables: { ...this.listQueryVariables, id: this.currentList.id }, - issuable, - newIndex, boardType: this.boardType, + id: issuable.id, issuableType: this.issuableType, cache, }); - } - }, - }); + if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) { + const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1; + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + issuable, + newIndex, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + } + }, + }); + } catch (error) { + setError({ + error, + message: sprintf( + s__('Boards|An error occurred while moving the %{issuableType}. Please try again.'), + { + issuableType: this.isEpicBoard ? 'epic' : 'issue', + }, + ), + }); + } }, async addListItem(input) { this.toggleForm(); this.addItemToListInProgress = true; + let issuable; try { await this.$apollo.mutate({ mutation: listIssuablesQueries[this.issuableType].createMutation, @@ -552,7 +590,7 @@ export default { withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, }, update: (cache, { data: { createIssuable } }) => { - const { issuable } = createIssuable; + issuable = createIssuable.issuable; addItemToList({ query: listIssuablesQueries[this.issuableType].query, variables: { ...this.listQueryVariables, id: this.currentList.id }, @@ -583,7 +621,7 @@ export default { } catch (error) { setError({ message: sprintf( - __('An error occurred while creating the %{issuableType}. Please try again.'), + s__('Boards|An error occurred while creating the %{issuableType}. Please try again.'), { issuableType: this.isEpicBoard ? 'epic' : 'issue', }, @@ -592,6 +630,13 @@ export default { }); } finally { this.addItemToListInProgress = false; + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: issuable, + isIssue: this.isIssueBoard, + }, + }); } }, }, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 8db86d0e894..068db98a750 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -8,9 +8,11 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; +import { fetchPolicies } from '~/lib/graphql'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; @@ -18,7 +20,6 @@ import Tracking from '~/tracking'; import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import AccessorUtilities from '~/lib/utils/accessor'; import { @@ -28,8 +29,10 @@ import { toggleFormEventPrefix, updateListQueries, toggleCollapsedMutations, + listsDeferredQuery, } from 'ee_else_ce/boards/constants'; import eventHub from '../eventhub'; +import { setError } from '../graphql/cache_updates'; import ItemCount from './item_count.vue'; export default { @@ -39,6 +42,9 @@ export default { listSettings: s__('Boards|Edit list settings'), expand: s__('Boards|Expand'), collapse: s__('Boards|Collapse'), + fetchError: s__( + "Boards|An error occurred while fetching list's information. Please try again.", + ), }, components: { GlButton, @@ -184,8 +190,16 @@ export default { userCanDrag() { return !this.disabled && isListDraggable(this.list); }, + // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache. + // if we have cached data, we disregard the loading state isLoading() { - return this.$apollo.queries.boardList.loading; + return ( + this.$apollo.queries.boardList.loading && + !this.$apollo.provider.clients.defaultClient.readQuery({ + query: listsDeferredQuery[this.issuableType].query, + variables: this.countQueryVariables, + }) + ); }, totalWeight() { return this.boardList?.totalWeight; @@ -193,19 +207,31 @@ export default { canShowTotalWeight() { return this.weightFeatureAvailable && !this.isLoading; }, + countQueryVariables() { + return { + id: this.list.id, + filters: this.filterParams, + }; + }, }, apollo: { boardList: { - query: listQuery, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query() { + return listsDeferredQuery[this.issuableType].query; + }, variables() { - return { - id: this.list.id, - filters: this.filterParams, - }; + return this.countQueryVariables; }, context: { isSingleRequest: true, }, + error(error) { + setError({ + error, + message: this.$options.i18n.fetchError, + }); + }, }, }, created() { @@ -293,8 +319,11 @@ export default { }, }, }); - } catch { - this.$emit('error'); + } catch (error) { + setError({ + error, + message: s__('Boards|An error occurred while updating the list. Please try again.'), + }); } } else { this.updateList({ listId: this.list.id, collapsed }); diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index b68444fb011..d78b60e91a8 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { getMilestone, formatIssueInput, getBoardQuery } from 'ee_else_ce/boards/boards_util'; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 0f43aae3936..58db2c9ac2a 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -2,6 +2,7 @@ import produce from 'immer'; import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { LIST, @@ -11,10 +12,11 @@ import { deleteListQueries, } from 'ee_else_ce/boards/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { setError } from '../graphql/cache_updates'; export default { listSettingsText: __('List settings'), @@ -131,23 +133,30 @@ export default { } }, async deleteList(listId) { - await this.$apollo.mutate({ - mutation: deleteListQueries[this.issuableType].mutation, - variables: { - listId, - }, - update: (store) => { - store.updateQuery( - { query: listsQuery[this.issuableType].query, variables: this.queryVariables }, - (sourceData) => - produce(sourceData, (draftData) => { - draftData[this.boardType].board.lists.nodes = draftData[ - this.boardType - ].board.lists.nodes.filter((list) => list.id !== listId); - }), - ); - }, - }); + try { + await this.$apollo.mutate({ + mutation: deleteListQueries[this.issuableType].mutation, + variables: { + listId, + }, + update: (store) => { + store.updateQuery( + { query: listsQuery[this.issuableType].query, variables: this.queryVariables }, + (sourceData) => + produce(sourceData, (draftData) => { + draftData[this.boardType].board.lists.nodes = draftData[ + this.boardType + ].board.lists.nodes.filter((list) => list.id !== listId); + }), + ); + }, + }); + } catch (error) { + setError({ + error, + message: s__('Boards|An error occurred while deleting the list. Please try again.'), + }); + } }, }, }; diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index fd9043a561f..2b8418333a8 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -1,8 +1,10 @@ <script> import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import { s__ } from '~/locale'; import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; import { getBoardQuery } from 'ee_else_ce/boards/boards_util'; +import { setError } from '../graphql/cache_updates'; import ConfigToggle from './config_toggle.vue'; import NewBoardButton from './new_board_button.vue'; import ToggleFocus from './toggle_focus.vue'; @@ -70,6 +72,12 @@ export default { labels: board.labels?.nodes, }; }, + error(error) { + setError({ + error, + message: s__('Boards|An error occurred while fetching board details. Please try again.'), + }); + }, }, }, computed: { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index fddb58c45fe..b3fe52944dc 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -10,6 +10,7 @@ import { } from '@gitlab/ui'; import { produce } from 'immer'; import { throttle } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; @@ -24,12 +25,16 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql'; import projectBoardsQuery from '../graphql/project_boards.query.graphql'; import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql'; import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql'; +import { setError } from '../graphql/cache_updates'; import { fullBoardId } from '../boards_util'; const MIN_BOARDS_TO_VIEW_RECENT = 10; export default { name: 'BoardsSelector', + i18n: { + fetchBoardsError: s__('Boards|An error occurred while fetching boards. Please try again.'), + }, components: { BoardForm, GlLoadingIcon, @@ -90,9 +95,12 @@ export default { parentType() { return this.boardType; }, - boardQuery() { + issueBoardsQuery() { return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery; }, + boardsQuery() { + return this.issueBoardsQuery; + }, loading() { return this.loadingRecentBoards || this.loadingBoards; }, @@ -143,7 +151,7 @@ export default { eventHub.$off('showBoardModal', this.showPage); }, methods: { - ...mapActions(['setError', 'fetchBoard', 'unsetActiveId']), + ...mapActions(['fetchBoard', 'unsetActiveId']), fullBoardId(boardId) { return fullBoardId(boardId); }, @@ -157,7 +165,7 @@ export default { if (!data?.[this.parentType]) { return []; } - return data[this.parentType][boardType].edges.map(({ node }) => ({ + return data[this.parentType][boardType].nodes.map((node) => ({ id: getIdFromGraphQLId(node.id), name: node.name, })); @@ -174,11 +182,17 @@ export default { variables() { return { fullPath: this.fullPath }; }, - query: this.boardQuery, + query: this.boardsQuery, update: (data) => this.boardUpdate(data, 'boards'), watchLoading: (isLoading) => { this.loadingBoards = isLoading; }, + error(error) { + setError({ + error, + message: this.$options.i18n.fetchBoardsError, + }); + }, }); this.loadRecentBoards(); @@ -193,25 +207,33 @@ export default { watchLoading: (isLoading) => { this.loadingRecentBoards = isLoading; }, + error(error) { + setError({ + error, + message: s__( + 'Boards|An error occurred while fetching recent boards. Please try again.', + ), + }); + }, }); }, addBoard(board) { const { defaultClient: store } = this.$apollo.provider.clients; const sourceData = store.readQuery({ - query: this.boardQuery, + query: this.boardsQuery, variables: { fullPath: this.fullPath }, }); const newData = produce(sourceData, (draftState) => { - draftState[this.parentType].boards.edges = [ - ...draftState[this.parentType].boards.edges, - { node: board }, + draftState[this.parentType].boards.nodes = [ + ...draftState[this.parentType].boards.nodes, + { ...board }, ]; }); store.writeQuery({ - query: this.boardQuery, + query: this.boardsQuery, variables: { fullPath: this.fullPath }, data: newData, }); @@ -267,9 +289,6 @@ export default { } }, }, - i18n: { - errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'), - }, }; </script> diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index dd3b9472879..bc896932ffc 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { formType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; @@ -29,7 +30,7 @@ export default { return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); }, tooltipTitle() { - return this.hasScope ? __("This board's scope is reduced") : ''; + return this.hasScope || this.boardHasScope ? __("This board's scope is reduced") : ''; }, }, methods: { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue deleted file mode 100644 index b70294c9db3..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue +++ /dev/null @@ -1,39 +0,0 @@ -<script> -import { mapGetters } from 'vuex'; -import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; - -export default { - components: { - IssuableTimeTracker, - }, - inject: ['timeTrackingLimitToHours', 'canUpdate'], - computed: { - ...mapGetters(['activeBoardItem']), - initialTimeTracking() { - const { - timeEstimate, - totalTimeSpent, - humanTimeEstimate, - humanTotalTimeSpent, - } = this.activeBoardItem; - return { - timeEstimate, - totalTimeSpent, - humanTimeEstimate, - humanTotalTimeSpent, - }; - }, - }, -}; -</script> - -<template> - <issuable-time-tracker - :issuable-id="activeBoardItem.id.toString()" - :issuable-iid="activeBoardItem.iid.toString()" - :limit-to-hours="timeTrackingLimitToHours" - :initial-time-tracking="initialTimeTracking" - :show-collapsed="false" - :can-add-time-entries="canUpdate" - /> -</template> 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 020edcb01b8..1c2c0022ddf 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql index ce9f7bbfd2a..0fb5748abfc 100644 --- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql @@ -2,11 +2,9 @@ query group_boards($fullPath: ID!) { group(fullPath: $fullPath) { id boards { - edges { - node { - id - name - } + nodes { + id + name } } } diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql index b9fe778d4d4..9dbf4528cec 100644 --- a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql @@ -2,11 +2,9 @@ query group_recent_boards($fullPath: ID!) { group(fullPath: $fullPath) { id recentIssueBoards { - edges { - node { - id - name - } + nodes { + id + name } } } diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql index 770c246a95b..97a298db246 100644 --- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql @@ -2,11 +2,9 @@ query project_boards($fullPath: ID!) { project(fullPath: $fullPath) { id boards { - edges { - node { - id - name - } + nodes { + id + name } } } diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql index c633107a409..0d3a8616603 100644 --- a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql @@ -2,11 +2,9 @@ query project_recent_boards($fullPath: ID!) { project(fullPath: $fullPath) { id recentIssueBoards { - edges { - node { - id - name - } + nodes { + id + name } } } diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 67388284d31..a03ec9193ea 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -29,7 +29,7 @@ function mountBoardApp(el) { const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); const initialFilterParams = { - ...convertObjectPropsToCamelCase(rawFilterParams), + ...convertObjectPropsToCamelCase(rawFilterParams, {}), }; const boardType = el.dataset.parent; diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js index 0a87c6ab821..ee0a5e27d9a 100644 --- a/app/assets/javascripts/boards/stores/index.js +++ b/app/assets/javascripts/boards/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actions from 'ee_else_ce/boards/stores/actions'; import getters from 'ee_else_ce/boards/stores/getters'; diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 2045b127a82..842d88e1267 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue @@ -23,15 +23,6 @@ export default { graphqlId() { return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, - queriesAvailable() { - if (this.glFeatures.ciGroupEnvScopeGraphql) { - return this.$options.queryData; - } - - return { - ciVariables: this.$options.queryData.ciVariables, - }; - }, }, mutationData: { [ADD_MUTATION_ACTION]: addGroupVariable, @@ -59,6 +50,6 @@ export default { entity="group" :full-path="groupPath" :mutation-data="$options.mutationData" - :query-data="queriesAvailable" + :query-data="$options.queryData" /> </template> 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 new file mode 100644 index 00000000000..0ce11da658c --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -0,0 +1,233 @@ +<script> +import { + GlButton, + GlDrawer, + GlFormCheckbox, + GlFormCombobox, + GlFormGroup, + GlFormSelect, + GlFormTextarea, + GlIcon, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +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 { + defaultVariableState, + ENVIRONMENT_SCOPE_LINK_TITLE, + EXPANDED_VARIABLES_NOTE, + FLAG_LINK_TITLE, + VARIABLE_ACTIONS, + variableOptions, +} from '../constants'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; +import { awsTokenList } from './ci_variable_autocomplete_tokens'; + +const i18n = { + addVariable: s__('CiVariables|Add Variable'), + cancel: __('Cancel'), + environments: __('Environments'), + environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, + expandedField: s__('CiVariables|Expand variable reference'), + expandedDescription: EXPANDED_VARIABLES_NOTE, + flags: __('Flags'), + flagsLinkTitle: FLAG_LINK_TITLE, + key: __('Key'), + maskedField: s__('CiVariables|Mask variable'), + maskedDescription: s__( + 'CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements.', + ), + protectedField: s__('CiVariables|Protect variable'), + protectedDescription: s__( + 'CiVariables|Export variable to pipelines running on protected branches and tags only.', + ), + type: __('Type'), + value: __('Value'), +}; + +export default { + DRAWER_Z_INDEX, + components: { + CiEnvironmentsDropdown, + GlButton, + GlDrawer, + GlFormCheckbox, + GlFormCombobox, + GlFormGroup, + GlFormSelect, + GlFormTextarea, + GlIcon, + GlLink, + GlSprintf, + }, + inject: ['environmentScopeLink'], + props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, + environments: { + type: Array, + required: false, + default: () => [], + }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, + mode: { + type: String, + required: true, + validator(val) { + return VARIABLE_ACTIONS.includes(val); + }, + }, + }, + data() { + return { + key: defaultVariableState.key, + variableType: defaultVariableState.variableType, + }; + }, + computed: { + getDrawerHeaderHeight() { + return getContentWrapperHeight(); + }, + }, + methods: { + close() { + this.$emit('close-form'); + }, + }, + awsTokenList, + flagLink: helpPagePath('ci/variables/index', { + anchor: 'define-a-cicd-variable-in-the-ui', + }), + i18n, + variableOptions, +}; +</script> +<template> + <gl-drawer + open + data-testid="ci-variable-drawer" + :header-height="getDrawerHeaderHeight" + :z-index="$options.DRAWER_Z_INDEX" + @close="close" + > + <template #title> + <h2 class="gl-m-0">{{ $options.i18n.addVariable }}</h2> + </template> + <gl-form-group + :label="$options.i18n.type" + label-for="ci-variable-type" + class="gl-border-none gl-mb-n5" + > + <gl-form-select + id="ci-variable-type" + v-model="variableType" + :options="$options.variableOptions" + /> + </gl-form-group> + <gl-form-group + class="gl-border-none gl-mb-n5" + label-for="ci-variable-env" + data-testid="environment-scope" + > + <template #label> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2"> + {{ $options.i18n.environments }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.environmentScopeLinkTitle" + :href="environmentScopeLink" + target="_blank" + data-testid="environment-scope-link" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> + </template> + <ci-environments-dropdown + class="gl-mb-5" + :are-environments-loading="areEnvironmentsLoading" + :environments="environments" + :has-env-scope-query="hasEnvScopeQuery" + selected-environment-scope="" + /> + </gl-form-group> + <gl-form-group class="gl-border-none gl-mb-n8"> + <template #label> + <div class="gl-display-flex gl-align-items-center gl-mb-n3"> + <span class="gl-mr-2"> + {{ $options.i18n.flags }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.flagsLinkTitle" + :href="$options.flagLink" + target="_blank" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> + </template> + <gl-form-checkbox 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"> + {{ $options.i18n.maskedField }} + <p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p> + </gl-form-checkbox> + <gl-form-checkbox data-testid="ci-variable-expanded-checkbox"> + {{ $options.i18n.expandedField }} + <p class="gl-text-secondary"> + <gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-form-checkbox> + </gl-form-group> + <gl-form-combobox + v-model="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-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" + > + <gl-form-textarea + id="ci-variable-value" + class="gl-border-none gl-font-monospace!" + rows="3" + max-rows="10" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + spellcheck="false" + /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button category="primary" 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> + </div> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 3af48635f3f..86c0f34215e 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -25,6 +25,7 @@ import { AWS_TOKEN_CONSTANTS, ADD_CI_VARIABLE_MODAL_ID, AWS_TIP_DISMISSED_COOKIE_NAME, + AWS_TIP_TITLE, AWS_TIP_MESSAGE, CONTAINS_VARIABLE_REFERENCE_MESSAGE, defaultVariableState, @@ -62,10 +63,6 @@ export default { }, mixins: [glFeatureFlagsMixin(), trackingMixin], inject: [ - 'awsLogoSvgPath', - 'awsTipCommandsLink', - 'awsTipDeployLink', - 'awsTipLearnLink', 'containsVariableReferenceLink', 'environmentScopeLink', 'isProtectedByDefault', @@ -241,7 +238,7 @@ export default { this.resetVariableData(); this.resetValidationErrorEvents(); - this.$emit('hideModal'); + this.$emit('close-form'); }, resetVariableData() { this.variable = { ...defaultVariableState }; @@ -295,6 +292,7 @@ export default { }, }, i18n: { + awsTipTitle: AWS_TIP_TITLE, awsTipMessage: AWS_TIP_MESSAGE, containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, defaultScope: allEnvironments.text, @@ -305,6 +303,9 @@ export default { flagLink: helpPagePath('ci/variables/index', { anchor: 'define-a-cicd-variable-in-the-ui', }), + oidcLink: helpPagePath('ci/cloud_services/index', { + anchor: 'oidc-authorization-with-your-cloud-provider', + }), modalId: ADD_CI_VARIABLE_MODAL_ID, tokens: awsTokens, tokenList: awsTokenList, @@ -322,6 +323,23 @@ export default { @hidden="resetModalHandler" @shown="onShow" > + <gl-collapse :visible="isTipVisible"> + <gl-alert + :title="$options.i18n.awsTipTitle" + variant="warning" + class="gl-mb-5" + data-testid="aws-guidance-tip" + @dismiss="dismissTip" + > + <gl-sprintf :message="$options.i18n.awsTipMessage"> + <template #link="{ content }"> + <gl-link :href="$options.oidcLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </gl-collapse> <form> <gl-form-combobox v-model="variable.key" @@ -468,45 +486,7 @@ export default { </gl-form-checkbox> </gl-form-group> </form> - <gl-collapse :visible="isTipVisible"> - <gl-alert - :title="__('Deploying to AWS is easy with GitLab')" - variant="tip" - data-testid="aws-guidance-tip" - @dismiss="dismissTip" - > - <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3"> - <div> - <p> - <gl-sprintf :message="$options.i18n.awsTipMessage"> - <template #deployLink="{ content }"> - <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> - </template> - <template #commandsLink="{ content }"> - <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <p> - <gl-button - :href="awsTipLearnLink" - target="_blank" - category="secondary" - variant="confirm" - class="gl-overflow-wrap-break" - >{{ __('Learn more about deploying to AWS') }}</gl-button - > - </p> - </div> - <img - class="gl-mt-3" - :alt="__('Amazon Web Services Logo')" - :src="awsLogoSvgPath" - height="32" - /> - </div> - </gl-alert> - </gl-collapse> + <gl-alert v-if="containsVariableReference" :title="__('Value might contain a variable reference')" 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 b8a95f9081a..f4e1da9b34f 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 @@ -1,13 +1,17 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants'; +import CiVariableDrawer from './ci_variable_drawer.vue'; import CiVariableTable from './ci_variable_table.vue'; import CiVariableModal from './ci_variable_modal.vue'; export default { components: { + CiVariableDrawer, CiVariableTable, CiVariableModal, }, + mixins: [glFeatureFlagsMixin()], props: { areEnvironmentsLoading: { type: Boolean, @@ -62,23 +66,32 @@ export default { }; }, computed: { - showModal() { + showForm() { return VARIABLE_ACTIONS.includes(this.mode); }, + useDrawerForm() { + return this.glFeatures?.ciVariableDrawer; + }, + showDrawer() { + return this.showForm && this.useDrawerForm; + }, + showModal() { + return this.showForm && !this.useDrawerForm; + }, }, methods: { addVariable(variable) { this.$emit('add-variable', variable); }, + closeForm() { + this.mode = null; + }, deleteVariable(variable) { this.$emit('delete-variable', variable); }, updateVariable(variable) { this.$emit('update-variable', variable); }, - hideModal() { - this.mode = null; - }, setSelectedVariable(variable = null) { if (!variable) { this.selectedVariable = {}; @@ -104,6 +117,7 @@ export default { @handle-prev-page="$emit('handle-prev-page')" @handle-next-page="$emit('handle-next-page')" @set-selected-variable="setSelectedVariable" + @delete-variable="deleteVariable" @sort-changed="(val) => $emit('sort-changed', val)" /> <ci-variable-modal @@ -118,10 +132,18 @@ export default { :selected-variable="selectedVariable" @add-variable="addVariable" @delete-variable="deleteVariable" - @hideModal="hideModal" + @close-form="closeForm" @update-variable="updateVariable" @search-environment-scope="$emit('search-environment-scope', $event)" /> + <ci-variable-drawer + v-if="showDrawer" + :are-environments-loading="areEnvironmentsLoading" + :has-env-scope-query="hasEnvScopeQuery" + :mode="mode" + v-on="$listeners" + @close-form="closeForm" + /> </div> </div> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue index ec7a921664f..a14cd1e387a 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 @@ -3,11 +3,14 @@ import { GlAlert, GlBadge, GlButton, + GlCard, + GlIcon, GlLoadingIcon, GlModalDirective, GlKeysetPagination, GlLink, GlTable, + GlModal, GlTooltipDirective, } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; @@ -45,9 +48,8 @@ export default { }, { key: 'actions', - label: '', - tdClass: 'text-right', - thClass: 'gl-w-5p', + label: __('Actions'), + thClass: 'gl-text-right', }, ], inheritedVarsFields: [ @@ -73,10 +75,13 @@ export default { GlAlert, GlBadge, GlButton, + GlCard, GlKeysetPagination, GlLink, + GlIcon, GlLoadingIcon, GlTable, + GlModal, }, directives: { GlModalDirective, @@ -84,6 +89,14 @@ export default { }, mixins: [glFeatureFlagsMixin()], inject: ['isInheritedGroupVars'], + i18n: { + title: s__('CiVariables|CI/CD Variables'), + addButton: s__('CiVariables|Add variable'), + editButton: __('Edit'), + deleteButton: __('Delete'), + modalDeleteTitle: s__('CiVariables|Delete variable'), + modalDeleteMessage: s__('CiVariables|Do you want to delete the variable %{key}?'), + }, props: { entity: { type: String, @@ -107,6 +120,20 @@ export default { required: true, }, }, + deleteModal: { + actionPrimary: { + text: __('Delete'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, data() { return { areValuesHidden: true, @@ -165,6 +192,9 @@ export default { setSelectedVariable(index = -1) { this.$emit('set-selected-variable', this.variables[index] ?? null); }, + deleteSelectedVariable(index = -1) { + this.$emit('delete-variable', this.variables[index] ?? null); + }, getAttributes(item) { const attributes = []; if (item.variableType === variableTypes.fileType) { @@ -181,188 +211,219 @@ export default { } return attributes; }, + removeVariableMessage(key) { + return sprintf(this.$options.i18n.modalDeleteMessage, { + key, + }); + }, }, maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED, }; </script> <template> - <div class="ci-variable-table" :data-testid="tableDataTestId"> - <gl-loading-icon v-if="isLoading" /> - <gl-alert - v-if="showAlert" - :dismissible="false" - :title="$options.maximumVariableLimitReached" - variant="info" - > - {{ exceedsVariableLimitText }} - </gl-alert> - <div - v-if="showPagination && !isInheritedGroupVars" - class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3" + <div> + <gl-card + class="gl-new-card ci-variable-table" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + :data-testid="tableDataTestId" > - <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mx-3" - data-qa-selector="add_ci_variable_button" - variant="confirm" - category="primary" - :aria-label="__('Add')" - :disabled="exceedsVariableLimit" - @click="setSelectedVariable()" - >{{ __('Add variable') }}</gl-button - > - </div> - <gl-table - v-if="!isLoading" - :fields="fields" - :items="variablesWithAttributes" - tbody-tr-class="js-ci-variable-row" - sort-by="key" - sort-direction="asc" - stacked="lg" - fixed - show-empty - sort-icon-left - no-sort-reset - no-local-sorting - @sort-changed="(val) => $emit('sort-changed', val)" - > - <template #table-colgroup="scope"> - <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> - </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" - > - <span - :id="`ci-variable-key-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-word-break-word" - >{{ item.key }}</span - > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h5 class="gl-new-card-title">{{ $options.i18n.title }}</h5> + <span class="gl-new-card-count"> + <gl-icon name="code" class="gl-mr-2" /> + {{ variables.length }} + </span> + </div> + <div v-if="!isInheritedGroupVars" class="gl-new-card-actions gl-font-size-0"> <gl-button - v-gl-tooltip + v-if="!isTableEmpty" category="tertiary" - icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" - :title="__('Copy key')" - :data-clipboard-text="item.key" - :aria-label="__('Copy to clipboard')" - /> - </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" - > - <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span> - <span - v-else - :id="`ci-variable-value-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - data-testid="revealedValue" - >{{ item.value }}</span + size="small" + class="gl-mr-3" + @click="toggleHiddenState" + >{{ valuesButtonText }}</gl-button > <gl-button - v-gl-tooltip - category="tertiary" - icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" - :title="__('Copy value')" - :data-clipboard-text="item.value" - :aria-label="__('Copy to clipboard')" - /> + v-gl-modal-directive="$options.modalId" + size="small" + :disabled="exceedsVariableLimit" + data-qa-selector="add_ci_variable_button" + data-testid="add-ci-variable-button" + @click="setSelectedVariable()" + >{{ $options.i18n.addButton }}</gl-button + > </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" + + <gl-loading-icon v-if="isLoading" class="gl-p-4" /> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> + <gl-table + v-if="!isLoading" + :fields="fields" + :items="variablesWithAttributes" + tbody-tr-class="js-ci-variable-row" + sort-by="key" + sort-direction="asc" + stacked="md" + fixed + show-empty + sort-icon-left + no-sort-reset + no-local-sorting + @sort-changed="(val) => $emit('sort-changed', val)" + > + <template #table-colgroup="scope"> + <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> + </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" > - {{ 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" - > - <span - :id="`ci-variable-env-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-word-break-word" - >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span + <span + :id="`ci-variable-key-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-word-break-word" + >{{ item.key }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + class="gl-my-n3 gl-ml-2" + :title="__('Copy key')" + :data-clipboard-text="item.key" + :aria-label="__('Copy to clipboard')" + /> + </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" > - <gl-button - v-gl-tooltip - category="tertiary" - icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" - :title="__('Copy environment')" - :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)" - :aria-label="__('Copy to clipboard')" - /> - </div> - </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" - > - <gl-link - :id="`ci-variable-group-${item.id}`" - data-testid="ci-variable-table-row-cicd-path" - class="gl-display-inline-block gl-max-w-full gl-word-break-word" - :href="item.groupCiCdSettingsPath" + <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span> + <span + v-else + :id="`ci-variable-value-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + data-testid="revealedValue" + >{{ item.value }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + class="gl-my-n3 gl-ml-2" + :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" > - {{ item.groupName }} - </gl-link> - </div> - </template> - <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }"> - <gl-button - v-gl-modal-directive="$options.modalId" - icon="pencil" - :aria-label="__('Edit')" - data-qa-selector="edit_ci_variable_button" - @click="setSelectedVariable(item.index)" - /> - </template> - <template #empty> - <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0"> - {{ __('There are no variables yet.') }} - </p> - </template> - </gl-table> - <gl-alert - v-if="showAlert" - :dismissible="false" - :title="$options.maximumVariableLimitReached" - variant="info" - > - {{ exceedsVariableLimitText }} - </gl-alert> + <span + :id="`ci-variable-env-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-word-break-word" + >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + class="gl-my-n3 gl-ml-2" + :title="__('Copy environment')" + :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)" + :aria-label="__('Copy to clipboard')" + /> + </div> + </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" + > + <gl-link + :id="`ci-variable-group-${item.id}`" + data-testid="ci-variable-table-row-cicd-path" + class="gl-display-inline-block gl-max-w-full gl-word-break-word" + :href="item.groupCiCdSettingsPath" + > + {{ item.groupName }} + </gl-link> + </div> + </template> + <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }"> + <div class="gl-display-flex gl-justify-content-end gl-mt-n2 gl-mb-n2"> + <gl-button + v-gl-modal-directive="$options.modalId" + icon="pencil" + size="small" + class="gl-mr-3" + :aria-label="$options.i18n.editButton" + data-qa-selector="edit_ci_variable_button" + @click="setSelectedVariable(item.index)" + /> + <gl-button + v-gl-modal-directive="`delete-variable-${item.index}`" + variant="danger" + category="secondary" + icon="remove" + size="small" + :aria-label="$options.i18n.deleteButton" + data-qa-selector="delete_ci_variable_button" + /> + <gl-modal + ref="modal" + :modal-id="`delete-variable-${item.index}`" + :title="$options.i18n.modalDeleteTitle" + :action-primary="$options.deleteModal.actionPrimary" + :action-secondary="$options.deleteModal.actionSecondary" + @primary="deleteSelectedVariable(item.index)" + > + {{ removeVariableMessage(item.key) }} + </gl-modal> + </div> + </template> + <template #empty> + <p class="gl-text-secondary gl-text-center gl-py-1 gl-mb-0"> + {{ __('There are no variables yet.') }} + </p> + </template> + </gl-table> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> + </gl-card> <div v-if="!isInheritedGroupVars"> - <div v-if="!showPagination" class="ci-variable-actions gl-display-flex gl-mt-5"> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mr-3" - data-qa-selector="add_ci_variable_button" - variant="confirm" - category="primary" - :aria-label="__('Add')" - :disabled="exceedsVariableLimit" - @click="setSelectedVariable()" - >{{ __('Add variable') }}</gl-button - > - <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ - valuesButtonText - }}</gl-button> - </div> - <div v-else class="gl-display-flex gl-justify-content-center gl-mt-6"> + <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-keyset-pagination v-bind="pageInfo" :prev-text="__('Previous')" diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index d702dd073ec..825b39e0cf9 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -7,14 +7,6 @@ export const SORT_DIRECTIONS = { ASC: 'KEY_ASC', DESC: 'KEY_DESC', }; - -// This const will be deprecated once we remove VueX from the section -export const displayText = { - variableText: __('Variable'), - fileText: __('File'), - allEnvironmentsText: __('All (default)'), -}; - export const variableTypes = { envType: 'ENV_VAR', fileType: 'FILE', @@ -26,8 +18,8 @@ export const allEnvironments = { }; export const variableOptions = [ - { value: variableTypes.envType, text: variableTypes.envType }, - { value: variableTypes.fileType, text: variableTypes.fileType }, + { value: variableTypes.envType, text: __('Variable (default)') }, + { value: variableTypes.fileType, text: __('File') }, ]; export const defaultVariableState = { @@ -48,8 +40,9 @@ export const instanceString = 'Instance'; export const projectString = 'Project'; export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed'; -export const AWS_TIP_MESSAGE = __( - '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', +export const AWS_TIP_TITLE = s__('CiVariable|Use OIDC to securely connect to cloud services'); +export const AWS_TIP_MESSAGE = s__( + 'CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}', ); export const EVENT_LABEL = 'ci_variable_modal'; diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js index e47b41ceae5..9342f57f2d8 100644 --- a/app/assets/javascripts/ci/ci_variable_list/index.js +++ b/app/assets/javascripts/ci/ci_variable_list/index.js @@ -10,10 +10,6 @@ import { generateCacheConfig, resolvers } from './graphql/settings'; const mountCiVariableListApp = (containerEl) => { const { - awsLogoSvgPath, - awsTipCommandsLink, - awsTipDeployLink, - awsTipLearnLink, containsVariableReferenceLink, endpoint, environmentScopeLink, @@ -57,10 +53,6 @@ const mountCiVariableListApp = (containerEl) => { el: containerEl, apolloProvider, provide: { - awsLogoSvgPath, - awsTipCommandsLink, - awsTipDeployLink, - awsTipLearnLink, containsVariableReferenceLink, endpoint, environmentScopeLink, 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 6ba8884f9a6..bc0cad75c60 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 @@ -107,7 +107,6 @@ export default { v-if="glFeatures.ciJobAssistantDrawer" icon="bulb" size="small" - data-testid="job-assistant-drawer-toggle" data-qa-selector="job_assistant_drawer_toggle" @click="toggleJobAssistantDrawer" > 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 656b1a6c347..f1c9770714a 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,7 +1,7 @@ <script> import { __ } from '~/locale'; import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +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 { PIPELINE_FAILURE } from '../../constants'; @@ -10,7 +10,7 @@ export default { linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'), }, components: { - PipelineMiniGraph, + LegacyPipelineMiniGraph, }, inject: ['projectFullPath'], props: { @@ -84,7 +84,7 @@ export default { </script> <template> - <pipeline-mini-graph + <legacy-pipeline-mini-graph v-if="hasPipelineStages" :downstream-pipelines="downstreamPipelines" :pipeline-path="pipelinePath" 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 bb79a4d74da..3bce50224d9 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 @@ -11,7 +11,7 @@ import { } from '~/pipelines/components/graph/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; @@ -34,8 +34,8 @@ export default { GlLink, GlLoadingIcon, GlSprintf, - GraphqlPipelineMiniGraph, PipelineEditorMiniGraph, + PipelineMiniGraph, }, directives: { GlTooltip: GlTooltipDirective, @@ -179,7 +179,7 @@ export default { </span> </div> <div class="gl-display-flex gl-flex-wrap-wrap"> - <graphql-pipeline-mini-graph + <pipeline-mini-graph v-if="isUsingPipelineMiniGraphQueries" :full-path="projectFullPath" :iid="pipeline.iid" 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 d7b8e7151d9..25e4e99bf54 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,6 +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" @click="createEmptyConfigFile" > 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 ba33888e2fb..7583fa7a3b5 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 @@ -201,7 +201,6 @@ export default { :title="$options.i18n.pipelineSourceTooltip" :toggle-text="$options.i18n.pipelineSourceDefault" disabled - data-testid="pipeline-source" /> <validate-pipeline-popover /> <gl-icon diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 0495546529a..41e5199e204 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -3,9 +3,9 @@ import { GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import JobAssistantDrawer from 'jh_else_ce/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; -import JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_drawer.vue'; import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorFileTree from './components/file_tree/container.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue index 6fd5c8130ad..cc7d9bd2340 100644 --- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue @@ -476,7 +476,6 @@ export default { <gl-dropdown-item v-for="option in configVariablesWithDescription.options[variable.key]" :key="option" - data-testid="pipeline-form-ci-variable-value-dropdown-items" data-qa-selector="ci_variable_value_dropdown_item" @click="setVariableAttribute(variable.key, 'value', option)" > diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index 0700d9e5439..c993b65f6c0 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -94,6 +94,7 @@ export default { return { schedules: { list: [], + currentUser: {}, }, scope, hasError: false, @@ -135,6 +136,14 @@ export default { }, ]; }, + onAllTab() { + // scope is undefined on first load, scope is only defined + // after tab switching + return this.scope === ALL_SCOPE || !this.scope; + }, + showEmptyState() { + return !this.isLoading && this.schedulesCount === 0 && this.onAllTab; + }, }, watch: { // this watcher ensures that the count on the all tab @@ -258,8 +267,10 @@ export default { </gl-sprintf> </gl-alert> + <pipeline-schedule-empty-state v-if="showEmptyState" /> + <gl-tabs - v-if="isLoading || schedulesCount > 0" + v-else sync-active-tab-with-query-params query-param-name="scope" nav-class="gl-flex-grow-1 gl-align-items-center gl-mt-2" @@ -284,6 +295,7 @@ export default { </template> <gl-loading-icon v-if="isLoading" size="lg" /> + <pipeline-schedules-table v-else :schedules="schedules.list" @@ -306,8 +318,6 @@ export default { </template> </gl-tabs> - <pipeline-schedule-empty-state v-else-if="!isLoading && schedulesCount === 0" /> - <take-ownership-modal :visible="showTakeOwnershipModal" @takeOwnership="takeOwnership" 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 d84a9a4a4b5..396ff9808f2 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 @@ -112,6 +112,7 @@ export default { cronTimezone: '', variables: [], schedule: {}, + showVarValues: false, }; }, i18n: { @@ -140,6 +141,8 @@ export default { scheduleFetchError: s__( 'PipelineSchedules|An error occurred while trying to fetch the pipeline schedule.', ), + revealText: __('Reveal values'), + hideText: __('Hide values'), }, typeOptions: { [VARIABLE_TYPE]: __('Variable'), @@ -167,11 +170,24 @@ export default { getEnabledRefTypes() { return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; }, + filledVariables() { + return this.variables.filter((variable) => variable.key !== '' && !variable.empty); + }, preparedVariablesUpdate() { - return this.variables.filter((variable) => variable.key !== ''); + return this.filledVariables.map((variable) => { + return { + id: variable.id, + key: variable.key, + value: variable.value, + variableType: variable.variableType, + destroy: variable.destroy, + }; + }); }, preparedVariablesCreate() { - return this.preparedVariablesUpdate.map((variable) => { + const vars = this.variables.filter((variable) => variable.key !== ''); + + return vars.map((variable) => { return { key: variable.key, value: variable.value, @@ -187,6 +203,15 @@ export default { ? this.$options.i18n.editScheduleBtnText : this.$options.i18n.createScheduleBtnText; }, + varSecurityBtnText() { + return this.showVarValues ? this.$options.i18n.hideText : this.$options.i18n.revealText; + }, + hasExistingScheduleVariables() { + return this.schedule?.variables?.nodes.length > 0; + }, + showVarSecurityBtn() { + return this.editing && this.hasExistingScheduleVariables; + }, }, created() { this.addEmptyVariable(); @@ -204,6 +229,7 @@ export default { key: '', value: '', destroy: false, + empty: true, }); }, setVariableAttribute(key, attribute, value) { @@ -289,6 +315,14 @@ export default { setTimezone(timezone) { this.cronTimezone = timezone.identifier || ''; }, + displayHiddenChars(variable) { + return ( + this.editing && this.hasExistingScheduleVariables && !this.showVarValues && !variable.empty + ); + }, + resetVariable(index) { + this.variables[index].empty = false; + }, }, }; </script> @@ -342,7 +376,7 @@ export default { /> </gl-form-group> <!--Variable List--> - <gl-form-group :label="$options.i18n.variables"> + <gl-form-group class="gl-mb-2" :label="$options.i18n.variables"> <div v-for="(variable, index) in variables" :key="`var-${index}`" @@ -372,10 +406,21 @@ export default { :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" data-qa-selector="ci_variable_key_field" - @change="addEmptyVariable()" + @change="addEmptyVariable(variable)" /> <gl-form-textarea + v-if="displayHiddenChars(variable)" + value="*****************" + disabled + class="gl-mb-3 gl-h-7!" + :style="$options.textAreaStyle" + :no-resize="false" + data-testid="pipeline-form-ci-variable-hidden-value" + /> + + <gl-form-textarea + v-else v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3 gl-h-7!" @@ -383,6 +428,7 @@ export default { :no-resize="false" data-testid="pipeline-form-ci-variable-value" data-qa-selector="ci_variable_value_field" + @change="resetVariable(index)" /> <template v-if="variables.length > 1"> @@ -406,6 +452,18 @@ export default { </div> </div> </gl-form-group> + + <gl-button + v-if="showVarSecurityBtn" + class="gl-mb-5" + category="secondary" + variant="confirm" + data-testid="variable-security-btn" + @click="showVarValues = !showVarValues" + > + {{ varSecurityBtnText }} + </gl-button> + <!--Activated--> <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3"> {{ $options.i18n.activated }} diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue index b97914f8c26..368cfb9c10d 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -1,5 +1,5 @@ <script> -import { GlTableLite } from '@gitlab/ui'; +import { GlTable } from '@gitlab/ui'; import { s__ } from '~/locale'; import PipelineScheduleActions from './cells/pipeline_schedule_actions.vue'; import PipelineScheduleLastPipeline from './cells/pipeline_schedule_last_pipeline.vue'; @@ -8,6 +8,9 @@ import PipelineScheduleOwner from './cells/pipeline_schedule_owner.vue'; import PipelineScheduleTarget from './cells/pipeline_schedule_target.vue'; export default { + i18n: { + emptyText: s__('PipelineSchedules|No pipeline schedules'), + }, fields: [ { key: 'description', @@ -47,7 +50,7 @@ export default { }, ], components: { - GlTableLite, + GlTable, PipelineScheduleActions, PipelineScheduleLastPipeline, PipelineScheduleNextRun, @@ -68,10 +71,12 @@ export default { </script> <template> - <gl-table-lite + <gl-table :fields="$options.fields" :items="schedules" :tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }" + :empty-text="$options.i18n.emptyText" + show-empty stacked="md" > <template #table-colgroup="{ fields }"> @@ -109,5 +114,5 @@ export default { @playPipelineSchedule="$emit('playPipelineSchedule', $event)" /> </template> - </gl-table-lite> + </gl-table> </template> diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js index 5bfcd69edec..c2f706e56e6 100644 --- a/app/assets/javascripts/ci/reports/codequality_report/store/index.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue index ca1f3301691..7342df6da67 100644 --- a/app/assets/javascripts/ci/reports/components/report_item.vue +++ b/app/assets/javascripts/ci/reports/components/report_item.vue @@ -53,7 +53,7 @@ export default { }; </script> <template> - <li class="report-block-list-issue gl-p-3!" data-qa-selector="report_item_row"> + <li class="report-block-list-issue gl-p-3!" data-testid="report-item-row"> <component :is="iconComponent" v-if="showReportSectionStatusIcon" diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue index 468c8916b8d..a4ec7b6a325 100644 --- a/app/assets/javascripts/ci/reports/components/report_section.vue +++ b/app/assets/javascripts/ci/reports/components/report_section.vue @@ -185,10 +185,7 @@ export default { <div class="media"> <status-icon :status="statusIconName" :size="24" class="align-self-center" /> <div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!"> - <div - data-testid="report-section-code-text" - class="js-code-text code-text gl-align-self-center gl-flex-grow-1" - > + <div class="js-code-text code-text gl-align-self-center gl-flex-grow-1"> <div class="gl-display-flex gl-align-items-center"> <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p> <slot :name="slotName"></slot> 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 2168685e703..e6813211fe9 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 @@ -52,6 +52,8 @@ export default { RunnerTypeTabs, RunnerActionsCell, RunnerJobStatusBadge, + RunnerDashboardLink: () => + import('ee_component/ci/runner/components/runner_dashboard_link.vue'), }, mixins: [glFeatureFlagMixin()], props: { @@ -188,12 +190,12 @@ export default { nav-class="gl-border-none!" /> - <div class="gl-w-full gl-md-w-auto gl-display-flex"> + <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 - class="gl-ml-3" :registration-token="registrationToken" :type="$options.INSTANCE_TYPE" placement="right" diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js index 54eb37f8c90..d4df1393487 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/index.js +++ b/app/assets/javascripts/ci/runner/admin_runners/index.js @@ -1,6 +1,9 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; + +import { provide } from 'ee_else_ce/ci/runner/admin_runners/provide'; + import { visitUrl } from '~/lib/utils/url_utility'; import { updateOutdatedUrl } from '~/ci/runner/runner_search_utils'; import createDefaultClient from '~/lib/graphql'; @@ -29,14 +32,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { return null; } - const { - runnerInstallHelpPage, - newRunnerPath, - registrationToken, - onlineContactTimeoutSecs, - staleTimeoutSecs, - } = el.dataset; - + const { newRunnerPath, registrationToken } = el.dataset; const { cacheConfig, typeDefs, localMutations } = createLocalState(); const apolloProvider = new VueApollo({ @@ -47,10 +43,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { el, apolloProvider, provide: { - runnerInstallHelpPage, + ...provide(el.dataset), localMutations, - onlineContactTimeoutSecs, - staleTimeoutSecs, }, render(h) { return h(AdminRunnersApp, { diff --git a/app/assets/javascripts/ci/runner/admin_runners/provide.js b/app/assets/javascripts/ci/runner/admin_runners/provide.js new file mode 100644 index 00000000000..81a39801718 --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_runners/provide.js @@ -0,0 +1,22 @@ +/** + * Provides global values to the admin runners app. + * + * @param {Object} `data-` HTML attributes of the mounting point + * @returns An object with properties to use provide/inject of the root app. + * See EE version + */ +export const provide = (elDataset) => { + const { + runnerInstallHelpPage, + onlineContactTimeoutSecs, + staleTimeoutSecs, + tagSuggestionsPath, + } = elDataset; + + return { + runnerInstallHelpPage, + onlineContactTimeoutSecs, + staleTimeoutSecs, + tagSuggestionsPath, + }; +}; diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue index 69021dde0e9..771ecb1a0d4 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue @@ -163,7 +163,7 @@ export default { " > <template #link="{ content }"> - <gl-link data-testid="runner-install-link" @click="toggleDrawer">{{ content }}</gl-link> + <gl-link @click="toggleDrawer">{{ content }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue index 339c92a427f..50d2fcfa961 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue @@ -45,6 +45,7 @@ export default { :label-for="inputId" :copy-button-title="$options.I18N_COPY_BUTTON_TITLE" :form-input-group-props="formInputGroupProps" + readonly @copy="onCopy" > <template v-for="slot in Object.keys($scopedSlots)" #[slot]> diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue index 8c1280cffb9..fac90fb0370 100644 --- a/app/assets/javascripts/ci/runner/components/runner_details.vue +++ b/app/assets/javascripts/ci/runner/components/runner_details.vue @@ -94,10 +94,7 @@ export default { <div> <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> <div class="gl-pt-4"> - <dl - class="gl-mb-0 gl-display-grid runner-details-grid-template" - data-testid="runner-details-list" - > + <dl class="gl-mb-0 gl-display-grid runner-details-grid-template"> <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> <runner-detail :label="s__('Runners|Last contact')" 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 ee56fea8282..3634dcf1c93 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 @@ -93,6 +93,8 @@ export default { :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" @onFilter="onFilter" @onSort="onSort" diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js index 71a145dd4a3..5bec9804002 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js @@ -3,26 +3,15 @@ import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/consta import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; -const options = [ - { value: 'true', title: __('Yes') }, - { value: 'false', title: __('No') }, -]; - export const pausedTokenConfig = { icon: 'pause', title: I18N_PAUSED, type: PARAM_KEY_PAUSED, token: BaseToken, unique: true, - options: options.map(({ value, title }) => ({ - value, - // Replace whitespace with a special character to avoid - // splitting this value. - // Replacing in each option, as translations may also - // contain spaces! - // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 - // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - title: title.replace(/\s/g, '\u00a0'), - })), + options: [ + { value: 'true', title: __('Yes') }, + { value: 'false', title: __('No') }, + ], operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js index 4bc32909777..1e4774bff72 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js @@ -15,28 +15,17 @@ import { PARAM_KEY_STATUS, } from '../../constants'; -const options = [ - { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE }, - { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE }, - { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED }, - { value: STATUS_STALE, title: I18N_STATUS_STALE }, -]; - export const statusTokenConfig = { icon: 'status', title: TOKEN_TITLE_STATUS, type: PARAM_KEY_STATUS, token: BaseToken, unique: true, - options: options.map(({ value, title }) => ({ - value, - // Replace whitespace with a special character to avoid - // splitting this value. - // Replacing in each option, as translations may also - // contain spaces! - // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 - // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - title: title.replace(/\s/g, '\u00a0'), - })), + options: [ + { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE }, + { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE }, + { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED }, + { value: STATUS_STALE, title: I18N_STATUS_STALE }, + ], operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue index 1de7775090a..dd1cca0a05c 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue @@ -7,20 +7,13 @@ import { s__ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { RUNNER_TAG_BG_CLASS } from '../../constants'; -// TODO This should be implemented via a GraphQL API -// The API should -// 1) scope to the rights of the user -// 2) stay up to date to the removal of old tags -// 3) consider the scope of search, like searching within the tags of a group -// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 -export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json'; - export default { components: { BaseToken, GlFilteredSearchSuggestion, GlToken, }, + inject: ['tagSuggestionsPath'], props: { config: { type: Object, @@ -36,7 +29,7 @@ export default { methods: { getTagsOptions(search) { return axios - .get(TAG_SUGGESTIONS_PATH, { + .get(this.tagSuggestionsPath, { params: { search, }, diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue index 799c6ec79d4..6a45c1313ca 100644 --- a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue +++ b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; @@ -45,10 +46,8 @@ export default { v-gl-modal="modalId" v-gl-tooltip.hover.top="$options.i18n.metadataLabel" category="secondary" - variant="info" icon="doc-text" :aria-label="$options.i18n.metadataLabel" - data-testid="metadata-button" @click="selectSecureFile()" /> </template> 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 a459b721394..fdf720a5f94 100644 --- a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue +++ b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlModal, GlSprintf, GlModalDirective } from '@gitlab/ui'; import { __, s__, createDateTimeFormat } from '~/locale'; diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue index 92043ff0a31..3acfac30245 100644 --- a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue +++ b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlTableLite } from '@gitlab/ui'; diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue index dd80698ec1a..509bdabdd9e 100644 --- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -2,6 +2,7 @@ import { GlAlert, GlButton, + GlCard, GlIcon, GlLoadingIcon, GlModal, @@ -24,6 +25,7 @@ export default { components: { GlAlert, GlButton, + GlCard, GlIcon, GlLoadingIcon, GlModal, @@ -42,6 +44,7 @@ export default { inject: ['projectId', 'admin', 'fileSizeLimit'], DEFAULT_PER_PAGE, i18n: { + title: __('Files'), deleteLabel: __('Delete File'), uploadLabel: __('Upload File'), uploadingLabel: __('Uploading...'), @@ -89,7 +92,8 @@ export default { }, { key: 'actions', - label: '', + label: __('Actions'), + thClass: 'gl-text-right', tdClass: 'gl-text-right gl-vertical-align-middle!', }, ], @@ -184,78 +188,95 @@ export default { <template> <div> - <div class="ci-secure-files-table"> + <div> <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null"> {{ errorMessage }} </gl-alert> - <gl-table - :busy="loading" - :fields="fields" - :items="projectSecureFiles" - tbody-tr-class="js-ci-secure-files-row" - data-qa-selector="ci_secure_files_table_content" - sort-by="key" - sort-direction="asc" - stacked="lg" - table-class="text-secondary" - show-empty - sort-icon-left - no-sort-reset - :empty-text="$options.i18n.noFilesMessage" + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" > - <template #table-busy> - <gl-loading-icon size="lg" class="gl-my-5" /> + <template #header> + <div class="gl-new-card-title-wrapper"> + <h5 class="gl-new-card-title gl-my-0"> + {{ $options.i18n.title }} + <span class="gl-new-card-count"> + <gl-icon name="document" class="gl-mr-2" /> + {{ projectSecureFiles.length }} + </span> + </h5> + </div> + <div class="gl-new-card-actions"> + <gl-button v-if="admin" size="small" @click="loadFileSelector"> + <span v-if="uploading"> + <gl-loading-icon class="gl-my-5" inline /> + {{ $options.i18n.uploadingLabel }} + </span> + <span v-else> + <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }} + </span> + </gl-button> + <input + id="file-upload" + ref="fileUpload" + type="file" + class="hidden" + data-qa-selector="file_upload_field" + @change="uploadSecureFile" + /> + </div> </template> - <template #cell(name)="{ item }"> - {{ item.name }} - </template> + <gl-table + :busy="loading" + :fields="fields" + :items="projectSecureFiles" + tbody-tr-class="js-ci-secure-files-row" + sort-by="key" + sort-direction="asc" + stacked="md" + table-class="text-secondary" + show-empty + sort-icon-left + no-sort-reset + :empty-text="$options.i18n.noFilesMessage" + > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> - <template #cell(created_at)="{ item }"> - <timeago-tooltip :time="item.created_at" /> - </template> + <template #cell(name)="{ item }"> + {{ item.name }} + </template> - <template #cell(actions)="{ item }"> - <metadata-button - :secure-file="item" - :admin="admin" - modal-id="$options.metadataModalId" - @selectSecureFile="updateMetadataSecureFile" - /> - <gl-button - v-if="admin" - v-gl-modal="$options.deleteModalId" - v-gl-tooltip.hover.top="$options.i18n.deleteLabel" - category="secondary" - variant="danger" - icon="remove" - :aria-label="$options.i18n.deleteLabel" - data-testid="delete-button" - @click="setDeleteModalData(item)" - /> - </template> - </gl-table> - </div> + <template #cell(created_at)="{ item }"> + <timeago-tooltip :time="item.created_at" /> + </template> - <div class="gl-display-flex gl-mt-5"> - <gl-button v-if="admin" variant="confirm" @click="loadFileSelector"> - <span v-if="uploading"> - <gl-loading-icon class="gl-my-5" inline /> - {{ $options.i18n.uploadingLabel }} - </span> - <span v-else> - <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }} - </span> - </gl-button> - <input - id="file-upload" - ref="fileUpload" - type="file" - class="hidden" - data-qa-selector="file_upload_field" - @change="uploadSecureFile" - /> + <template #cell(actions)="{ item }"> + <metadata-button + :secure-file="item" + :admin="admin" + modal-id="$options.metadataModalId" + @selectSecureFile="updateMetadataSecureFile" + /> + <gl-button + v-if="admin" + v-gl-modal="$options.deleteModalId" + v-gl-tooltip.hover.top="$options.i18n.deleteLabel" + size="small" + category="tertiary" + variant="default" + icon="remove" + :aria-label="$options.i18n.deleteLabel" + data-testid="delete-button" + @click="setDeleteModalData(item)" + /> + </template> + </gl-table> + </gl-card> </div> <gl-pagination @@ -266,6 +287,7 @@ export default { :next-text="$options.i18n.pagination.next" :prev-text="$options.i18n.pagination.prev" align="center" + class="gl-mt-5" /> <gl-modal diff --git a/app/assets/javascripts/ci_settings_general_pipeline/index.js b/app/assets/javascripts/ci_settings_general_pipeline/index.js new file mode 100644 index 00000000000..5053786fbba --- /dev/null +++ b/app/assets/javascripts/ci_settings_general_pipeline/index.js @@ -0,0 +1,19 @@ +export const initGeneralPipelinesOptions = () => { + const forwardDeploymentEnabledCheckbox = document.getElementById( + 'project_ci_cd_settings_attributes_forward_deployment_enabled', + ); + const forwardDeploymentRollbackAllowedCheckbox = document.getElementById( + 'project_ci_cd_settings_attributes_forward_deployment_rollback_allowed', + ); + + if (forwardDeploymentRollbackAllowedCheckbox && forwardDeploymentEnabledCheckbox) { + forwardDeploymentRollbackAllowedCheckbox.disabled = !forwardDeploymentEnabledCheckbox.checked; + + forwardDeploymentEnabledCheckbox.addEventListener('change', () => { + if (!forwardDeploymentEnabledCheckbox.checked) { + forwardDeploymentRollbackAllowedCheckbox.checked = false; + } + forwardDeploymentRollbackAllowedCheckbox.disabled = !forwardDeploymentEnabledCheckbox.checked; + }); + } +}; diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index a1b264cfe54..0871d543d46 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -1,13 +1,5 @@ <script> -import { - GlAlert, - GlAvatar, - GlAvatarLink, - GlBadge, - GlButton, - GlTable, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlBadge, GlButton, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { thWidthPercent } from '~/lib/utils/table_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -25,7 +17,6 @@ export default { }, components: { ClipboardButton, - GlAlert, GlAvatar, GlAvatarLink, GlBadge, @@ -53,28 +44,32 @@ export default { { key: 'token', label: s__('Pipelines|Token'), - thClass: thWidthPercent(70), + thClass: thWidthPercent(60), + tdClass: 'gl-vertical-align-middle!', }, { key: 'description', label: s__('Pipelines|Description'), - thClass: thWidthPercent(15), + thClass: thWidthPercent(20), + tdClass: 'gl-vertical-align-middle!', }, { key: 'owner', label: s__('Pipelines|Owner'), thClass: thWidthPercent(5), + tdClass: 'gl-vertical-align-middle!', }, { key: 'lastUsed', label: s__('Pipelines|Last Used'), - thClass: thWidthPercent(5), + thClass: thWidthPercent(10), + tdClass: 'gl-vertical-align-middle!', }, { key: 'actions', - label: '', + label: __('Actions'), tdClass: 'gl-text-right gl-white-space-nowrap', - thClass: thWidthPercent(5), + thClass: `gl-text-right ${thWidthPercent(5)}`, }, ], computed: { @@ -88,9 +83,22 @@ export default { return '*'.repeat(47); }, }, + mounted() { + const revealButton = document.querySelector('[data-testid="reveal-hide-values-button"]'); + if (revealButton) { + if (this.triggers.length === 0) { + revealButton.style.display = 'none'; + } + + revealButton.addEventListener('click', () => { + this.toggleHiddenState(revealButton); + }); + } + }, methods: { - toggleHiddenState() { + toggleHiddenState(element) { this.areValuesHidden = !this.areValuesHidden; + element.innerText = this.valuesButtonText; }, }, }; @@ -102,7 +110,8 @@ export default { v-if="hasTriggers" :fields="$options.fields" :items="triggers" - class="triggers-list" + class="triggers-list gl-mb-0" + stacked="md" responsive > <template #cell(token)="{ item }"> @@ -116,8 +125,8 @@ export default { :title="$options.i18n.copyTrigger" css-class="gl-border-none gl-py-0 gl-px-2" /> - <div class="gl-display-inline-block gl-ml-3"> - <gl-badge v-if="!item.canAccessProject" variant="danger"> + <div v-if="!item.canAccessProject" class="gl-display-inline-block gl-ml-3"> + <gl-badge variant="danger"> <span v-gl-tooltip.viewport boundary="viewport" @@ -132,7 +141,7 @@ export default { :title="item.description" truncate-target="child" placement="top" - class="gl-max-w-15 gl-display-flex" + class="gl-max-w-15 gl-display-inline-flex" > <div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div> </tooltip-on-truncate> @@ -157,6 +166,7 @@ export default { :title="$options.i18n.editButton" :aria-label="$options.i18n.editButton" icon="pencil" + category="tertiary" data-testid="edit-btn" :href="item.editProjectTriggerPath" /> @@ -164,32 +174,24 @@ export default { :title="$options.i18n.revokeButton" :aria-label="$options.i18n.revokeButton" icon="remove" + category="tertiary" :data-confirm="$options.i18n.revokeButtonConfirm" data-method="delete" data-confirm-btn-variant="danger" rel="nofollow" - class="gl-ml-3" data-testid="trigger_revoke_button" data-qa-selector="trigger_revoke_button" :href="item.projectTriggerPath" /> </template> </gl-table> - <gl-alert + <div v-else - variant="warning" - :dismissible="false" - :show-icon="false" + class="gl-new-card-empty gl-px-5 gl-py-4" data-testid="no_triggers_content" data-qa-selector="no_triggers_content" > {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} - </gl-alert> - <gl-button - v-if="hasTriggers" - data-testid="reveal-hide-values-button" - @click="toggleHiddenState" - >{{ valuesButtonText }}</gl-button - > + </div> </div> </template> diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index d740d1c8865..9d7d68ee31c 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index 8a997624a36..eabe809fbd2 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -147,7 +147,6 @@ export default { <template v-if="confirmCleanup"> <gl-button :disabled="!canSubmit" - data-testid="remove-integration-and-resources-modal-button" variant="danger" category="primary" @click="handleSubmit(true)" diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue index b2a8381f937..25669e4fd0c 100644 --- a/app/assets/javascripts/clusters/forms/components/integration_form.vue +++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue @@ -8,6 +8,7 @@ import { GlLink, GlButton, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { s__ } from '~/locale'; @@ -74,7 +75,6 @@ export default { v-gl-tooltip:tooltipcontainer name="cluster[enabled]" class="gl-mb-0 js-project-feature-toggle" - data-qa-selector="integration_status_toggle" aria-describedby="toggleCluster" :disabled="!editable" :label="$options.i18n.toggleLabel" @@ -111,7 +111,6 @@ export default { id="cluster_base_domain" v-model="baseDomainField" name="cluster[base_domain]" - data-qa-selector="base_domain_field" class="col-md-6" type="text" /> @@ -144,7 +143,6 @@ export default { type="submit" :disabled="!canSubmit" :aria-disabled="!canSubmit" - data-qa-selector="save_changes_button" >{{ s__('ClusterIntegration|Save changes') }}</gl-button > </div> diff --git a/app/assets/javascripts/clusters/forms/stores/index.js b/app/assets/javascripts/clusters/forms/stores/index.js index 87f1c05fdf9..b0cacde17a1 100644 --- a/app/assets/javascripts/clusters/forms/stores/index.js +++ b/app/assets/javascripts/clusters/forms/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import state from './state'; diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue index 93c37226a09..5f40815bd02 100644 --- a/app/assets/javascripts/clusters_list/components/agent_token.vue +++ b/app/assets/javascripts/clusters_list/components/agent_token.vue @@ -89,6 +89,7 @@ export default { <p class="gl-display-flex gl-align-items-flex-start"> <code-block class="gl-w-full" :code="agentRegistrationCommand" /> <modal-copy-button + data-testid="agent-registration-command" :title="$options.i18n.copyCommand" :text="agentRegistrationCommand" :modal-id="modalId" diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index b1765d336c8..33d98c381fb 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue index b241aa34283..d2cc0df8a9d 100644 --- a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue +++ b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; export default { diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 4b85ca2b508..590fdb947b3 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlBadge, @@ -9,6 +10,7 @@ import { GlTableLite, GlTooltipDirective, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import { CLUSTER_TYPES, STATUSES } from '../constants'; diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index 7b97a5af373..c388d3fee71 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -92,11 +92,7 @@ export default { <!--TODO: Replace button-group workaround once `split` option for new dropdowns is implemented.--> <!-- See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263--> - <gl-button-group - ref="actions" - data-qa-selector="clusters_actions_button" - class="gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0" - > + <gl-button-group ref="actions" class="gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0"> <gl-button v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID" :href="defaultActionUrl" diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue index d831d79b994..4450c85661a 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -1,5 +1,6 @@ <script> import { GlCard, GlSprintf, GlPopover, GlLink, GlBadge, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST } from '../constants'; import Clusters from './clusters.vue'; diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index 55e62d1c698..e98e2b37362 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -32,6 +32,10 @@ export default { registerAgentPath: helpPagePath('user/clusters/agent/install/index', { anchor: 'register-the-agent-with-gitlab', }), + terraformDocsLink: + 'https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/cluster_agent_token', + minAgentsForTerraform: 10, + maxAgents: 100, components: { AvailableAgentsDropdown, AgentToken, @@ -80,6 +84,7 @@ export default { clusterAgent: null, availableAgents: [], kasDisabled: false, + configuredAgentsCount: 0, }; }, computed: { @@ -113,6 +118,12 @@ export default { modalSize() { return this.kasDisabled ? 'sm' : 'md'; }, + showTerraformSuggestionAlert() { + return this.configuredAgentsCount >= this.$options.minAgentsForTerraform; + }, + showMaxAgentsAlert() { + return this.configuredAgentsCount >= this.$options.maxAgents; + }, }, methods: { setAgentName(name) { @@ -135,6 +146,7 @@ export default { const configuredAgents = data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? []; + this.configuredAgentsCount = configuredAgents.length; this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent)); }, createAgentMutation() { @@ -233,6 +245,22 @@ export default { </gl-sprintf> </p> + <gl-alert + v-if="showTerraformSuggestionAlert" + :dismissible="false" + variant="warning" + class="gl-my-4" + > + <span v-if="showMaxAgentsAlert">{{ $options.i18n.maxAgentsSupport }}</span> + <span> + <gl-sprintf :message="$options.i18n.useTerraformText"> + <template #link="{ content }"> + <gl-link :href="$options.terraformDocsLink">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </gl-alert> + <form> <gl-form-group label-for="agent-name"> <available-agents-dropdown diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 3ce10f7c3a2..7c5a2d27829 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -131,6 +131,10 @@ export const I18N_AGENT_MODAL = { learnMoreLink: s__('ClusterAgents|How do I register an agent?'), registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'), unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), + maxAgentsSupport: s__('ClusterAgents|We only support 100 agents on the UI.'), + useTerraformText: s__( + 'ClusterAgents|To manage more agents, %{linkStart}use Terraform%{linkEnd}.', + ), }; export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError'; diff --git a/app/assets/javascripts/clusters_list/store/index.js b/app/assets/javascripts/clusters_list/store/index.js index 7cdd93eeae9..4161098f199 100644 --- a/app/assets/javascripts/clusters_list/store/index.js +++ b/app/assets/javascripts/clusters_list/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue index 81edbb4182e..67c9ce235cc 100644 --- a/app/assets/javascripts/code_navigation/components/app.vue +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import eventHub from '~/notes/event_hub'; import Popover from './popover.vue'; diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue index b7fa3242fbf..8d388fb8a53 100644 --- a/app/assets/javascripts/code_navigation/components/popover.vue +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlTabs, GlTab, GlLink, GlBadge } from '@gitlab/ui'; import DocLine from './doc_line.vue'; diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js index 83906245b81..11bffec7ae0 100644 --- a/app/assets/javascripts/code_navigation/index.js +++ b/app/assets/javascripts/code_navigation/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import App from './components/app.vue'; import createStore from './store'; diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js index 6b448bc5fb7..3f9a21c0514 100644 --- a/app/assets/javascripts/code_navigation/store/index.js +++ b/app/assets/javascripts/code_navigation/store/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue index 6bdf1b313cb..c29482eab7a 100644 --- a/app/assets/javascripts/comment_templates/components/form.vue +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -1,9 +1,11 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; +import { InternalEvents } from '~/tracking'; import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql'; import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql'; @@ -16,6 +18,7 @@ export default { GlAlert, MarkdownField, }, + mixins: [InternalEvents.mixin()], props: { id: { type: String, @@ -60,6 +63,13 @@ export default { }, }, methods: { + onCancel() { + if (this.id) { + this.$router.push({ path: '/' }); + } else { + this.$emit('cancel'); + } + }, onSubmit() { this.showValidation = true; @@ -83,6 +93,7 @@ export default { this.$emit('saved'); this.updateCommentTemplate = { name: '', content: '' }; this.showValidation = false; + this.track_event('i_code_review_saved_replies_create'); } }, }) @@ -135,6 +146,7 @@ export default { v-model="updateCommentTemplate.name" :placeholder="__('Enter a name for your comment template')" data-testid="comment-template-name-input" + class="gl-form-input-xl" /> </gl-form-group> <gl-form-group @@ -142,6 +154,7 @@ export default { :state="isContentValid" :invalid-feedback="__('Please enter the comment template content.')" data-testid="comment-template-content-form-group" + class="gl-lg-max-w-80p" > <markdown-field :enable-preview="false" @@ -177,6 +190,6 @@ export default { > {{ __('Save') }} </gl-button> - <gl-button v-if="id" :to="{ path: '/' }">{{ __('Cancel') }}</gl-button> + <gl-button @click="onCancel">{{ __('Cancel') }}</gl-button> </gl-form> </template> diff --git a/app/assets/javascripts/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue index 46d6b49297d..9c460297335 100644 --- a/app/assets/javascripts/comment_templates/components/list.vue +++ b/app/assets/javascripts/comment_templates/components/list.vue @@ -1,20 +1,14 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> -import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlKeysetPagination } from '@gitlab/ui'; import ListItem from './list_item.vue'; export default { components: { - GlLoadingIcon, GlKeysetPagination, - GlSprintf, ListItem, }, props: { - loading: { - type: Boolean, - required: false, - default: false, - }, savedReplies: { type: Array, required: true, @@ -23,10 +17,6 @@ export default { type: Object, required: true, }, - count: { - type: Number, - required: true, - }, }, methods: { prevPage() { @@ -44,28 +34,16 @@ export default { </script> <template> - <div class="settings-section"> - <gl-loading-icon v-if="loading" size="lg" /> - <template v-else> - <div class="settings-sticky-header"> - <div class="settings-sticky-header-inner"> - <h4 class="gl-my-0" data-testid="title"> - <gl-sprintf :message="__('My comment templates (%{count})')"> - <template #count>{{ count }}</template> - </gl-sprintf> - </h4> - </div> - </div> - <ul class="gl-list-style-none gl-p-0 gl-m-0"> - <list-item v-for="template in savedReplies" :key="template.id" :template="template" /> - </ul> - <gl-keyset-pagination - v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" - v-bind="pageInfo" - class="gl-mt-4" - @prev="prevPage" - @next="nextPage" - /> - </template> + <div class="gl-new-card-content gl-p-0"> + <ul class="content-list"> + <list-item v-for="template in savedReplies" :key="template.id" :template="template" /> + </ul> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + class="gl-mt-4" + @prev="prevPage" + @next="nextPage" + /> </div> </template> diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue index 70ba449113b..0619201e346 100644 --- a/app/assets/javascripts/comment_templates/components/list_item.vue +++ b/app/assets/javascripts/comment_templates/components/list_item.vue @@ -74,8 +74,8 @@ export default { </script> <template> - <li class="gl-pt-4 gl-pb-5 gl-border-b"> - <div class="gl-display-flex gl-align-items-center"> + <li class="gl-px-5! gl-py-4!"> + <div class="gl-display-flex"> <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6> <div class="gl-ml-auto"> <gl-disclosure-dropdown @@ -94,7 +94,9 @@ export default { </gl-tooltip> </div> </div> - <div class="gl-mt-3 gl-font-monospace gl-white-space-pre-wrap">{{ template.content }}</div> + <div class="gl-font-monospace gl-white-space-pre-line gl-font-sm gl-mt-n5"> + {{ template.content }} + </div> <gl-modal ref="delete-modal" :title="__('Delete comment template')" diff --git a/app/assets/javascripts/comment_templates/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue index 343efdccefa..e9515352399 100644 --- a/app/assets/javascripts/comment_templates/pages/edit.vue +++ b/app/assets/javascripts/comment_templates/pages/edit.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; diff --git a/app/assets/javascripts/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue index daa4ba689a7..58fbe3574bc 100644 --- a/app/assets/javascripts/comment_templates/pages/index.vue +++ b/app/assets/javascripts/comment_templates/pages/index.vue @@ -1,4 +1,6 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> +import { GlCard, GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; import CreateForm from '../components/form.vue'; import savedRepliesQuery from '../queries/saved_replies.query.graphql'; @@ -27,6 +29,10 @@ export default { }, }, components: { + GlCard, + GlButton, + GlLoadingIcon, + GlIcon, CreateForm, List, }, @@ -36,34 +42,58 @@ export default { count: 0, pageInfo: {}, pagination: {}, + showForm: false, }; }, methods: { refetchSavedReplies() { this.pagination = {}; this.$apollo.queries.savedReplies.refetch(); + this.toggleShowForm(); }, changePage(pageInfo) { this.pagination = pageInfo; }, + toggleShowForm() { + this.showForm = !this.showForm; + }, }, }; </script> <template> - <div> - <div class="settings-section"> - <h5 class="gl-mt-0 gl-font-lg"> - {{ __('Add new comment template') }} - </h5> - <create-form @saved="refetchSavedReplies" /> + <gl-card + class="gl-new-card gl-overflow-hidden" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper" data-testid="title"> + <h3 class="gl-new-card-title"> + {{ __('My comment templates') }} + </h3> + <div class="gl-new-card-count"> + <gl-icon name="comment-lines" class="gl-mr-2" /> + {{ count }} + </div> + </div> + <gl-button v-if="!showForm" size="small" class="gl-ml-3" @click="toggleShowForm"> + {{ __('Add new') }} + </gl-button> + </template> + <div v-if="showForm" class="gl-new-card-add-form gl-m-3 gl-mb-4"> + <h4 class="gl-mt-0">{{ __('Add new comment template') }}</h4> + <create-form @saved="refetchSavedReplies" @cancel="toggleShowForm" /> </div> + <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="sm" class="gl-my-5" /> <list - :loading="$apollo.queries.savedReplies.loading" + v-else-if="savedReplies" :saved-replies="savedReplies" :page-info="pageInfo" - :count="count" @input="changePage" /> - </div> + <div v-else class="gl-new-card-empty gl-px-5 gl-py-4"> + {{ __('You have no saved replies yet.') }} + </div> + </gl-card> </template> diff --git a/app/assets/javascripts/commit/components/signature_badge.vue b/app/assets/javascripts/commit/components/signature_badge.vue index 344536df093..edc7c9d2f96 100644 --- a/app/assets/javascripts/commit/components/signature_badge.vue +++ b/app/assets/javascripts/commit/components/signature_badge.vue @@ -51,11 +51,11 @@ export default { class="gl-border-0 gl-outline-0! gl-p-0 gl-bg-transparent" :aria-label="statusConfig.label" > - <gl-badge :variant="statusConfig.variant" size="md" data-testid="signature-status"> + <gl-badge :variant="statusConfig.variant" size="md"> {{ statusConfig.label }} </gl-badge> </button> - <gl-popover target="signature" triggers="focus" data-testid="signature-info"> + <gl-popover target="signature" triggers="focus"> <template #title> {{ statusConfig.title }} </template> diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue b/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_table_wrapper.vue diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index c937e65abe3..aeac744f319 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 1036b6552d1..25c03496a76 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -193,9 +193,12 @@ export default { } }, focus() { + this.contentEditor.tiptapEditor.commands.focus(); + }, + onFocus() { this.focused = true; }, - blur() { + onBlur() { this.focused = false; }, notifyLoading() { @@ -230,8 +233,8 @@ export default { <div class="md-area gl-overflow-hidden"> <editor-state-observer @docUpdate="notifyChange" - @focus="focus" - @blur="blur" + @focus="onFocus" + @blur="onBlur" @keydown="$emit('keydown', $event)" /> <content-editor-alert /> diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index f484ce98e90..ab9e5619600 100644 --- a/app/assets/javascripts/content_editor/extensions/copy_paste.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -182,6 +182,16 @@ export default Extension.create({ ); } + const preStartRegex = /^<pre[^>]*lang="markdown"[^>]*>/; + const preEndRegex = /<\/pre>$/; + const htmlContentWithoutMeta = htmlContent?.replace(/^<meta[^>]*>/, ''); + const pastingMarkdownBlock = + hasHTML && + preStartRegex.test(htmlContentWithoutMeta) && + preEndRegex.test(htmlContentWithoutMeta); + + if (pastingMarkdownBlock) return this.editor.commands.pasteContent(textContent, true); + return this.editor.commands.pasteContent(hasHTML ? htmlContent : textContent, !hasHTML); }, }, diff --git a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js index 5e7c981ace3..964455a3922 100644 --- a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js +++ b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js @@ -222,6 +222,10 @@ export default { step21: () => import(/* webpackChunkName: 'hl-step21' */ 'highlight.js/lib/languages/step21'), stylus: () => import(/* webpackChunkName: 'hl-stylus' */ 'highlight.js/lib/languages/stylus'), subunit: () => import(/* webpackChunkName: 'hl-subunit' */ 'highlight.js/lib/languages/subunit'), + svelte: () => + import( + /* webpackChunkName: 'hl-svelte' */ '~/vue_shared/components/source_viewer/languages/svelte' + ), swift: () => import(/* webpackChunkName: 'hl-swift' */ 'highlight.js/lib/languages/swift'), taggerscript: () => import(/* webpackChunkName: 'hl-taggerscript' */ 'highlight.js/lib/languages/taggerscript'), diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue new file mode 100644 index 00000000000..85c42ca5485 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_closed.vue @@ -0,0 +1,43 @@ +<script> +import { + EVENT_CLOSED_I18N, + TARGET_TYPE_MERGE_REQUEST, + EVENT_CLOSED_ICONS, +} from 'ee_else_ce/contribution_events/constants'; +import { getValueByEventTarget } from '../../utils'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventClosed', + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + targetType() { + return this.event.target.type; + }, + message() { + return getValueByEventTarget(EVENT_CLOSED_I18N, this.event); + }, + iconName() { + return getValueByEventTarget(EVENT_CLOSED_ICONS, this.event); + }, + iconClass() { + return this.targetType === TARGET_TYPE_MERGE_REQUEST ? 'gl-text-red-500' : 'gl-text-blue-500'; + }, + }, +}; +</script> + +<template> + <contribution-event-base + :event="event" + :message="message" + :icon-name="iconName" + :icon-class="iconClass" + /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue new file mode 100644 index 00000000000..ee433c17792 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_commented.vue @@ -0,0 +1,71 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { + EVENT_COMMENTED_I18N, + EVENT_COMMENTED_SNIPPET_I18N, +} from 'ee_else_ce/contribution_events/constants'; +import { SNIPPET_NOTEABLE_TYPE, COMMIT_NOTEABLE_TYPE } from '~/notes/constants'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import ResourceParentLink from '../resource_parent_link.vue'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventCommented', + components: { ContributionEventBase, GlSprintf, GlLink, ResourceParentLink }, + directives: { + SafeHtml, + }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + resourceParent() { + return this.event.resource_parent; + }, + noteable() { + return this.event.noteable; + }, + noteableType() { + return this.noteable.type; + }, + message() { + if (this.noteableType === SNIPPET_NOTEABLE_TYPE) { + return ( + EVENT_COMMENTED_SNIPPET_I18N[this.resourceParent?.type] || + EVENT_COMMENTED_SNIPPET_I18N.fallback + ); + } + + return EVENT_COMMENTED_I18N[this.noteableType] || EVENT_COMMENTED_I18N.fallback; + }, + noteableLinkClass() { + if (this.noteableType === COMMIT_NOTEABLE_TYPE) { + return ['gl-font-monospace']; + } + + return []; + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" icon-name="comment" icon-class="gl-text-blue-600"> + <gl-sprintf :message="message"> + <template #noteableLink> + <gl-link :class="noteableLinkClass" :href="noteable.web_url">{{ + noteable.reference_link_text + }}</gl-link> + </template> + <template #resourceParentLink> + <resource-parent-link :event="event" /> + </template> + </gl-sprintf> + <template v-if="noteable.first_line_in_markdown" #additional-info> + <div v-safe-html="noteable.first_line_in_markdown" class="md"></div> + </template> + </contribution-event-base> +</template> 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 new file mode 100644 index 00000000000..7915cd6679d --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_created.vue @@ -0,0 +1,62 @@ +<script> +import { + EVENT_CREATED_I18N, + TARGET_TYPE_DESIGN, + TYPE_FALLBACK, +} from 'ee_else_ce/contribution_events/constants'; +import { getValueByEventTarget } from '../../utils'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventCreated', + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + target() { + return this.event.target; + }, + resourceParent() { + return this.event.resource_parent; + }, + message() { + if (!this.target) { + return EVENT_CREATED_I18N[this.resourceParent.type] || EVENT_CREATED_I18N[TYPE_FALLBACK]; + } + + return getValueByEventTarget(EVENT_CREATED_I18N, this.event); + }, + iconName() { + switch (this.target?.type) { + case TARGET_TYPE_DESIGN: + return 'upload'; + + default: + return 'status_open'; + } + }, + iconClass() { + switch (this.target?.type) { + case TARGET_TYPE_DESIGN: + return null; + + default: + return 'gl-text-green-500'; + } + }, + }, +}; +</script> + +<template> + <contribution-event-base + :event="event" + :message="message" + :icon-name="iconName" + :icon-class="iconClass" + /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue new file mode 100644 index 00000000000..36c65950238 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_reopened.vue @@ -0,0 +1,36 @@ +<script> +import { + EVENT_REOPENED_I18N, + EVENT_REOPENED_ICONS, +} from 'ee_else_ce/contribution_events/constants'; +import { getValueByEventTarget } from '../../utils'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventReopened', + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + message() { + return getValueByEventTarget(EVENT_REOPENED_I18N, this.event); + }, + iconName() { + return getValueByEventTarget(EVENT_REOPENED_ICONS, this.event); + }, + }, +}; +</script> + +<template> + <contribution-event-base + :event="event" + :message="message" + :icon-name="iconName" + icon-class="gl-text-green-500" + /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue index 62c803b9217..8b42d77675f 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_events.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue @@ -8,6 +8,10 @@ import { EVENT_TYPE_PUSHED, EVENT_TYPE_PRIVATE, EVENT_TYPE_MERGED, + EVENT_TYPE_CREATED, + EVENT_TYPE_CLOSED, + EVENT_TYPE_REOPENED, + EVENT_TYPE_COMMENTED, } from '../constants'; import ContributionEventApproved from './contribution_event/contribution_event_approved.vue'; import ContributionEventExpired from './contribution_event/contribution_event_expired.vue'; @@ -16,6 +20,10 @@ import ContributionEventLeft from './contribution_event/contribution_event_left. import ContributionEventPushed from './contribution_event/contribution_event_pushed.vue'; import ContributionEventPrivate from './contribution_event/contribution_event_private.vue'; import ContributionEventMerged from './contribution_event/contribution_event_merged.vue'; +import ContributionEventCreated from './contribution_event/contribution_event_created.vue'; +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'; export default { props: { @@ -131,6 +139,18 @@ export default { case EVENT_TYPE_MERGED: return ContributionEventMerged; + case EVENT_TYPE_CREATED: + return ContributionEventCreated; + + case EVENT_TYPE_CLOSED: + return ContributionEventClosed; + + case EVENT_TYPE_REOPENED: + return ContributionEventReopened; + + case EVENT_TYPE_COMMENTED: + return ContributionEventCommented; + 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 6559d6c7272..a14574ed826 100644 --- a/app/assets/javascripts/contribution_events/components/target_link.vue +++ b/app/assets/javascripts/contribution_events/components/target_link.vue @@ -14,7 +14,7 @@ export default { return this.event.target; }, targetLinkText() { - return this.target.reference_link_text; + return this.target.reference_link_text || this.target.title; }, targetLinkAttributes() { return { diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js index d4444e3bede..b5eddbf7e25 100644 --- a/app/assets/javascripts/contribution_events/constants.js +++ b/app/assets/javascripts/contribution_events/constants.js @@ -1,3 +1,11 @@ +import { s__ } from '~/locale'; +import { + ISSUE_NOTEABLE_TYPE, + MERGE_REQUEST_NOTEABLE_TYPE, + DESIGN_NOTEABLE_TYPE, + COMMIT_NOTEABLE_TYPE, +} from '~/notes/constants'; + // From app/models/event.rb#L16 export const EVENT_TYPE_CREATED = 'created'; export const EVENT_TYPE_UPDATED = 'updated'; @@ -16,3 +24,118 @@ export const EVENT_TYPE_PRIVATE = 'private'; // From app/models/push_event_payload.rb#L22 export const PUSH_EVENT_REF_TYPE_BRANCH = 'branch'; export const PUSH_EVENT_REF_TYPE_TAG = 'tag'; + +export const RESOURCE_PARENT_TYPE_PROJECT = 'project'; + +// From app/models/event.rb#L39 +export const TARGET_TYPE_ISSUE = 'Issue'; +export const TARGET_TYPE_MILESTONE = 'Milestone'; +export const TARGET_TYPE_MERGE_REQUEST = 'MergeRequest'; +export const TARGET_TYPE_WIKI = 'WikiPage::Meta'; +export const TARGET_TYPE_DESIGN = 'DesignManagement::Design'; +export const TARGET_TYPE_WORK_ITEM = 'WorkItem'; + +// From app/models/work_items/type.rb#L28 +export const WORK_ITEM_ISSUE_TYPE_ISSUE = 'issue'; +export const WORK_ITEM_ISSUE_TYPE_TASK = 'task'; +export const WORK_ITEM_ISSUE_TYPE_INCIDENT = 'incident'; + +export const TYPE_FALLBACK = 'fallback'; + +export const EVENT_CREATED_I18N = Object.freeze({ + [RESOURCE_PARENT_TYPE_PROJECT]: s__('ContributionEvent|Created project %{resourceParentLink}.'), + [TARGET_TYPE_MILESTONE]: s__( + 'ContributionEvent|Opened milestone %{targetLink} in %{resourceParentLink}.', + ), + [TARGET_TYPE_MERGE_REQUEST]: s__( + 'ContributionEvent|Opened merge request %{targetLink} in %{resourceParentLink}.', + ), + [TARGET_TYPE_WIKI]: s__( + 'ContributionEvent|Created wiki page %{targetLink} in %{resourceParentLink}.', + ), + [TARGET_TYPE_DESIGN]: s__( + 'ContributionEvent|Added design %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_ISSUE]: s__( + 'ContributionEvent|Opened issue %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_TASK]: s__( + 'ContributionEvent|Opened task %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_INCIDENT]: s__( + 'ContributionEvent|Opened incident %{targetLink} in %{resourceParentLink}.', + ), + [TYPE_FALLBACK]: s__('ContributionEvent|Created resource.'), +}); + +export const EVENT_CLOSED_I18N = Object.freeze({ + [TARGET_TYPE_MILESTONE]: s__( + 'ContributionEvent|Closed milestone %{targetLink} in %{resourceParentLink}.', + ), + [TARGET_TYPE_MERGE_REQUEST]: s__( + 'ContributionEvent|Closed merge request %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_ISSUE]: s__( + 'ContributionEvent|Closed issue %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_TASK]: s__( + 'ContributionEvent|Closed task %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_INCIDENT]: s__( + 'ContributionEvent|Closed incident %{targetLink} in %{resourceParentLink}.', + ), + [TYPE_FALLBACK]: s__('ContributionEvent|Closed resource.'), +}); + +export const EVENT_REOPENED_I18N = Object.freeze({ + [TARGET_TYPE_MILESTONE]: s__( + 'ContributionEvent|Reopened milestone %{targetLink} in %{resourceParentLink}.', + ), + [TARGET_TYPE_MERGE_REQUEST]: s__( + 'ContributionEvent|Reopened merge request %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_ISSUE]: s__( + 'ContributionEvent|Reopened issue %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_TASK]: s__( + 'ContributionEvent|Reopened task %{targetLink} in %{resourceParentLink}.', + ), + [WORK_ITEM_ISSUE_TYPE_INCIDENT]: s__( + 'ContributionEvent|Reopened incident %{targetLink} in %{resourceParentLink}.', + ), + [TYPE_FALLBACK]: s__('ContributionEvent|Reopened resource.'), +}); + +export const EVENT_COMMENTED_I18N = Object.freeze({ + [ISSUE_NOTEABLE_TYPE]: s__( + 'ContributionEvent|Commented on issue %{noteableLink} in %{resourceParentLink}.', + ), + [MERGE_REQUEST_NOTEABLE_TYPE]: s__( + 'ContributionEvent|Commented on merge request %{noteableLink} in %{resourceParentLink}.', + ), + [DESIGN_NOTEABLE_TYPE]: s__( + 'ContributionEvent|Commented on design %{noteableLink} in %{resourceParentLink}.', + ), + [COMMIT_NOTEABLE_TYPE]: s__( + 'ContributionEvent|Commented on commit %{noteableLink} in %{resourceParentLink}.', + ), + 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}.'), +}); + +export const EVENT_CLOSED_ICONS = Object.freeze({ + [WORK_ITEM_ISSUE_TYPE_ISSUE]: 'issue-closed', + [TARGET_TYPE_MERGE_REQUEST]: 'merge-request-close', + [TYPE_FALLBACK]: 'status_closed', +}); + +export const EVENT_REOPENED_ICONS = Object.freeze({ + [TARGET_TYPE_MERGE_REQUEST]: 'merge-request-open', + [TYPE_FALLBACK]: 'status_open', +}); diff --git a/app/assets/javascripts/contribution_events/utils.js b/app/assets/javascripts/contribution_events/utils.js new file mode 100644 index 00000000000..0760b5187c6 --- /dev/null +++ b/app/assets/javascripts/contribution_events/utils.js @@ -0,0 +1,9 @@ +import { TYPE_FALLBACK } from './constants'; + +export const getValueByEventTarget = (map, event) => { + const { + target: { type: targetType, issue_type: issueType }, + } = event; + + return map[issueType || targetType] || map[TYPE_FALLBACK]; +}; diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index ce99d5da3cc..21428ff9eca 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -1,7 +1,9 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { debounce, uniq } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { visitUrl } from '~/lib/utils/url_utility'; import { getDatesInRange } from '~/lib/utils/datetime_utility'; diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js index a4d0004cee5..f451f50e454 100644 --- a/app/assets/javascripts/contributors/stores/index.js +++ b/app/assets/javascripts/contributors/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue index 2be17d1f80f..f10565e98e5 100644 --- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue +++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue @@ -231,7 +231,6 @@ export default { <gl-button v-if="canAdminCrmContact" v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" - data-testid="edit-contact-button" icon="pencil" :aria-label="$options.i18n.editButtonLabel" /> diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue index 28f0b34f031..78e1433ab24 100644 --- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue +++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue @@ -226,7 +226,6 @@ export default { <gl-button v-if="canAdminCrmOrganization" v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" - data-testid="edit-organization-button" icon="pencil" :aria-label="$options.i18n.editButtonLabel" /> diff --git a/app/assets/javascripts/custom_emoji/components/app.vue b/app/assets/javascripts/custom_emoji/components/app.vue new file mode 100644 index 00000000000..405a296397f --- /dev/null +++ b/app/assets/javascripts/custom_emoji/components/app.vue @@ -0,0 +1,15 @@ +<script> +export default {}; +</script> + +<template> + <div class="row gl-mt-5"> + <div class="col-12"> + <h4 class="gl-mt-0"> + {{ __('Custom emoji') }} + </h4> + <p>{{ __('Custom emoji will be available to use in every project in group.') }}</p> + <router-view /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/custom_emoji/components/delete_item.vue b/app/assets/javascripts/custom_emoji/components/delete_item.vue new file mode 100644 index 00000000000..9d13d40dc47 --- /dev/null +++ b/app/assets/javascripts/custom_emoji/components/delete_item.vue @@ -0,0 +1,90 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { uniqueId } from 'lodash'; +import { GlButton, GlTooltipDirective, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import deleteCustomEmojiMutation from '../queries/delete_custom_emoji.mutation.graphql'; + +export default { + name: 'DeleteItem', + components: { + GlButton, + GlModal, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + props: { + emoji: { + type: Object, + required: true, + }, + }, + data() { + return { + isDeleting: false, + modalId: uniqueId('delete-custom-emoji-'), + }; + }, + methods: { + showModal() { + this.$refs['delete-modal'].show(); + }, + async onDelete() { + this.isDeleting = true; + + try { + await this.$apollo.mutate({ + mutation: deleteCustomEmojiMutation, + variables: { + id: this.emoji.id, + }, + update: (cache) => { + const cacheId = cache.identify(this.emoji); + cache.evict({ id: cacheId }); + }, + }); + } catch (e) { + createAlert(__('Failed to delete custom emoji. Please try again.')); + Sentry.captureException(e); + } + }, + }, + actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } }, + actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } }, +}; +</script> + +<template> + <div> + <gl-button + v-gl-tooltip + icon="remove" + :aria-label="__('Delete custom emoji')" + :title="__('Delete custom emoji')" + :loading="isDeleting" + data-testid="delete-button" + @click="showModal" + /> + <gl-modal + ref="delete-modal" + :title="__('Delete custom emoji')" + :action-primary="$options.actionPrimary" + :action-secondary="$options.actionSecondary" + :modal-id="modalId" + size="sm" + @primary="onDelete" + > + <gl-sprintf + :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')" + > + <template #name + ><strong>{{ emoji.name }}</strong></template + > + </gl-sprintf> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/custom_emoji/components/form.vue b/app/assets/javascripts/custom_emoji/components/form.vue new file mode 100644 index 00000000000..9f9bc064640 --- /dev/null +++ b/app/assets/javascripts/custom_emoji/components/form.vue @@ -0,0 +1,143 @@ +<script> +import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { logError } from '~/lib/logger'; +import { __ } from '~/locale'; +import createCustomEmojiMutation from '../queries/create_custom_emoji.mutation.graphql'; + +export default { + name: 'CustomEmojiForm', + components: { + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlAlert, + }, + inject: { + groupPath: { + default: '', + }, + }, + data() { + return { + errors: [], + saving: false, + showValidation: false, + updateCustomEmoji: { + name: '', + url: '', + }, + }; + }, + computed: { + isNameValid() { + if (this.showValidation) return Boolean(this.updateCustomEmoji.name); + + return true; + }, + isUrlValid() { + if (this.showValidation) return Boolean(this.updateCustomEmoji.url); + + return true; + }, + isValid() { + return this.isNameValid && this.isUrlValid; + }, + }, + methods: { + onSubmit() { + this.showValidation = true; + + if (!this.isValid) return; + + this.errors = []; + this.saving = true; + + this.$apollo + .mutate({ + mutation: createCustomEmojiMutation, + variables: { + groupPath: this.groupPath, + name: this.updateCustomEmoji.name, + url: this.updateCustomEmoji.url, + }, + update: (store, { data: { createCustomEmoji } }) => { + if (createCustomEmoji.errors.length) { + this.errors = createCustomEmoji.errors.map((e) => e); + } else { + this.$emit('saved'); + this.updateCustomEmoji = { name: '', url: '' }; + this.showValidation = false; + } + }, + }) + .catch((error) => { + const errors = error.graphQLErrors; + + if (errors?.length) { + this.errors = errors.map((e) => e.message); + } else { + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Unexpected error while saving emoji', error); + + this.errors = [__('An unexpected error occurred. Please try again.')]; + } + }) + .finally(() => { + this.saving = false; + }); + }, + }, + restrictedToolbarItems: ['full-screen'], + markdownDocsPath: helpPagePath('user/markdown'), +}; +</script> + +<template> + <gl-form class="gl-mb-6" data-testid="custom-emoji-form" @submit.prevent="onSubmit"> + <gl-alert + v-for="error in errors" + :key="error" + variant="danger" + class="gl-mb-3" + :dismissible="false" + > + {{ error }} + </gl-alert> + <gl-form-group + :label="__('Name')" + :state="isNameValid" + :invalid-feedback="__('Please enter a name for the custom emoji.')" + data-testid="custom-emoji-name-form-group" + > + <gl-form-input + v-model="updateCustomEmoji.name" + :placeholder="__('eg party_tanuki')" + data-testid="custom-emoji-name-input" + /> + </gl-form-group> + <gl-form-group + :label="__('URL')" + :state="isUrlValid" + :invalid-feedback="__('Please enter a URL for the custom emoji.')" + data-testid="custom-emoji-url-form-group" + > + <gl-form-input + v-model="updateCustomEmoji.url" + :placeholder="__('Enter a URL for your custom emoji')" + data-testid="custom-emoji-url-input" + /> + </gl-form-group> + <gl-button + variant="confirm" + class="gl-mr-3 js-no-auto-disable" + type="submit" + :loading="saving" + data-testid="custom-emoji-form-submit-btn" + > + {{ __('Save') }} + </gl-button> + <gl-button :to="{ path: '/' }">{{ __('Cancel') }}</gl-button> + </gl-form> +</template> diff --git a/app/assets/javascripts/custom_emoji/components/list.vue b/app/assets/javascripts/custom_emoji/components/list.vue new file mode 100644 index 00000000000..72b28e8db4a --- /dev/null +++ b/app/assets/javascripts/custom_emoji/components/list.vue @@ -0,0 +1,154 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { GlLoadingIcon, GlTableLite, GlTabs, GlTab, GlBadge, GlKeysetPagination } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; +import DeleteItem from './delete_item.vue'; + +export default { + components: { + GlTableLite, + GlLoadingIcon, + GlTabs, + GlTab, + GlBadge, + GlKeysetPagination, + DeleteItem, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + customEmojis: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + count: { + type: Number, + required: true, + }, + userPermissions: { + type: Object, + required: true, + }, + }, + computed: { + primaryAction() { + if (!this.userPermissions.createCustomEmoji) return undefined; + + return { + text: __('New custom emoji'), + attributes: { + variant: 'info', + to: '/new', + }, + }; + }, + }, + methods: { + prevPage() { + this.$emit('input', { + before: this.pageInfo.startCursor, + }); + }, + nextPage() { + this.$emit('input', { + after: this.pageInfo.endCursor, + }); + }, + formatDate(date) { + return formatDate(date, 'mmmm d, yyyy'); + }, + }, + fields: [ + { + key: 'emoji', + label: __('Image'), + thClass: 'gl-border-t-0!', + tdClass: 'gl-vertical-align-middle!', + columnWidth: '70px', + }, + { + key: 'name', + label: __('Name'), + thClass: 'gl-border-t-0!', + tdClass: 'gl-vertical-align-middle! gl-font-monospace', + }, + { + key: 'created_at', + label: __('Created date'), + thClass: 'gl-border-t-0!', + tdClass: 'gl-vertical-align-middle!', + columnWidth: '25%', + }, + { + key: 'action', + label: '', + thClass: 'gl-border-t-0!', + columnWidth: '64px', + }, + ], +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="loading" size="lg" /> + <template v-else> + <gl-tabs content-class="gl-pt-0" :action-primary="primaryAction"> + <gl-tab> + <template #title> + {{ __('Emoji') }} + <gl-badge size="sm" class="gl-tab-counter-badge">{{ count }}</gl-badge> + </template> + <gl-table-lite + :items="customEmojis" + :fields="$options.fields" + table-class="gl-table-layout-fixed" + > + <template #table-colgroup="scope"> + <col + v-for="field in scope.fields" + :key="field.key" + :style="{ width: field.columnWidth }" + /> + </template> + <template #cell(emoji)="data"> + <gl-emoji + :data-name="data.item.name" + :data-fallback-src="data.item.url" + data-unicode-version="custom" + /> + </template> + <template #cell(action)="data"> + <delete-item + v-if="data.item.userPermissions.deleteCustomEmoji" + :key="data.item.name" + :emoji="data.item" + /> + </template> + <template #cell(created_at)="data"> + {{ formatDate(data.item.createdAt) }} + </template> + <template #cell(name)="data"> + <strong class="gl-str-truncated">:{{ data.item.name }}:</strong> + </template> + </gl-table-lite> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + class="gl-mt-4" + @prev="prevPage" + @next="nextPage" + /> + </gl-tab> + </gl-tabs> + </template> + </div> +</template> diff --git a/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js b/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js new file mode 100644 index 00000000000..c9c3f0831fd --- /dev/null +++ b/app/assets/javascripts/custom_emoji/custom_emoji_bundle.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import defaultClient from './graphql_client'; +import routes from './routes'; +import App from './components/app.vue'; + +export const initCustomEmojis = () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const el = document.getElementById('js-custom-emojis-root'); + + if (!el) return; + + const apolloProvider = new VueApollo({ + defaultClient, + }); + const router = new VueRouter({ + base: el.dataset.basePath, + mode: 'history', + routes, + }); + const { groupPath } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'CustomEmojiApp', + router, + apolloProvider, + provide: { + groupPath, + }, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/custom_emoji/graphql_client.js b/app/assets/javascripts/custom_emoji/graphql_client.js new file mode 100644 index 00000000000..42d0c82c828 --- /dev/null +++ b/app/assets/javascripts/custom_emoji/graphql_client.js @@ -0,0 +1,3 @@ +import createDefaultClient from '~/lib/graphql'; + +export default createDefaultClient(); diff --git a/app/assets/javascripts/custom_emoji/pages/index.vue b/app/assets/javascripts/custom_emoji/pages/index.vue new file mode 100644 index 00000000000..118d6213acd --- /dev/null +++ b/app/assets/javascripts/custom_emoji/pages/index.vue @@ -0,0 +1,67 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { fetchPolicies } from '~/lib/graphql'; +import customEmojisQuery from '../queries/custom_emojis.query.graphql'; +import List from '../components/list.vue'; + +export default { + apollo: { + customEmojis: { + fetchPolicy: fetchPolicies.NETWORK_ONLY, + query: customEmojisQuery, + update: (r) => r.group?.customEmoji?.nodes, + variables() { + return { + groupPath: this.groupPath, + ...this.pagination, + }; + }, + result({ data }) { + const pageInfo = data.group?.customEmoji?.pageInfo; + this.count = data.group?.customEmoji?.count; + this.userPermissions = data.group?.userPermissions; + + if (pageInfo) { + this.pageInfo = pageInfo; + } + }, + }, + }, + components: { + List, + }, + inject: { + groupPath: { + default: '', + }, + }, + data() { + return { + customEmojis: [], + count: 0, + pageInfo: {}, + pagination: {}, + userPermissions: {}, + }; + }, + methods: { + refetchCustomEmojis() { + this.$apollo.queries.customEmojis.refetch(); + }, + changePage(pageInfo) { + this.pagination = pageInfo; + }, + }, +}; +</script> + +<template> + <list + :count="count" + :loading="$apollo.queries.customEmojis.loading" + :page-info="pageInfo" + :custom-emojis="customEmojis" + :user-permissions="userPermissions" + @input="changePage" + /> +</template> diff --git a/app/assets/javascripts/custom_emoji/pages/new.vue b/app/assets/javascripts/custom_emoji/pages/new.vue new file mode 100644 index 00000000000..803a3b7a7ae --- /dev/null +++ b/app/assets/javascripts/custom_emoji/pages/new.vue @@ -0,0 +1,24 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import CreateForm from '../components/form.vue'; + +export default { + components: { + CreateForm, + }, + methods: { + redirectToIndex() { + this.$router.push('/'); + }, + }, +}; +</script> + +<template> + <div> + <h5 class="gl-mt-0 gl-font-lg"> + {{ __('Add new emoji') }} + </h5> + <create-form @saved="redirectToIndex" /> + </div> +</template> diff --git a/app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql b/app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql new file mode 100644 index 00000000000..2c0dbfdf93e --- /dev/null +++ b/app/assets/javascripts/custom_emoji/queries/create_custom_emoji.mutation.graphql @@ -0,0 +1,5 @@ +mutation createCustomEmoji($groupPath: ID!, $name: String!, $url: String!) { + createCustomEmoji(input: { groupPath: $groupPath, name: $name, url: $url }) { + errors + } +} diff --git a/app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql b/app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql new file mode 100644 index 00000000000..a4189f80436 --- /dev/null +++ b/app/assets/javascripts/custom_emoji/queries/custom_emojis.query.graphql @@ -0,0 +1,25 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getCustomEmojis($groupPath: ID!, $after: String = "", $before: String = "") { + group(fullPath: $groupPath) { + id + userPermissions { + createCustomEmoji + } + customEmoji(after: $after, before: $before) { + count + pageInfo { + ...PageInfo + } + nodes { + id + name + url + createdAt + userPermissions { + deleteCustomEmoji + } + } + } + } +} diff --git a/app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql b/app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql new file mode 100644 index 00000000000..37618bc2749 --- /dev/null +++ b/app/assets/javascripts/custom_emoji/queries/delete_custom_emoji.mutation.graphql @@ -0,0 +1,7 @@ +mutation deleteCustomEmoji($id: CustomEmojiID!) { + destroyCustomEmoji(input: { id: $id }) { + customEmoji { + id + } + } +} diff --git a/app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql b/app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql new file mode 100644 index 00000000000..284a9290a03 --- /dev/null +++ b/app/assets/javascripts/custom_emoji/queries/user_permissions.query.graphql @@ -0,0 +1,8 @@ +query customEmojiPermissions($groupPath: ID!) { + group(fullPath: $groupPath) { + id + userPermissions { + createCustomEmoji + } + } +} diff --git a/app/assets/javascripts/custom_emoji/routes.js b/app/assets/javascripts/custom_emoji/routes.js new file mode 100644 index 00000000000..938475d81cd --- /dev/null +++ b/app/assets/javascripts/custom_emoji/routes.js @@ -0,0 +1,35 @@ +import IndexComponent from './pages/index.vue'; +import NewComponent from './pages/new.vue'; +import userPermissionsQuery from './queries/user_permissions.query.graphql'; +import defaultClient from './graphql_client'; + +export default [ + { + path: '/', + component: IndexComponent, + }, + { + path: '/new', + component: NewComponent, + async beforeEnter(to, from, next) { + const { + data: { + group: { + userPermissions: { createCustomEmoji }, + }, + }, + } = await defaultClient.query({ + query: userPermissionsQuery, + variables: { + groupPath: document.body.dataset.groupFullPath, + }, + }); + + if (!createCustomEmoji) { + next({ path: '/' }); + } else { + next(); + } + }, + }, +]; diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index b13b0ede9f0..72d1ce9768a 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -1,6 +1,7 @@ <script> import { GlFormGroup, GlFormInput, GlModal, GlSprintf, GlLink } from '@gitlab/ui'; import { isValidCron } from 'cron-validator'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; @@ -24,10 +25,10 @@ export default { static: true, lazy: true, }, - translations: { + i18n: { cronPlaceholder: '* * * * *', cronSyntaxInstructions: __( - 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}', + 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}.', ), addTitle: __('Add deploy freeze'), editTitle: __('Edit deploy freeze'), @@ -81,9 +82,7 @@ export default { return Boolean(this.selectedId); }, modalTitle() { - return this.isEditing - ? this.$options.translations.editTitle - : this.$options.translations.addTitle; + return this.isEditing ? this.$options.i18n.editTitle : this.$options.i18n.addTitle; }, }, methods: { @@ -104,6 +103,13 @@ export default { this.addFreezePeriod(); } }, + focusFirstInput() { + if (this.$refs.freezeStartCron) { + setTimeout(() => { + this.$refs.freezeStartCron?.$el?.focus(); + }, 250); + } + }, }, }; </script> @@ -115,9 +121,10 @@ export default { :action-primary="addDeployFreezeButton" @primary="submit" @canceled="resetModalHandler" + @change="focusFirstInput" > <p> - <gl-sprintf :message="$options.translations.cronSyntaxInstructions"> + <gl-sprintf :message="$options.i18n.cronSyntaxInstructions"> <template #cronSyntax="{ content }"> <gl-link href="https://crontab.guru/" target="_blank">{{ content }}</gl-link> </template> @@ -132,11 +139,13 @@ export default { > <gl-form-input id="deploy-freeze-start" + ref="freezeStartCron" v-model="freezeStartCron" class="gl-font-monospace!" data-qa-selector="deploy_freeze_start_field" - :placeholder="$options.translations.cronPlaceholder" + :placeholder="$options.i18n.cronPlaceholder" :state="freezeStartCronState" + autofocus trim /> </gl-form-group> @@ -152,7 +161,7 @@ export default { v-model="freezeEndCron" class="gl-font-monospace!" data-qa-selector="deploy_freeze_end_field" - :placeholder="$options.translations.cronPlaceholder" + :placeholder="$options.i18n.cronPlaceholder" :state="freezeEndCronState" trim /> diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue index 77767456f76..705b1871ec0 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue @@ -1,39 +1,49 @@ <script> -import { GlTable, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { + GlCard, + GlTable, + GlButton, + GlIcon, + GlModal, + GlModalDirective, + GlSprintf, +} from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export default { fields: [ { key: 'freezeStart', label: s__('DeployFreeze|Freeze start'), + tdClass: 'gl-vertical-align-middle!', }, { key: 'freezeEnd', label: s__('DeployFreeze|Freeze end'), + tdClass: 'gl-vertical-align-middle!', }, { key: 'cronTimezone', label: s__('DeployFreeze|Time zone'), + tdClass: 'gl-vertical-align-middle!', }, { - key: 'edit', - label: s__('DeployFreeze|Edit'), - }, - { - key: 'delete', - label: s__('DeployFreeze|Delete'), + key: 'actions', + label: __('Actions'), + thClass: 'gl-text-right', }, ], - translations: { + i18n: { + title: s__('DeployFreeze|Deploy freezes'), addDeployFreeze: s__('DeployFreeze|Add deploy freeze'), deleteDeployFreezeTitle: s__('DeployFreeze|Delete deploy freeze?'), deleteDeployFreezeMessage: s__( 'DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?', ), emptyStateText: s__( - 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}', + 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd} above.', ), }, modal: { @@ -42,10 +52,16 @@ export default { text: s__('DeployFreeze|Delete freeze period'), attributes: { variant: 'danger', 'data-testid': 'modal-confirm' }, }, + actionSecondary: { + text: __('Cancel'), + attributes: { variant: 'default' }, + }, }, components: { + GlCard, GlTable, GlButton, + GlIcon, GlModal, GlSprintf, }, @@ -80,65 +96,78 @@ export default { </script> <template> - <div class="deploy-freeze-table"> + <gl-card + class="gl-new-card deploy-freeze-table" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title">{{ $options.i18n.title }}</h3> + <span class="gl-new-card-count"> + <gl-icon name="deployments" class="gl-mr-2" /> + {{ freezePeriods.length }} + </span> + </div> + <div class="gl-new-card-actions"> + <gl-button v-gl-modal.deploy-freeze-modal size="small" data-testid="add-deploy-freeze">{{ + $options.i18n.addDeployFreeze + }}</gl-button> + </div> + </template> + <gl-table data-testid="deploy-freeze-table" :items="freezePeriods" :fields="$options.fields" show-empty - stacked="lg" + stacked="md" > <template #cell(cronTimezone)="{ item }"> {{ item.cronTimezone.formattedTimezone }} </template> - <template #cell(edit)="{ item }"> - <gl-button - v-gl-modal.deploy-freeze-modal - icon="pencil" - data-testid="edit-deploy-freeze" - :aria-label="__('Edit deploy freeze')" - @click="setFreezePeriod(item)" - /> - </template> - <template #cell(delete)="{ item }"> - <gl-button - v-gl-modal="$options.modal.id" - category="secondary" - variant="danger" - icon="remove" - :aria-label="$options.modal.actionPrimary.text" - :loading="item.isDeleting" - data-testid="delete-deploy-freeze" - @click="handleDeleteFreezePeriod(item)" - /> + <template #cell(actions)="{ item }"> + <div class="gl-display-flex gl-justify-content-end gl-mt-n2 gl-mb-n2"> + <gl-button + v-gl-modal.deploy-freeze-modal + icon="pencil" + data-testid="edit-deploy-freeze" + :aria-label="__('Edit deploy freeze')" + class="gl-mr-3" + @click="setFreezePeriod(item)" + /> + <gl-button + v-gl-modal="$options.modal.id" + category="secondary" + variant="danger" + icon="remove" + :aria-label="$options.modal.actionPrimary.text" + :loading="item.isDeleting" + data-testid="delete-deploy-freeze" + @click="handleDeleteFreezePeriod(item)" + /> + </div> </template> <template #empty> - <p data-testid="empty-freeze-periods" class="gl-text-center text-plain"> - <gl-sprintf :message="$options.translations.emptyStateText"> + <p data-testid="empty-freeze-periods" class="gl-text-secondary gl-text-center gl-mb-0"> + <gl-sprintf :message="$options.i18n.emptyStateText"> <template #strong="{ content }"> - <strong>{{ content }}</strong> + {{ content }} </template> </gl-sprintf> </p> </template> </gl-table> - <gl-button - v-gl-modal.deploy-freeze-modal - data-testid="add-deploy-freeze" - category="primary" - variant="confirm" - > - {{ $options.translations.addDeployFreeze }} - </gl-button> <gl-modal - :title="$options.translations.deleteDeployFreezeTitle" + :title="$options.i18n.deleteDeployFreezeTitle" :modal-id="$options.modal.id" :action-primary="$options.modal.actionPrimary" + :action-secondary="$options.modal.actionSecondary" static @primary="confirmDeleteFreezePeriod" > <template v-if="freezePeriodToDelete"> - <gl-sprintf :message="$options.translations.deleteDeployFreezeMessage"> + <gl-sprintf :message="$options.i18n.deleteDeployFreezeMessage"> <template #start> <code>{{ freezePeriodToDelete.freezeStart }}</code> </template> @@ -149,5 +178,5 @@ export default { </gl-sprintf> </template> </gl-modal> - </div> + </gl-card> </template> diff --git a/app/assets/javascripts/deploy_freeze/store/index.js b/app/assets/javascripts/deploy_freeze/store/index.js index 2da7ed31a13..d8c35eac7ee 100644 --- a/app/assets/javascripts/deploy_freeze/store/index.js +++ b/app/assets/javascripts/deploy_freeze/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 5fc15578827..ec17bbea48f 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; @@ -14,8 +14,9 @@ export default { ConfirmModal, KeysPanel, NavigationTabs, - GlLoadingIcon, + GlButton, GlIcon, + GlLoadingIcon, }, props: { endpoint: { @@ -42,6 +43,10 @@ export default { available_project_keys: s__('DeployKeys|Privately accessible deploy keys'), public_keys: s__('DeployKeys|Publicly accessible deploy keys'), }, + i18n: { + loading: s__('DeployKeys|Loading deploy keys'), + addButton: s__('DeployKeys|Add new key'), + }, computed: { tabs() { return Object.keys(this.$options.scopes).map((scope) => { @@ -132,30 +137,48 @@ export default { </script> <template> - <div class="gl-mb-3 deploy-keys"> + <div class="deploy-keys"> <confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" /> <gl-loading-icon v-if="isLoading && !hasKeys" - :label="s__('DeployKeys|Loading deploy keys')" - size="lg" + :label="$options.i18n.loading" + size="sm" + class="gl-m-5" /> <template v-else-if="hasKeys"> - <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> - <div class="fade-left"> - <gl-icon name="chevron-lg-left" :size="12" /> - </div> - <div class="fade-right"> - <gl-icon name="chevron-lg-right" :size="12" /> + <div class="gl-new-card-header gl-align-items-center gl-pt-0 gl-pb-0 gl-pl-0"> + <div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0"> + <div class="fade-left"> + <gl-icon name="chevron-lg-left" :size="12" /> + </div> + <div class="fade-right"> + <gl-icon name="chevron-lg-right" :size="12" /> + </div> + + <navigation-tabs + :tabs="tabs" + scope="deployKeys" + class="gl-rounded-lg" + @onChangeTab="onChangeTab" + /> </div> - <navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" /> + <div class="gl-new-card-actions"> + <gl-button + size="small" + class="js-toggle-button js-toggle-content" + data-testid="add-new-deploy-key-button" + > + {{ $options.i18n.addButton }} + </gl-button> + </div> </div> <keys-panel :project-id="projectId" :keys="keys[currentTab]" :store="store" :endpoint="endpoint" - data-qa-selector="project_deploy_keys_container" + data-testid="project-deploy-keys-container" /> </template> </div> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 94f27dbf048..16c745d8cff 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -1,5 +1,6 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> -import { GlIcon, GlLink, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { GlBadge, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { head, tail } from 'lodash'; import { s__, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -9,9 +10,9 @@ import ActionBtn from './action_btn.vue'; export default { components: { ActionBtn, + GlBadge, GlButton, GlIcon, - GlLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -110,21 +111,30 @@ export default { </script> <template> - <div class="gl-responsive-table-row deploy-key"> + <div + class="gl-responsive-table-row gl-align-items-flex-start deploy-key gl-bg-gray-10 gl-md-pl-5 gl-md-pr-5 gl-border-gray-100!" + > <div class="table-section section-40"> - <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> - <div class="table-mobile-content" data-qa-selector="key_container"> - <strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong> - <dl> + <div + role="rowheader" + class="table-mobile-header gl-align-self-start gl-font-weight-bold gl-text-gray-700" + > + {{ s__('DeployKeys|Deploy key') }} + </div> + <div class="table-mobile-content" data-testid="key-container"> + <p class="title gl-font-weight-semibold gl-text-gray-700" data-testid="key-title-content"> + {{ deployKey.title }} + </p> + <dl class="gl-font-sm gl-mb-0"> <dt>{{ __('SHA256') }}</dt> - <dd class="fingerprint" data-qa-selector="key_sha256_fingerprint_content"> + <dd class="fingerprint" data-testid="key-sha256-fingerprint-content"> {{ deployKey.fingerprint_sha256 }} </dd> <template v-if="deployKey.fingerprint"> <dt> {{ __('MD5') }} </dt> - <dd class="fingerprint" data-qa-selector="key_md5_fingerprint_content"> + <dd class="fingerprint"> {{ deployKey.fingerprint }} </dd> </template> @@ -132,53 +142,62 @@ export default { </div> </div> <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div> - <div class="table-mobile-content deploy-project-list"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold gl-text-gray-700"> + {{ s__('DeployKeys|Project usage') }} + </div> + <div class="table-mobile-content deploy-project-list gl-display-flex gl-flex-wrap"> <template v-if="projects.length > 0"> - <gl-link + <gl-badge v-gl-tooltip :title="projectTooltipTitle(firstProject)" - class="label deploy-project-label" + :icon="firstProject.can_push ? 'lock-open' : 'lock'" + class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate" > - <span> {{ firstProject.project.full_name }} </span> - <gl-icon :name="firstProject.can_push ? 'lock-open' : 'lock'" /> - </gl-link> - <gl-link + <span class="gl-text-truncate">{{ firstProject.project.full_name }}</span> + </gl-badge> + + <gl-badge v-if="isExpandable" v-gl-tooltip :title="restProjectsTooltip" - class="label deploy-project-label" - @click="toggleExpanded" + class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate" + href="#" + @click.native="toggleExpanded" > - <span>{{ restProjectsLabel }}</span> - </gl-link> - <gl-link + <span class="gl-text-truncate">{{ restProjectsLabel }}</span> + </gl-badge> + + <gl-badge v-for="deployKeysProject in restProjects" v-else-if="isExpanded" :key="deployKeysProject.project.full_path" v-gl-tooltip :href="deployKeysProject.project.full_path" :title="projectTooltipTitle(deployKeysProject)" - class="label deploy-project-label" + :icon="deployKeysProject.can_push ? 'lock-open' : 'lock'" + class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate" > - <span> {{ deployKeysProject.project.full_name }} </span> - <gl-icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'" /> - </gl-link> + <span class="gl-text-truncate">{{ deployKeysProject.project.full_name }}</span> + </gl-badge> </template> - <span v-else class="text-secondary">{{ __('None') }}</span> + <span v-else class="gl-text-secondary">{{ __('None') }}</span> </div> </div> <div class="table-section section-15"> - <div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div> - <div class="table-mobile-content text-secondary key-created-at"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold gl-text-gray-700"> + {{ __('Created') }} + </div> + <div class="table-mobile-content gl-text-gray-700 key-created-at"> <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)"> <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span> </span> </div> </div> <div class="table-section section-15"> - <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div> - <div class="table-mobile-content text-secondary key-expires-at"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold gl-text-gray-700"> + {{ __('Expires') }} + </div> + <div class="table-mobile-content gl-text-gray-700 key-expires-at"> <span v-if="deployKey.expires_at" v-gl-tooltip @@ -213,7 +232,7 @@ export default { :deploy-key="deployKey" :title="__('Remove')" :aria-label="__('Remove')" - category="primary" + category="secondary" variant="danger" icon="remove" type="remove" @@ -228,7 +247,7 @@ export default { type="disable" data-container="body" icon="cancel" - category="primary" + category="secondary" variant="danger" /> </div> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index e04cbbe72b9..dac63188aa5 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -28,9 +28,12 @@ export default { </script> <template> - <div class="deploy-keys-panel table-holder"> + <div class="deploy-keys-panel table-holder gl-bg-white gl-rounded-lg"> <template v-if="keys.length > 0"> - <div role="row" class="gl-responsive-table-row table-row-header"> + <div + role="row" + class="gl-responsive-table-row table-row-header gl-font-base gl-font-weight-bold gl-text-gray-900 gl-md-pl-5 gl-md-pr-5 gl-bg-gray-10 gl-border-gray-100!" + > <div role="rowheader" class="table-section section-40"> {{ s__('DeployKeys|Deploy key') }} </div> @@ -50,8 +53,8 @@ export default { :project-id="projectId" /> </template> - <div v-else class="settings-message text-center gl-mt-5"> - {{ s__('DeployKeys|No deploy keys found. Create one with the form above.') }} + <div v-else class="gl-new-card-empty gl-bg-gray-10 gl-text-center gl-p-5"> + {{ s__('DeployKeys|No deploy keys found, start by adding a new one above.') }} </div> </div> </template> 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 7ec3ec3f84d..a56fce98f85 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -8,7 +8,9 @@ import { GlFormInputGroup, GlSprintf, GlLink, + GlAlert, } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; import { createAlert, VARIANT_INFO } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { formatDate } from '~/lib/utils/datetime_utility'; @@ -26,6 +28,8 @@ export default { ClipboardButton, GlSprintf, GlLink, + GlAlert, + MountingPortal, }, props: { @@ -170,12 +174,17 @@ export default { </script> <template> <div> - <div v-if="newTokenDetails" class="created-deploy-token-container info-well"> - <div class="well-segment"> - <h5>{{ $options.translations.newTokenMessage }}</h5> + <mounting-portal append mount-to="#new-deploy-token-alert"> + <gl-alert + v-if="newTokenDetails" + variant="success" + class="gl-mb-4" + @dismiss="newTokenDetails = null" + > + <h5 class="gl-mt-0!">{{ $options.translations.newTokenMessage }}</h5> <gl-form-group> <template #description> - <div class="deploy-token-help-block gl-mt-2 text-success"> + <div class="deploy-token-help-block gl-mt-2"> <gl-sprintf :message="$options.translations.newTokenUsernameDescription" :placeholders="placeholders.link" @@ -200,9 +209,9 @@ export default { </template> </gl-form-input-group> </gl-form-group> - <gl-form-group> + <gl-form-group class="gl-mb-0"> <template #description> - <div class="deploy-token-help-block gl-mt-2 text-danger"> + <div class="deploy-token-help-block gl-mt-2"> <gl-sprintf :message="$options.translations.newTokenDescription" :placeholders="placeholders.i" @@ -222,9 +231,9 @@ export default { </template> </gl-form-input-group> </gl-form-group> - </div> - </div> - <h5>{{ $options.translations.addTokenHeader }}</h5> + </gl-alert> + </mounting-portal> + <h4 class="gl-mt-0">{{ $options.translations.addTokenHeader }}</h4> <p> <gl-sprintf :message="$options.translations.addTokenDescription" @@ -296,6 +305,9 @@ export default { <gl-button variant="confirm" @click="createDeployToken"> {{ $options.translations.addTokenButton }} </gl-button> + <gl-button class="gl-ml-3 js-toggle-button"> + {{ $options.translations.cancelTokenCreation }} + </gl-button> </div> <gl-datepicker v-model="expiresAt" target="#deploy_token_expires_at" container="body" /> </div> diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue index 7879357a042..52d94e65e72 100644 --- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue +++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue @@ -35,9 +35,9 @@ export default { <div> <gl-button v-gl-modal="modalId" - category="primary" + category="secondary" variant="danger" - class="gl-float-right" + size="small" data-testid="revoke-button" >{{ s__('DeployTokens|Revoke') }}</gl-button > diff --git a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js index 410864a83a2..0d3f92b2347 100644 --- a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js +++ b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js @@ -2,6 +2,7 @@ import { s__ } from '~/locale'; const translations = { addTokenButton: s__('DeployTokens|Create deploy token'), + cancelTokenCreation: s__('DeployTokens|Cancel'), addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'), addTokenExpiryDescription: s__( 'DeployTokens|Enter an expiration date for your token. Defaults to never expire.', @@ -23,7 +24,7 @@ const translations = { newTokenDescription: s__( 'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.', ), - newTokenMessage: s__('DeployTokens|Your New Deploy Token'), + newTokenMessage: s__('DeployTokens|Your new deploy token'), newTokenUsernameCopy: s__('DeployTokens|Copy username'), newTokenUsernameDescription: s__( 'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}', diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 6dbf12054cf..4e5e07c57e4 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -136,9 +136,9 @@ export default class Notes { // Reopen and close actions for Issue/MR combined with note form submit this.$wrapperEl.on( 'click', - // this oddly written selector needs to match the old style (input with class) as + // this oddly written selector needs to match the old style (button with class) as // well as the new DOM styling from the Vue-based note form - 'input.js-comment-submit-button, .js-comment-submit-button > button:first-child', + 'button.js-comment-submit-button, .js-comment-submit-button > button:first-child', this.postComment, ); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 1f2c9f19a95..a5b6d6276f8 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -166,7 +166,7 @@ export default { ); }, /** - * Prepare award emoji nodes based on emoji name + * Prepare emoji reaction nodes based on emoji name * and whether the user has toggled the emoji off or on */ getAwardEmojiNodes(name, toggledOn) { @@ -312,7 +312,6 @@ export default { icon="ellipsis_v" category="tertiary" data-qa-selector="design_discussion_actions_ellipsis_dropdown" - data-testid="more-actions-dropdown" text-sr-only :title="$options.i18n.moreActionsLabel" :aria-label="$options.i18n.moreActionsLabel" diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue index fd691d1f04e..5f399573c7e 100644 --- a/app/assets/javascripts/design_management/components/image.vue +++ b/app/assets/javascripts/design_management/components/image.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 8339034fae9..7b98557f4f0 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import { n__, __ } from '~/locale'; diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index cd76b6c1885..741fdee5567 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlIcon, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue index 98b7ab5c094..e03f2668f75 100644 --- a/app/assets/javascripts/design_management/components/upload/button.vue +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 65e04b1ff98..77e2803d092 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert } from '@gitlab/ui'; import { isNull } from 'lodash'; diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index af7c5a25d94..09f99f0927f 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlButton, GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 5149dcc5d17..e3882ce42c2 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import api from '~/api'; @@ -18,16 +19,11 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import { Mousetrap } from '~/lib/mousetrap'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '~/notes/event_hub'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { - TREE_LIST_WIDTH_STORAGE_KEY, - INITIAL_TREE_WIDTH, - MIN_TREE_WIDTH, - TREE_HIDE_STATS_WIDTH, MR_TREE_SHOW_KEY, ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, @@ -56,12 +52,13 @@ import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; import HiddenFilesWarning from './hidden_files_warning.vue'; import NoChanges from './no_changes.vue'; -import TreeList from './tree_list.vue'; import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync'; +import DiffsFileTree from './diffs_file_tree.vue'; export default { name: 'DiffsApp', components: { + DiffsFileTree, FindingsDrawer, DynamicScroller, DynamicScrollerItem, @@ -72,9 +69,7 @@ export default { HiddenFilesWarning, CollapsedFilesWarning, CommitWidget, - TreeList, GlLoadingIcon, - PanelResizer, GlPagination, GlSprintf, GlAlert, @@ -124,11 +119,7 @@ export default { }, }, data() { - const treeWidth = - parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH; - return { - treeWidth, diffFilesLength: 0, virtualScrollCurrentIndex: -1, subscribedToVirtualScrollingEvents: false, @@ -141,7 +132,6 @@ export default { }), ...mapState('findingsDrawer', ['activeDrawer']), ...mapState('diffs', [ - 'showTreeList', 'isLoading', 'diffFiles', 'diffViewType', @@ -194,12 +184,6 @@ export default { diffsIncomplete() { return this.flatBlobsList.length !== this.diffFiles.length; }, - renderFileTree() { - return this.renderDiffFiles && this.showTreeList; - }, - hideFileStats() { - return this.treeWidth <= TREE_HIDE_STATS_WIDTH; - }, isFullChangeset() { return this.startVersion === null && this.latestDiff; }, @@ -273,7 +257,6 @@ export default { this.subscribeToVirtualScrollingEvents(); }, isLoading: 'adjustView', - renderFileTree: 'adjustView', }, mounted() { if (this.endpointCodequality) { @@ -376,7 +359,6 @@ export default { 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', - 'cacheTreeListWidth', 'goToFile', 'setShowTreeList', 'navigateToDiffFileIndex', @@ -590,8 +572,6 @@ export default { window.location.reload(); }, }, - minTreeWidth: MIN_TREE_WIDTH, - maxTreeWidth: window.innerWidth / 2, howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', { anchor: 'checkout-merge-requests-locally-through-the-head-ref', }), @@ -624,22 +604,7 @@ export default { :data-can-create-note="getNoteableData.current_user.can_create_note" class="files d-flex gl-mt-2" > - <div - v-if="renderFileTree" - :style="{ width: `${treeWidth}px` }" - :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }" - class="diff-tree-list js-diff-tree-list gl-px-5" - > - <panel-resizer - :size.sync="treeWidth" - :start-size="treeWidth" - :min-size="$options.minTreeWidth" - :max-size="$options.maxTreeWidth" - side="right" - @resize-end="cacheTreeListWidth" - /> - <tree-list :hide-file-stats="hideFileStats" /> - </div> + <diffs-file-tree :render-diff-files="renderDiffFiles" @toggled="adjustView" /> <div class="col-12 col-md-auto diff-files-holder"> <commit-widget v-if="commit" :commit="commit" :collapsible="false" /> <gl-alert diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue index ebb6ec1e7c8..c8b644e3538 100644 --- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue +++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { EVT_EXPAND_ALL_FILES } from '../constants'; diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 6104a304fbd..bc2376fec09 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import { setUrlParams } from '~/lib/utils/url_utility'; @@ -46,7 +47,9 @@ export default { 'removedLines', ]), toggleFileBrowserTitle() { - return this.showTreeList ? __('Hide file browser') : __('Show file browser'); + return this.showTreeList + ? __('Hide file browser (or press F)') + : __('Show file browser (or press F)'); }, hasChanges() { return this.diffFiles.length > 0; diff --git a/app/assets/javascripts/diffs/components/diff_comment_cell.vue b/app/assets/javascripts/diffs/components/diff_comment_cell.vue index a4fae652d02..3eae6263eca 100644 --- a/app/assets/javascripts/diffs/components/diff_comment_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_comment_cell.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import DiffDiscussionReply from './diff_discussion_reply.vue'; import DiffDiscussions from './diff_discussions.vue'; diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 1c93cb4d021..720d9b6d3bf 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,9 +1,10 @@ <script> import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { sprintf } from '~/locale'; import { createAlert } from '~/alert'; -import { mapParallel, mapParallelNoSast } from 'ee_else_ce/diffs/components/diff_row_utils'; +import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { diffViewerModes } from '~/ide/constants'; @@ -14,7 +15,6 @@ import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_d import NoteForm from '~/notes/components/note_form.vue'; import eventHub from '~/notes/event_hub'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '../i18n'; import { getDiffMode } from '../store/utils'; @@ -36,7 +36,7 @@ export default { UserAvatarLink, DiffFileDrafts, }, - mixins: [diffLineNoteFormMixin, draftCommentsMixin, glFeatureFlagsMixin()], + mixins: [diffLineNoteFormMixin, draftCommentsMixin], props: { diffFile: { type: Object, @@ -92,11 +92,7 @@ export default { return this.getUserData; }, mappedLines() { - if (this.glFeatures.sastReportsInInlineDiff) { - return this.diffLines(this.diffFile).map(mapParallel(this)) || []; - } - - return this.diffLines(this.diffFile).map(mapParallelNoSast(this)) || []; + return this.diffLines(this.diffFile).map(mapParallel(this)) || []; }, imageDiscussions() { return this.diffFile.discussions.filter( diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue index 8b747aa08dd..4de38b09be6 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 9e399a642d0..8915f32eadf 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 53a55aac1ec..54fee000368 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/alert'; diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 4e1ccfc530e..f99edced361 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlLoadingIcon, GlSprintf, GlAlert } from '@gitlab/ui'; import { escape } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { IdState } from 'vendor/vue-virtual-scroller'; @@ -226,6 +227,12 @@ export default { } this.manageViewedEffects(); + + if (this.viewDiffsFileByFile) { + requestIdleCallback(() => { + this.prefetchFileNeighbors(); + }); + } }, beforeDestroy() { eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener); @@ -234,6 +241,7 @@ export default { ...mapActions('diffs', [ 'loadCollapsedDiff', 'assignDiscussionsToDiff', + 'prefetchFileNeighbors', 'setFileCollapsedByUser', 'saveDiffDiscussion', 'toggleFileCommentForm', diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index e336161f952..d62d0e11bff 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -12,6 +12,7 @@ import { GlLoadingIcon, } from '@gitlab/ui'; import { escape } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { IdState } from 'vendor/vue-virtual-scroller'; diff --git a/app/assets/javascripts/diffs/components/diff_inline_findings.vue b/app/assets/javascripts/diffs/components/diff_inline_findings.vue index 1e9a1825d3e..59f92040776 100644 --- a/app/assets/javascripts/diffs/components/diff_inline_findings.vue +++ b/app/assets/javascripts/diffs/components/diff_inline_findings.vue @@ -1,8 +1,8 @@ <script> -import DiffCodeQualityItem from './diff_code_quality_item.vue'; +import DiffInlineFindingsItem from './diff_inline_findings_item.vue'; export default { - components: { DiffCodeQualityItem }, + components: { DiffInlineFindingsItem }, props: { title: { type: String, @@ -22,7 +22,7 @@ export default { {{ title }} </h4> <ul class="gl-list-style-none gl-mb-0 gl-p-0"> - <diff-code-quality-item + <diff-inline-findings-item v-for="finding in findings" :key="finding.description" :finding="finding" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue index 727b2a0c099..5cc2a3079b0 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue +++ b/app/assets/javascripts/diffs/components/diff_inline_findings_item.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { getSeverity } from '~/ci/reports/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -42,7 +43,7 @@ export default { :size="12" :name="enhancedFinding.name" :class="enhancedFinding.class" - class="codequality-severity-icon" + class="inline-findings-severity-icon" /> </span> <span diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue index 40e53438bc8..4867a21493f 100644 --- a/app/assets/javascripts/diffs/components/diff_line.vue +++ b/app/assets/javascripts/diffs/components/diff_line.vue @@ -1,9 +1,9 @@ <script> -import DiffCodeQuality from './diff_code_quality.vue'; +import InlineFindings from './inline_findings.vue'; export default { components: { - DiffCodeQuality, + InlineFindings, }, props: { line: { @@ -15,31 +15,18 @@ export default { parsedCodeQuality() { return (this.line.left ?? this.line.right)?.codequality; }, - parsedSast() { - return (this.line.left ?? this.line.right)?.sast; - }, codeQualityLineNumber() { return this.parsedCodeQuality[0]?.line; }, - sastLineNumber() { - return this.parsedSast[0]?.line; - }, }, methods: { - hideCodeQualityFindings() { - this.$emit( - 'hideCodeQualityFindings', - this.codeQualityLineNumber ? this.codeQualityLineNumber : this.sastLineNumber, - ); + hideInlineFindings() { + this.$emit('hideInlineFindings', this.codeQualityLineNumber); }, }, }; </script> <template> - <diff-code-quality - :code-quality="parsedCodeQuality" - :sast="parsedSast" - @hideCodeQualityFindings="hideCodeQualityFindings" - /> + <inline-findings :code-quality="parsedCodeQuality" @hideInlineFindings="hideInlineFindings" /> </template> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 9a3256beff4..287b2fc1973 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,8 +1,11 @@ <script> +import { nextTick } from 'vue'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { s__, __, sprintf } from '~/locale'; import { createAlert } from '~/alert'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import { clearDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -208,6 +211,9 @@ export default { lineCode: this.line.line_code, fileHash: this.diffFileHash, }); + nextTick(() => { + clearDraft(this.autosaveKey); + }); }), handleSaveNote(note, parentElement, errorCallback) { return this.saveDiffDiscussion({ note, formData: this.formData }) diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 3c9770864fa..318ecc89d14 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -24,7 +24,8 @@ import * as utils from './diff_row_utils'; export default { DiffGutterAvatars, - CodeQualityGutterIcon: () => import('ee_component/diffs/components/code_quality_gutter_icon.vue'), + InlineFindingsGutterIcon: () => + import('ee_component/diffs/components/inline_findings_gutter_icon.vue'), // Temporary mixin for migration from Vue.js 2 to @vue/compat mixins: [compatFunctionalMixin], @@ -79,7 +80,7 @@ export default { type: Function, required: true, }, - codeQualityExpanded: { + inlineFindingsExpanded: { type: Boolean, required: false, default: false, @@ -325,6 +326,7 @@ export default { </div> <div :title="$options.coverageStateLeft(props).text" + :data-tooltip-custom-class="$options.coverageStateLeft(props).class" :class="[ $options.parallelViewLeftLineType(props), $options.coverageStateLeft(props).class, @@ -332,17 +334,16 @@ export default { class="diff-td line-coverage left-side has-tooltip" ></div> <div - class="diff-td line-codequality left-side" + class="diff-td line-inline-findings left-side" :class="$options.parallelViewLeftLineType(props)" > <component - :is="$options.CodeQualityGutterIcon" + :is="$options.InlineFindingsGutterIcon" v-if="$options.showCodequalityLeft(props) || $options.showSecurityLeft(props)" - :code-quality-expanded="props.codeQualityExpanded" + :inline-findings-expanded="props.inlineFindingsExpanded" :codequality="props.line.left.codequality" - :sast="props.line.left.sast" :file-path="props.filePath" - @showCodeQualityFindings=" + @showInlineFindings=" listeners.toggleCodeQualityFindings( props.line.left.codequality[0] ? props.line.left.codequality[0].line @@ -381,7 +382,7 @@ export default { :class="$options.classNameMapCellLeft(props)" ></div> <div - class="diff-td line-codequality left-side empty-cell" + class="diff-td line-inline-findings left-side empty-cell" :class="$options.classNameMapCellLeft(props)" ></div> <div @@ -465,6 +466,7 @@ export default { </div> <div :title="$options.coverageStateRight(props).text" + :data-tooltip-custom-class="$options.coverageStateRight(props).class" :class="[ props.line.right.type, $options.coverageStateRight(props).class, @@ -473,17 +475,16 @@ export default { class="diff-td line-coverage right-side has-tooltip" ></div> <div - class="diff-td line-codequality right-side" + class="diff-td line-inline-findings right-side" :class="$options.classNameMapCellRight(props)" > <component - :is="$options.CodeQualityGutterIcon" + :is="$options.InlineFindingsGutterIcon" v-if="$options.showCodequalityRight(props) || $options.showSecurityRight(props)" :codequality="props.line.right.codequality" - :sast="props.line.right.sast" :file-path="props.filePath" - data-testid="codeQualityIcon" - @showCodeQualityFindings=" + data-testid="inlineFindingsIcon" + @showInlineFindings=" listeners.toggleCodeQualityFindings( props.line.right.codequality[0] ? props.line.right.codequality[0].line @@ -518,7 +519,7 @@ export default { :class="$options.classNameMapCellRight(props)" ></div> <div - class="diff-td line-codequality right-side empty-cell" + class="diff-td line-inline-findings right-side empty-cell" :class="$options.classNameMapCellRight(props)" ></div> <div diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 28834dab3b3..a489c96b0c9 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -189,7 +189,3 @@ export const mapParallel = (content) => (line) => { commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder', }; }; - -export const mapParallelNoSast = (content) => { - return mapParallel(content); -}; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 6bacc6839d8..88ea4e15552 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,9 +1,11 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState, mapActions } from 'vuex'; import { throttle } from 'lodash'; import { IdState } from 'vendor/vue-virtual-scroller'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import { hide } from '~/tooltips'; import { pickDirection } from '../utils/diff_line'; @@ -21,7 +23,11 @@ export default { DiffCommentCell, DraftNote, }, - mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })], + mixins: [ + draftCommentsMixin, + IdState({ idProp: (vm) => vm.diffFile.file_hash }), + glFeatureFlagsMixin(), + ], props: { diffFile: { type: Object, @@ -44,7 +50,7 @@ export default { }, data() { return { - codeQualityExpandedLines: [], + inlineFindingsExpandedLines: [], }; }, idState() { @@ -75,12 +81,15 @@ export default { this.diffLines, ); }, - hasCodequalityChanges() { + hasInlineFindingsChanges() { return ( this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0 || this.sastDiff?.added?.length > 0 ); }, + sastReportsInInlineDiff() { + return this.glFeatures.sastReportsInInlineDiff; + }, }, created() { this.onDragOverThrottled = throttle((line) => this.onDragOver(line), 100, { leading: true }); @@ -100,17 +109,17 @@ export default { } this.idState.dragStart = line; }, - hideCodeQualityFindings(line) { - const index = this.codeQualityExpandedLines.indexOf(line); + hideInlineFindings(line) { + const index = this.inlineFindingsExpandedLines.indexOf(line); if (index > -1) { - this.codeQualityExpandedLines.splice(index, 1); + this.inlineFindingsExpandedLines.splice(index, 1); } }, toggleCodeQualityFindings(line) { - if (!this.codeQualityExpandedLines.includes(line)) { - this.codeQualityExpandedLines.push(line); + if (!this.inlineFindingsExpandedLines.includes(line)) { + this.inlineFindingsExpandedLines.push(line); } else { - this.hideCodeQualityFindings(line); + this.hideInlineFindings(line); } }, onDragOver(line) { @@ -207,7 +216,7 @@ export default { <div :class="[ $options.userColorScheme, - { 'inline-diff-view': inline, 'with-codequality': hasCodequalityChanges }, + { 'inline-diff-view': inline, 'with-inline-findings': hasInlineFindingsChanges }, ]" :data-commit-id="commitId" class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file" @@ -256,7 +265,7 @@ export default { :is-last-highlighted-line="isLastHighlightedLine(line) || index === commentedLines.endLine" :inline="inline" :index="index" - :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))" + :inline-findings-expanded="inlineFindingsExpandedLines.includes(getCodeQualityLine(line))" :file-line-coverage="fileLineCoverage" :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" @@ -270,11 +279,14 @@ export default { @startdragging="onStartDragging" @stopdragging="onStopDragging" /> + <!-- Don't display InlineFindings expanded section when sastReportsInInlineDiff is false --> <diff-line - v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))" + v-if=" + inlineFindingsExpandedLines.includes(getCodeQualityLine(line)) && !sastReportsInInlineDiff + " :key="line.line_code" :line="line" - @hideCodeQualityFindings="hideCodeQualityFindings" + @hideInlineFindings="hideInlineFindings" /> <div v-if="line.renderCommentRow" diff --git a/app/assets/javascripts/diffs/components/diffs_file_tree.vue b/app/assets/javascripts/diffs/components/diffs_file_tree.vue new file mode 100644 index 00000000000..34cd901dd0c --- /dev/null +++ b/app/assets/javascripts/diffs/components/diffs_file_tree.vue @@ -0,0 +1,79 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapState } from 'vuex'; +import { Mousetrap } from '~/lib/mousetrap'; +import { keysFor, MR_TOGGLE_FILE_BROWSER } from '~/behaviors/shortcuts/keybindings'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + INITIAL_TREE_WIDTH, + MIN_TREE_WIDTH, + TREE_HIDE_STATS_WIDTH, + TREE_LIST_WIDTH_STORAGE_KEY, +} from '../constants'; +import TreeList from './tree_list.vue'; + +export default { + name: 'DiffsFileTree', + components: { TreeList, PanelResizer }, + mixins: [glFeatureFlagsMixin()], + minTreeWidth: MIN_TREE_WIDTH, + maxTreeWidth: window.innerWidth / 2, + props: { + renderDiffFiles: { + type: Boolean, + required: true, + }, + }, + data() { + const treeWidth = + parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH; + + return { + treeWidth, + }; + }, + computed: { + ...mapState('diffs', ['showTreeList']), + renderFileTree() { + return this.renderDiffFiles && this.showTreeList; + }, + hideFileStats() { + return this.treeWidth <= TREE_HIDE_STATS_WIDTH; + }, + }, + watch: { + renderFileTree() { + this.$emit('toggled'); + }, + }, + mounted() { + Mousetrap.bind(keysFor(MR_TOGGLE_FILE_BROWSER), this.toggleTreeList); + }, + beforeDestroy() { + Mousetrap.unbind(keysFor(MR_TOGGLE_FILE_BROWSER), this.toggleTreeList); + }, + methods: { + ...mapActions('diffs', ['cacheTreeListWidth', 'toggleTreeList']), + }, +}; +</script> + +<template> + <div + v-if="renderFileTree" + :style="{ width: `${treeWidth}px` }" + :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }" + class="diff-tree-list gl-px-5" + > + <panel-resizer + :size.sync="treeWidth" + :start-size="treeWidth" + :min-size="$options.minTreeWidth" + :max-size="$options.maxTreeWidth" + side="right" + @resize-end="cacheTreeListWidth" + /> + <tree-list :hide-file-stats="hideFileStats" /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index bd040cd1ba1..aafb9bb9d0b 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -1,5 +1,6 @@ <script> import { isArray } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/inline_findings.vue index 4ed54ecdf66..efceedd1141 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/inline_findings.vue @@ -14,18 +14,14 @@ export default { type: Array, required: true, }, - sast: { - type: Array, - required: true, - }, }, }; </script> <template> <div - data-testid="diff-codequality" - class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4" + data-testid="inline-findings" + class="gl-relative inline-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4" > <diff-inline-findings v-if="codeQuality.length" @@ -33,19 +29,13 @@ export default { :findings="codeQuality" /> - <diff-inline-findings - v-if="sast.length" - :title="$options.i18n.newSastFindings" - :findings="sast" - /> - <gl-button - data-testid="diff-codequality-close" + data-testid="inline-findings-close" category="tertiary" size="small" icon="close" class="gl-absolute gl-right-2 gl-top-2" - @click="$emit('hideCodeQualityFindings')" + @click="$emit('hideInlineFindings')" /> </div> </template> diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue index 42af2ab7880..ab5f31a1fb7 100644 --- a/app/assets/javascripts/diffs/components/no_changes.vue +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; export default { diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index a705f29ff65..9744b650d3c 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -6,6 +6,7 @@ import { GlFormCheckbox, GlTooltip, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { SETTINGS_DROPDOWN } from '../i18n'; diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue index 2cffe928d7b..fddd455b17e 100644 --- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -68,7 +68,7 @@ export default { :size="12" :name="severityIcon(drawer.severity)" :class="severityClass(drawer.severity)" - class="codequality-severity-icon" + class="inline-findings-severity-icon" /> {{ drawer.severity }} diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 6f17d70b952..7a661d51c9b 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import micromatch from 'micromatch'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index b9cf26827f2..49f25416585 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js index 5ca9ade668c..4d54dcfa765 100644 --- a/app/assets/javascripts/diffs/mixins/draft_comments.js +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; diff --git a/app/assets/javascripts/diffs/mixins/image_diff.js b/app/assets/javascripts/diffs/mixins/image_diff.js index 9067ea6f8b3..93fb5afbce1 100644 --- a/app/assets/javascripts/diffs/mixins/image_diff.js +++ b/app/assets/javascripts/diffs/mixins/image_diff.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; export default { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 2a557017953..bbc602aedf6 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -113,6 +113,46 @@ export const setBaseConfig = ({ commit }, options) => { }); }; +export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry) => { + const versionPath = state.mergeRequestDiff?.version_path; + + if ( + treeEntry && + !treeEntry.diffLoaded && + !treeEntry.diffLoading && + !getters.getDiffFileByHash(treeEntry.fileHash) + ) { + const urlParams = { + old_path: treeEntry.filePaths.old, + new_path: treeEntry.filePaths.new, + w: state.showWhitespace ? '0' : '1', + view: 'inline', + commit_id: getters.commitId, + }; + + if (versionPath) { + const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath }); + + urlParams.diff_id = diffId; + urlParams.start_sha = startSha; + } + + commit(types.TREE_ENTRY_DIFF_LOADING, { path: treeEntry.filePaths.new }); + + try { + const { data: diffData } = await axios.get( + mergeUrlParams({ ...urlParams }, state.endpointDiffForPath), + ); + + commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files }); + + eventHub.$emit('diffFilesModified'); + } catch (e) { + commit(types.TREE_ENTRY_DIFF_LOADING, { path: treeEntry.filePaths.new, loading: false }); + } + } +}; + export const fetchFileByFile = async ({ state, getters, commit }) => { const isNoteLink = isUrlHashNoteLink(window?.location?.hash); const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId); @@ -304,6 +344,17 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { } }); }; + +export function prefetchFileNeighbors({ getters, dispatch }) { + const { flatBlobsList: allBlobs, currentDiffIndex: currentIndex } = getters; + + const previous = Math.max(currentIndex - 1, 0); + const next = Math.min(allBlobs.length - 1, currentIndex + 1); + + dispatch('prefetchSingleFile', allBlobs[next]); + dispatch('prefetchSingleFile', allBlobs[previous]); +} + export const fetchCoverageFiles = ({ commit, state }) => { const coveragePoll = new Poll({ resource: { @@ -425,7 +476,9 @@ export const showCommentForm = ({ commit }, { lineCode, fileHash }) => { // works. If we focus the comment form on mount and the comment form gets removed and then // added again the page will scroll in unexpected ways setTimeout(() => { - const el = document.querySelector(`[data-line-code="${lineCode}"] textarea`); + const el = document.querySelector( + `[data-line-code="${lineCode}"] textarea, [data-line-code="${lineCode}"] [contenteditable="true"]`, + ); if (!el) return; @@ -643,6 +696,10 @@ export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) => } }; +export const toggleTreeList = ({ state, commit }) => { + commit(types.SET_SHOW_TREE_LIST, !state.showTreeList); +}; + export const openDiffFileCommentForm = ({ commit, getters }, formData) => { const form = getters.getCommentFormForDiffFile(formData.fileHash); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index c32d82faad0..3df491503a4 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -19,6 +19,7 @@ export const RENDER_FILE = 'RENDER_FILE'; export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN'; +export const TREE_ENTRY_DIFF_LOADING = 'TREE_ENTRY_DIFF_LOADING'; export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST'; export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE'; export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index f90e0a24d0e..3af2d6ee6b1 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -266,6 +266,9 @@ export default { [types.TOGGLE_FOLDER_OPEN](state, path) { state.treeEntries[path].opened = !state.treeEntries[path].opened; }, + [types.TREE_ENTRY_DIFF_LOADING](state, { path, loading = true }) { + state.treeEntries[path].diffLoading = loading; + }, [types.SET_SHOW_TREE_LIST](state, showTreeList) { state.showTreeList = showTreeList; }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 97dfd351e67..307c41a98f8 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -593,6 +593,7 @@ export function markTreeEntriesLoaded({ priorEntries, loadedFiles }) { if (entry) { entry.diffLoaded = true; + entry.diffLoading = false; } }); diff --git a/app/assets/javascripts/diffs/utils/tree_worker_utils.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js index 8689809cfa9..e1e3495a51f 100644 --- a/app/assets/javascripts/diffs/utils/tree_worker_utils.js +++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js @@ -1,4 +1,3 @@ -import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; import { TREE_TYPE } from '../constants'; export const getLowestSingleFolder = (folder) => { @@ -28,7 +27,7 @@ export const getLowestSingleFolder = (folder) => { const { path, tree } = getFolder(folder, [folder.name]); return { - path: truncatePathMiddleToLength(path.join('/'), 40), + path: path.join('/'), treeAcc: tree.length ? tree[tree.length - 1].tree : null, }; }; @@ -86,6 +85,7 @@ export const generateTreeList = (files) => { Object.assign(entry, { changed: true, diffLoaded: false, + diffLoading: false, filePaths: { old: file.old_path, new: file.new_path, diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 3a1188d7aab..65091487c93 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -703,6 +703,21 @@ } ] }, + "azure_key_vault": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, "file": { "type": "boolean", "default": true, @@ -713,8 +728,17 @@ "description": "Specifies the JWT variable that should be used to authenticate with Hashicorp Vault." } }, - "required": [ - "vault" + "anyOf": [ + { + "required": [ + "vault" + ] + }, + { + "required": [ + "azure_key_vault" + ] + } ], "additionalProperties": false } @@ -1074,6 +1098,73 @@ } ] }, + "parallel": { + "description": "Splits up a single job into multiple that run in parallel. Provides `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables to the jobs.", + "oneOf": [ + { + "type": "integer", + "description": "Creates N instances of the job that run in parallel.", + "default": 0, + "minimum": 2, + "maximum": 200 + }, + { + "type": "object", + "properties": { + "matrix": { + "type": "array", + "description": "Defines different variables for jobs that are running in parallel.", + "items": { + "type": "object", + "description": "Defines the variables for a specific job.", + "additionalProperties": { + "type": [ + "string", + "number", + "array" + ] + } + }, + "maxItems": 200 + } + }, + "additionalProperties": false, + "required": [ + "matrix" + ] + } + ] + }, + "parallel_matrix": { + "description": "Use the `needs:parallel:matrix` keyword to specify parallelized jobs needed to be completed for the job to run. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#needsparallelmatrix)", + "oneOf": [ + { + "type": "object", + "properties": { + "matrix": { + "type": "array", + "description": "Defines different variables for jobs that are running in parallel.", + "items": { + "type": "object", + "description": "Defines the variables for a specific job.", + "additionalProperties": { + "type": [ + "string", + "number", + "array" + ] + } + }, + "maxItems": 200 + } + }, + "additionalProperties": false, + "required": [ + "matrix" + ] + } + ] + }, "when": { "markdownDescription": "Describes the conditions for when to run the job. Defaults to 'on_success'. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when).", "default": "on_success", @@ -1494,6 +1585,9 @@ }, "optional": { "type": "boolean" + }, + "parallel": { + "$ref": "#/definitions/parallel_matrix" } }, "required": [ @@ -1512,6 +1606,9 @@ }, "artifacts": { "type": "boolean" + }, + "parallel": { + "$ref": "#/definitions/parallel_matrix" } }, "required": [ @@ -1534,6 +1631,9 @@ }, "artifacts": { "type": "boolean" + }, + "parallel": { + "$ref": "#/definitions/parallel_matrix" } }, "required": [ @@ -1747,41 +1847,7 @@ "$ref": "#/definitions/retry" }, "parallel": { - "description": "Parallel will split up a single job into several, and provide `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables for the running jobs.", - "oneOf": [ - { - "type": "integer", - "description": "Creates N instances of the same job that run in parallel.", - "default": 0, - "minimum": 2, - "maximum": 200 - }, - { - "type": "object", - "properties": { - "matrix": { - "type": "array", - "description": "Defines different variables for jobs that are running in parallel.", - "items": { - "type": "object", - "description": "Defines environment variables for specific job.", - "additionalProperties": { - "type": [ - "string", - "number", - "array" - ] - } - }, - "maxItems": 200 - } - }, - "additionalProperties": false, - "required": [ - "matrix" - ] - } - ] + "$ref": "#/definitions/parallel" }, "interruptible": { "$ref": "#/definitions/interruptible" diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js index 931407f4cf7..f3d72c2dba5 100644 --- a/app/assets/javascripts/emoji/awards_app/index.js +++ b/app/assets/javascripts/emoji/awards_app/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; diff --git a/app/assets/javascripts/emoji/awards_app/store/index.js b/app/assets/javascripts/emoji/awards_app/store/index.js index 53ed50f9f5d..71c1071c719 100644 --- a/app/assets/javascripts/emoji/awards_app/store/index.js +++ b/app/assets/javascripts/emoji/awards_app/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue index a72e7df7769..d8607cbc60b 100644 --- a/app/assets/javascripts/emoji/components/category.vue +++ b/app/assets/javascripts/emoji/components/category.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIntersectionObserver } from '@gitlab/ui'; import { humanize } from '~/lib/utils/text_utility'; @@ -55,7 +56,7 @@ export default { /> </template> <p v-else> - {{ s__('AwardEmoji|No emojis found.') }} + {{ s__('AwardEmoji|No emoji found.') }} </p> </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 0e3dd9f7535..fcc54f17466 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; import { findLastIndex } from 'lodash'; diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js index 85c8204225a..e052508fd26 100644 --- a/app/assets/javascripts/emoji/no_emoji_validator.js +++ b/app/assets/javascripts/emoji/no_emoji_validator.js @@ -20,7 +20,7 @@ export default class NoEmojiValidator extends InputValidator { const { value } = this.inputDomElement; - this.errorMessage = __('Invalid input, please avoid emojis'); + this.errorMessage = __('Invalid input, please avoid emoji'); this.validatePattern(value); this.setValidationStateAndMessage(); diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue index 8577bf629a3..9ee716ccbab 100644 --- a/app/assets/javascripts/environments/components/commit.vue +++ b/app/assets/javascripts/environments/components/commit.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAvatar, GlAvatarLink, GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { escape } from 'lodash'; diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index b2844ed5ad6..2186941e00c 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index 01b8208fd55..96d2a8d9ba2 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlBadge, diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index a2405d23924..f90a1dcd193 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -4,7 +4,7 @@ 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 getEnvironmentWithNamespace from '../graphql/queries/environment_with_namespace.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'; @@ -18,8 +18,8 @@ export default { apollo: { environment: { query() { - return this.glFeatures?.kubernetesNamespaceForEnvironment - ? getEnvironmentWithNamespace + return this.glFeatures?.fluxResourceForEnvironment + ? getEnvironmentWithFluxResource : getEnvironment; }, variables() { @@ -60,6 +60,7 @@ export default { externalUrl: this.formEnvironment.externalUrl, clusterAgentId: this.formEnvironment.clusterAgentId, kubernetesNamespace: this.formEnvironment.kubernetesNamespace, + fluxResourcePath: this.formEnvironment.fluxResourcePath, }, }, }); diff --git a/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue b/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue new file mode 100644 index 00000000000..cad6752da94 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue @@ -0,0 +1,210 @@ +<script> +import { GlFormGroup, GlCollapsibleListbox, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import fluxKustomizationsQuery from '../graphql/queries/flux_kustomizations.query.graphql'; +import fluxHelmReleasesQuery from '../graphql/queries/flux_helm_releases.query.graphql'; +import { + HELM_RELEASES_RESOURCE_TYPE, + KUSTOMIZATIONS_RESOURCE_TYPE, + KUSTOMIZATION, + HELM_RELEASE, +} from '../constants'; + +export default { + components: { + GlFormGroup, + GlCollapsibleListbox, + GlAlert, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: true, + type: String, + }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, + }, + i18n: { + fluxResourceLabel: s__('Environments|Select Flux resource (optional)'), + kustomizationsGroupLabel: s__('Environments|Kustomizations'), + helmReleasesGroupLabel: s__('Environments|HelmReleases'), + fluxResourcesHelpText: s__('Environments|Select Flux resource'), + errorTitle: s__( + 'Environments|Unable to access the following resources from this environment. Check your authorization on the following and try again:', + ), + reset: __('Reset'), + }, + data() { + return { + fluxResourceSearchTerm: '', + kustomizationsError: '', + helmReleasesError: '', + }; + }, + apollo: { + fluxKustomizations: { + query: fluxKustomizationsQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + skip() { + return !this.namespace; + }, + update(data) { + return data?.fluxKustomizations || []; + }, + error() { + this.kustomizationsError = KUSTOMIZATION; + }, + result(result) { + if (!result?.error && !result.errors?.length) { + this.kustomizationsError = ''; + } + }, + }, + fluxHelmReleases: { + query: fluxHelmReleasesQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + skip() { + return !this.namespace; + }, + update(data) { + return data?.fluxHelmReleases || []; + }, + error() { + this.helmReleasesError = HELM_RELEASE; + }, + result(result) { + if (!result?.error && !result.errors?.length) { + this.helmReleasesError = ''; + } + }, + }, + }, + computed: { + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + loadingFluxResourcesList() { + return this.$apollo.loading; + }, + kubernetesErrors() { + const errors = []; + if (this.kustomizationsError) { + errors.push(this.kustomizationsError); + } + if (this.helmReleasesError) { + errors.push(this.helmReleasesError); + } + return errors; + }, + fluxResourcesDropdownToggleText() { + const selectedResourceParts = this.fluxResourcePath ? this.fluxResourcePath.split('/') : []; + return selectedResourceParts.length + ? selectedResourceParts.at(-1) + : this.$options.i18n.fluxResourcesHelpText; + }, + fluxKustomizationsList() { + return ( + this.fluxKustomizations?.map((item) => { + return { + value: `${item.apiVersion}/namespaces/${item.metadata.namespace}/${KUSTOMIZATIONS_RESOURCE_TYPE}/${item.metadata.name}`, + text: item.metadata.name, + }; + }) || [] + ); + }, + fluxHelmReleasesList() { + return ( + this.fluxHelmReleases?.map((item) => { + return { + value: `${item.apiVersion}/namespaces/${item.metadata.namespace}/${HELM_RELEASES_RESOURCE_TYPE}/${item.metadata.name}`, + text: item.metadata.name, + }; + }) || [] + ); + }, + filteredKustomizationsList() { + const lowerCasedSearchTerm = this.fluxResourceSearchTerm.toLowerCase(); + return this.fluxKustomizationsList.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + filteredHelmResourcesList() { + const lowerCasedSearchTerm = this.fluxResourceSearchTerm.toLowerCase(); + return this.fluxHelmReleasesList.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + fluxResourcesList() { + const list = []; + if (this.filteredKustomizationsList?.length) { + list.push({ + text: this.$options.i18n.kustomizationsGroupLabel, + options: this.filteredKustomizationsList, + }); + } + + if (this.filteredHelmResourcesList?.length) { + list.push({ + text: this.$options.i18n.helmReleasesGroupLabel, + options: this.filteredHelmResourcesList, + }); + } + return list; + }, + }, + methods: { + onChange(event) { + this.$emit('change', event); + }, + onSearch(search) { + this.fluxResourceSearchTerm = search; + }, + }, +}; +</script> +<template> + <gl-form-group :label="$options.i18n.fluxResourceLabel" label-for="environment_flux_resource"> + <gl-alert v-if="kubernetesErrors.length" variant="warning" :dismissible="false" class="gl-mb-5"> + {{ $options.i18n.errorTitle }} + <ul class="gl-mb-0 gl-pl-6"> + <li v-for="(error, index) of kubernetesErrors" :key="index">{{ error }}</li> + </ul> + </gl-alert> + + <gl-collapsible-listbox + id="environment_flux_resource_path" + class="gl-w-full" + block + :selected="fluxResourcePath" + :items="fluxResourcesList" + :loading="loadingFluxResourcesList" + :toggle-text="fluxResourcesDropdownToggleText" + :header-text="$options.i18n.fluxResourcesHelpText" + :reset-button-label="$options.i18n.reset" + searchable + @search="onSearch" + @select="onChange" + @reset="onChange(null)" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 1bff013b9c2..d89dcf56b7c 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -21,6 +21,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql'; import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql'; +import EnvironmentFluxResourceSelector from './environment_flux_resource_selector.vue'; export default { components: { @@ -32,6 +33,7 @@ export default { GlLink, GlSprintf, GlAlert, + EnvironmentFluxResourceSelector, }, mixins: [glFeatureFlagsMixin()], inject: { @@ -173,15 +175,20 @@ export default { item.text.toLowerCase().includes(lowerCasedSearchTerm), ); }, - isKasKubernetesNamespaceAvailable() { - return this.glFeatures?.kubernetesNamespaceForEnvironment; - }, showNamespaceSelector() { - return Boolean(this.isKasKubernetesNamespaceAvailable && this.selectedAgentId); + return Boolean(this.selectedAgentId); }, namespaceDropdownToggleText() { return this.selectedNamespace || this.$options.i18n.namespaceHelpText; }, + isKasFluxResourceAvailable() { + return this.glFeatures?.fluxResourceForEnvironment; + }, + showFluxResourceSelector() { + return Boolean( + this.isKasFluxResourceAvailable && this.selectedNamespace && this.selectedAgentId, + ); + }, k8sAccessConfiguration() { if (!this.showNamespaceSelector) { return null; @@ -201,6 +208,7 @@ export default { watch: { environment(change) { this.selectedAgentId = change.clusterAgentId; + this.selectedNamespace = change.kubernetesNamespace; }, }, methods: { @@ -229,7 +237,12 @@ export default { }, onAgentChange($event) { this.selectedNamespace = null; - this.onChange({ ...this.environment, clusterAgentId: $event, kubernetesNamespace: null }); + this.onChange({ + ...this.environment, + clusterAgentId: $event, + kubernetesNamespace: null, + fluxResourcePath: null, + }); }, onNamespaceSearch(search) { this.namespaceSearchTerm = search; @@ -348,11 +361,21 @@ export default { :reset-button-label="$options.i18n.reset" :searchable="true" @search="onNamespaceSearch" - @select="onChange({ ...environment, kubernetesNamespace: $event })" + @select=" + onChange({ ...environment, kubernetesNamespace: $event, fluxResourcePath: null }) + " @reset="onChange({ ...environment, kubernetesNamespace: null })" /> </gl-form-group> + <environment-flux-resource-selector + v-if="showFluxResourceSelector" + :namespace="selectedNamespace" + :configuration="k8sAccessConfiguration" + :flux-resource-path="environment.fluxResourcePath" + @change="onChange({ ...environment, fluxResourcePath: $event })" + /> + <div class="gl-mr-6"> <gl-button :loading="loading" diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index a95b5b273f7..795cbf5327a 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -250,7 +250,6 @@ export default { v-if="canSetupReviewApp" v-model="isReviewAppModalVisible" :modal-id="$options.modalId" - data-testid="enable-review-app-modal" /> <stop-stale-environments-modal v-if="canCleanUpEnvs" diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index a1efeaac359..0e52a80c2c5 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -24,11 +24,20 @@ export default { required: true, type: Object, }, + environmentName: { + required: true, + type: String, + }, namespace: { required: false, type: String, default: '', }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, }, data() { return { @@ -96,7 +105,13 @@ export default { </p> <gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4"> <template v-if="isVisible"> - <kubernetes-status-bar :cluster-health-status="clusterHealthStatus" class="gl-mb-4" /> + <kubernetes-status-bar + :cluster-health-status="clusterHealthStatus" + :configuration="k8sAccessConfiguration" + :namespace="namespace" + :environment-name="environmentName" + :flux-resource-path="fluxResourcePath" + class="gl-mb-3" /> <kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-5" /> <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue index 94cd7438e46..e8857dfe459 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -1,12 +1,24 @@ <script> -import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { GlLoadingIcon, GlBadge, GlPopover, GlSprintf, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { HEALTH_BADGES } from '../constants'; +import { + HEALTH_BADGES, + SYNC_STATUS_BADGES, + STATUS_TRUE, + STATUS_FALSE, + HELM_RELEASES_RESOURCE_TYPE, + KUSTOMIZATIONS_RESOURCE_TYPE, +} from '../constants'; +import fluxKustomizationStatusQuery from '../graphql/queries/flux_kustomization_status.query.graphql'; +import fluxHelmReleaseStatusQuery from '../graphql/queries/flux_helm_release_status.query.graphql'; export default { components: { GlLoadingIcon, GlBadge, + GlPopover, + GlSprintf, + GlLink, }, props: { clusterHealthStatus: { @@ -17,23 +29,175 @@ export default { return ['error', 'success', ''].includes(val); }, }, + configuration: { + required: true, + type: Object, + }, + environmentName: { + required: true, + type: String, + }, + namespace: { + required: false, + type: String, + default: '', + }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, + }, + apollo: { + fluxKustomizationStatus: { + query: fluxKustomizationStatusQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + environmentName: this.environmentName.toLowerCase(), + fluxResourcePath: this.fluxResourcePath, + }; + }, + skip() { + return Boolean( + !this.namespace || this.fluxResourcePath?.includes(HELM_RELEASES_RESOURCE_TYPE), + ); + }, + error(err) { + this.fluxApiError = err.message; + }, + }, + fluxHelmReleaseStatus: { + query: fluxHelmReleaseStatusQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + environmentName: this.environmentName.toLowerCase(), + fluxResourcePath: this.fluxResourcePath, + }; + }, + skip() { + return Boolean( + !this.namespace || + this.$apollo.queries.fluxKustomizationStatus.loading || + this.hasKustomizations || + this.fluxResourcePath?.includes(KUSTOMIZATIONS_RESOURCE_TYPE), + ); + }, + error(err) { + this.fluxApiError = err.message; + }, + }, + }, + data() { + return { + fluxApiError: '', + }; }, computed: { healthBadge() { return HEALTH_BADGES[this.clusterHealthStatus]; }, + hasKustomizations() { + return this.fluxKustomizationStatus?.length; + }, + hasHelmReleases() { + return this.fluxHelmReleaseStatus?.length; + }, + isLoading() { + return ( + this.$apollo.queries.fluxKustomizationStatus.loading || + this.$apollo.queries.fluxHelmReleaseStatus.loading + ); + }, + fluxBadgeId() { + return `${this.environmentName}-flux-sync-badge`; + }, + fluxCRD() { + if (!this.hasKustomizations && !this.hasHelmReleases) { + return []; + } + + return this.hasKustomizations ? this.fluxKustomizationStatus : this.fluxHelmReleaseStatus; + }, + fluxAnyStalled() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Stalled'; + }); + }, + fluxAnyReconciling() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Reconciling'; + }); + }, + fluxAnyReconciled() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Ready'; + }); + }, + fluxAnyFailed() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_FALSE && condition.type === 'Ready'; + }); + }, + syncStatusBadge() { + if (!this.fluxCRD.length && this.fluxApiError) { + return { ...SYNC_STATUS_BADGES.unavailable, popoverText: this.fluxApiError }; + } else if (!this.fluxCRD.length) { + return SYNC_STATUS_BADGES.unavailable; + } else if (this.fluxAnyFailed) { + return { ...SYNC_STATUS_BADGES.failed, popoverText: this.fluxAnyFailed.message }; + } else if (this.fluxAnyStalled) { + return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message }; + } else if (this.fluxAnyReconciling) { + return SYNC_STATUS_BADGES.reconciling; + } else if (this.fluxAnyReconciled) { + return SYNC_STATUS_BADGES.reconciled; + } + return SYNC_STATUS_BADGES.unknown; + }, }, i18n: { healthLabel: s__('Environment|Environment health'), + syncStatusLabel: s__('Environment|Sync status'), }, + badgeContainerClasses: 'gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-mr-3 gl-mb-2', }; </script> <template> - <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-mb-2"> - <span class="gl-font-sm gl-font-monospace gl-mr-3">{{ $options.i18n.healthLabel }}</span> - <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline /> - <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant"> - {{ healthBadge.text }} - </gl-badge> + <div class="gl-display-flex gl-flex-wrap"> + <div :class="$options.badgeContainerClasses"> + <span class="gl-mr-3">{{ $options.i18n.healthLabel }}</span> + <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline /> + <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant" data-testid="health-badge"> + {{ healthBadge.text }} + </gl-badge> + </div> + + <div :class="$options.badgeContainerClasses"> + <span class="gl-mr-3">{{ $options.i18n.syncStatusLabel }}</span> + <gl-loading-icon v-if="isLoading" size="sm" inline /> + <template v-else-if="syncStatusBadge"> + <gl-badge + :id="fluxBadgeId" + :icon="syncStatusBadge.icon" + :variant="syncStatusBadge.variant" + data-testid="sync-badge" + tabindex="0" + >{{ syncStatusBadge.text }} + </gl-badge> + <gl-popover :target="fluxBadgeId" :title="syncStatusBadge.popoverTitle"> + <gl-sprintf :message="syncStatusBadge.popoverText"> + <template #link="{ content }"> + <gl-link :href="syncStatusBadge.popoverLink" class="gl-font-sm">{{ + content + }}</gl-link></template + > + </gl-sprintf> + </gl-popover> + </template> + </div> </div> </template> diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue index c6bc94b0b80..6a4ed34989f 100644 --- a/app/assets/javascripts/environments/components/new_environment.vue +++ b/app/assets/javascripts/environments/components/new_environment.vue @@ -35,6 +35,7 @@ export default { projectPath: this.projectPath, clusterAgentId: this.environment.clusterAgentId, kubernetesNamespace: this.environment.kubernetesNamespace, + fluxResourcePath: this.environment.fluxResourcePath, }, }, }); diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index fda1c85f739..2148343f690 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -14,7 +14,7 @@ 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 getEnvironmentClusterAgentWithNamespace from '../graphql/queries/environment_cluster_agent_with_namespace.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'; @@ -83,7 +83,7 @@ export default { tierTooltip: s__('Environment|Deployment tier'), }, data() { - return { visible: false, clusterAgent: null, kubernetesNamespace: '' }; + return { visible: false, clusterAgent: null, kubernetesNamespace: '', fluxResourcePath: '' }; }, computed: { icon() { @@ -165,8 +165,8 @@ export default { rolloutStatus() { return this.environment?.rolloutStatus; }, - isKubernetesNamespaceAvailable() { - return this.glFeatures?.kubernetesNamespaceForEnvironment; + isFluxResourceAvailable() { + return this.glFeatures?.fluxResourceForEnvironment; }, }, methods: { @@ -185,13 +185,14 @@ export default { return { environmentName: this.environment.name, projectFullPath: this.projectPath }; }, query() { - return this.isKubernetesNamespaceAvailable - ? getEnvironmentClusterAgentWithNamespace + return this.isFluxResourceAvailable + ? getEnvironmentClusterAgentWithFluxResource : getEnvironmentClusterAgent; }, update(data) { this.clusterAgent = data?.project?.environment?.clusterAgent; - this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace || ''; + this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace; + this.fluxResourcePath = data?.project?.environment?.fluxResourcePath || ''; }, }); }, @@ -372,7 +373,12 @@ export default { </gl-sprintf> </div> <div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses"> - <kubernetes-overview :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" /> + <kubernetes-overview + :cluster-agent="clusterAgent" + :namespace="kubernetesNamespace" + :flux-resource-path="fluxResourcePath" + :environment-name="environment.name" + /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> <deploy-board-wrapper diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index dc9481a5429..7214454c45c 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -1,5 +1,6 @@ import { __, s__ } from '~/locale'; import { getDateInPast } from '~/lib/utils/datetime_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; // These statuses are based on how the backend defines pod phases here // lib/gitlab/kubernetes/pod.rb @@ -104,6 +105,56 @@ export const HEALTH_BADGES = { }, }; +export const SYNC_STATUS_BADGES = { + reconciled: { + variant: 'success', + icon: 'status_success', + text: s__('Environment|Reconciled'), + popoverText: s__('Deployment|Flux sync reconciled successfully'), + }, + reconciling: { + variant: 'info', + icon: 'status_running', + text: s__('Environment|Reconciling'), + popoverText: s__('Deployment|Flux sync reconciling'), + }, + stalled: { + variant: 'warning', + icon: 'status_pending', + text: s__('Environment|Stalled'), + popoverTitle: s__('Deployment|Flux sync stalled'), + }, + failed: { + variant: 'danger', + icon: 'status_failed', + text: s__('Deployment|Failed'), + popoverTitle: s__('Deployment|Flux sync failed'), + }, + unknown: { + variant: 'neutral', + icon: 'status_notfound', + text: s__('Deployment|Unknown'), + popoverTitle: s__('Deployment|Flux sync status is unknown'), + popoverText: s__( + 'Deployment|Unable to detect state. %{linkStart}How are states detected?%{linkEnd}', + ), + popoverLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/419666#results', + }, + unavailable: { + variant: 'muted', + icon: 'status_notfound', + text: s__('Deployment|Unavailable'), + popoverTitle: s__('Deployment|Flux sync status is unavailable'), + popoverText: s__( + 'Deployment|Sync status is unknown. %{linkStart}How do I configure Flux for my deployment?%{linkEnd}', + ), + popoverLink: helpPagePath('user/clusters/agent/gitops/flux_tutorial'), + }, +}; + +export const STATUS_TRUE = 'True'; +export const STATUS_FALSE = 'False'; + export const PHASE_RUNNING = 'Running'; export const PHASE_PENDING = 'Pending'; export const PHASE_SUCCEEDED = 'Succeeded'; @@ -124,3 +175,16 @@ export const CLUSTER_AGENT_ERROR_MESSAGES = { [ERROR_NOT_FOUND]: s__('Environment|Cluster agent not found.'), [ERROR_OTHER]: s__('Environment|There was an error connecting to the cluster agent.'), }; + +export const CLUSTER_FLUX_RECOURSES_ERROR_MESSAGES = { + [ERROR_UNAUTHORIZED]: s__( + 'Environment|Unauthorized to access %{resourceType} from this environment.', + ), + [ERROR_OTHER]: s__('Environment|There was an error fetching %{resourceType}.'), +}; + +export const HELM_RELEASES_RESOURCE_TYPE = 'helmreleases'; +export const KUSTOMIZATIONS_RESOURCE_TYPE = 'kustomizations'; + +export const KUSTOMIZATION = 'Kustomization'; +export const HELM_RELEASE = 'HelmRelease'; diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue index 787302df60f..686acc22585 100644 --- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue +++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue @@ -107,7 +107,6 @@ export default { v-if="isRollbackAvailable" v-gl-modal.confirm-rollback-modal v-gl-tooltip - data-testid="rollback-button" :title="rollbackButtonTitle" :icon="rollbackIcon" :aria-label="rollbackButtonTitle" diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index ff2dd9935ae..aa836299bcc 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; diff --git a/app/assets/javascripts/environments/environment_details/pagination.vue b/app/assets/javascripts/environments/environment_details/pagination.vue index 414610b306a..f8bacca061b 100644 --- a/app/assets/javascripts/environments/environment_details/pagination.vue +++ b/app/assets/javascripts/environments/environment_details/pagination.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlKeysetPagination } from '@gitlab/ui'; import { setUrlParams } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 553b06e632f..8faed710402 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -9,6 +9,8 @@ import k8sPodsQuery from './queries/k8s_pods.query.graphql'; import k8sServicesQuery from './queries/k8s_services.query.graphql'; import k8sWorkloadsQuery from './queries/k8s_workloads.query.graphql'; import k8sNamespacesQuery from './queries/k8s_namespaces.query.graphql'; +import fluxKustomizationStatusQuery from './queries/flux_kustomization_status.query.graphql'; +import fluxHelmReleaseStatusQuery from './queries/flux_helm_release_status.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -170,6 +172,21 @@ export const apolloProvider = (endpoint) => { }, }, }); + cache.writeQuery({ + query: fluxKustomizationStatusQuery, + data: { + status: '', + type: '', + }, + }); + cache.writeQuery({ + query: fluxHelmReleaseStatusQuery, + data: { + status: '', + type: '', + }, + }); + return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql index 20402e8d32e..53dfe5303f3 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql @@ -5,6 +5,7 @@ query getEnvironment($projectFullPath: ID!, $environmentName: String) { id name externalUrl + kubernetesNamespace 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 760f1fba897..19374ae7a81 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 + kubernetesNamespace clusterAgent { id name diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql index 5e72c2dac20..80363a06d42 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql @@ -1,9 +1,10 @@ -query getEnvironmentClusterAgentWithNamespace($projectFullPath: ID!, $environmentName: String) { +query getEnvironmentClusterAgentWithFluxResource($projectFullPath: ID!, $environmentName: String) { project(fullPath: $projectFullPath) { id environment(name: $environmentName) { id kubernetesNamespace + fluxResourcePath clusterAgent { id name diff --git a/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql index 42796f982b6..166cd64189f 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql @@ -1,4 +1,4 @@ -query getEnvironmentWithNamespace($projectFullPath: ID!, $environmentName: String) { +query getEnvironmentWithFluxResource($projectFullPath: ID!, $environmentName: String) { project(fullPath: $projectFullPath) { id environment(name: $environmentName) { @@ -6,6 +6,7 @@ query getEnvironmentWithNamespace($projectFullPath: ID!, $environmentName: Strin name externalUrl kubernetesNamespace + fluxResourcePath clusterAgent { id name diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql new file mode 100644 index 00000000000..544232dafd7 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql @@ -0,0 +1,17 @@ +query getFluxHelmReleaseStatusQuery( + $configuration: LocalConfiguration + $namespace: String + $environmentName: String + $fluxResourcePath: String +) { + fluxHelmReleaseStatus( + configuration: $configuration + namespace: $namespace + environmentName: $environmentName + fluxResourcePath: $fluxResourcePath + ) @client { + message + status + type + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql new file mode 100644 index 00000000000..fb37aba5adb --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql @@ -0,0 +1,9 @@ +query getFluxHelmReleasesQuery($configuration: LocalConfiguration, $namespace: String) { + fluxHelmReleases(configuration: $configuration, namespace: $namespace) @client { + apiVersion + metadata { + name + namespace + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql new file mode 100644 index 00000000000..2884f95355e --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql @@ -0,0 +1,17 @@ +query getFluxHelmKustomizationStatusQuery( + $configuration: LocalConfiguration + $namespace: String + $environmentName: String + $fluxResourcePath: String +) { + fluxKustomizationStatus( + configuration: $configuration + namespace: $namespace + environmentName: $environmentName + fluxResourcePath: $fluxResourcePath + ) @client { + message + status + type + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql new file mode 100644 index 00000000000..ea7966560c3 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql @@ -0,0 +1,9 @@ +query getFluxKustomizationsQuery($configuration: LocalConfiguration, $namespace: String) { + fluxKustomizations(configuration: $configuration, namespace: $namespace) @client { + apiVersion + metadata { + name + namespace + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 8cfe44c5a05..017e3ccb45b 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,320 +1,14 @@ -import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; -import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; -import { - convertObjectPropsToCamelCase, - parseIntPagination, - normalizeHeaders, -} from '~/lib/utils/common_utils'; -import { humanizeClusterErrors } from '../helpers/k8s_integration_helper'; - -import pollIntervalQuery from './queries/poll_interval.query.graphql'; -import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; -import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; -import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; -import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql'; -import isEnvironmentStoppingQuery from './queries/is_environment_stopping.query.graphql'; -import pageInfoQuery from './queries/page_info.query.graphql'; - -const buildErrors = (errors = []) => ({ - errors, - __typename: 'LocalEnvironmentErrors', -}); - -const mapNestedEnvironment = (env) => ({ - ...convertObjectPropsToCamelCase(env, { deep: true }), - __typename: 'NestedLocalEnvironment', -}); -const mapEnvironment = (env) => ({ - ...convertObjectPropsToCamelCase(env, { deep: true }), - __typename: 'LocalEnvironment', -}); - -const mapWorkloadItems = (items, kind) => { - return items.map((item) => { - const updatedItem = { - status: {}, - spec: {}, - }; - - switch (kind) { - case 'DeploymentList': - updatedItem.status.conditions = item.status.conditions || []; - break; - case 'DaemonSetList': - updatedItem.status = { - numberMisscheduled: item.status.numberMisscheduled || 0, - numberReady: item.status.numberReady || 0, - desiredNumberScheduled: item.status.desiredNumberScheduled || 0, - }; - break; - case 'StatefulSetList': - case 'ReplicaSetList': - updatedItem.status.readyReplicas = item.status.readyReplicas || 0; - updatedItem.spec.replicas = item.spec.replicas || 0; - break; - case 'JobList': - updatedItem.status.failed = item.status.failed || 0; - updatedItem.status.succeeded = item.status.succeeded || 0; - updatedItem.spec.completions = item.spec.completions || 0; - break; - case 'CronJobList': - updatedItem.status.active = item.status.active || 0; - updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || ''; - updatedItem.spec.suspend = item.spec.suspend || 0; - break; - default: - updatedItem.status = item?.status; - updatedItem.spec = item?.spec; - break; - } - - return updatedItem; - }); -}; - -const handleClusterError = (err) => { - const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; - throw error; -}; +import { baseQueries, baseMutations } from './resolvers/base'; +import kubernetesQueries from './resolvers/kubernetes'; +import fluxQueries from './resolvers/flux'; export const resolvers = (endpoint) => ({ Query: { - environmentApp(_context, { page, scope, search }, { cache }) { - return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => { - const headers = normalizeHeaders(res.headers); - const interval = headers['POLL-INTERVAL']; - const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; - - if (interval) { - cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); - } else { - cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); - } - - cache.writeQuery({ - query: pageInfoQuery, - data: { pageInfo }, - }); - - return { - availableCount: res.data.available_count, - environments: res.data.environments.map(mapNestedEnvironment), - reviewApp: { - ...convertObjectPropsToCamelCase(res.data.review_app), - __typename: 'ReviewApp', - }, - canStopStaleEnvironments: res.data.can_stop_stale_environments, - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentApp', - }; - }); - }, - folder(_, { environment: { folderPath }, scope, search }) { - return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ - availableCount: res.data.available_count, - environments: res.data.environments.map(mapEnvironment), - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentFolder', - })); - }, - isLastDeployment(_, { environment }) { - return environment?.lastDeployment?.isLast; - }, - k8sPods(_, { configuration, namespace }) { - const coreV1Api = new CoreV1Api(new Configuration(configuration)); - const podsApi = namespace - ? coreV1Api.listCoreV1NamespacedPod(namespace) - : coreV1Api.listCoreV1PodForAllNamespaces(); - - return podsApi - .then((res) => res?.data?.items || []) - .catch((err) => { - handleClusterError(err); - }); - }, - k8sServices(_, { configuration }) { - const coreV1Api = new CoreV1Api(new Configuration(configuration)); - return coreV1Api - .listCoreV1ServiceForAllNamespaces() - .then((res) => { - const items = res?.data?.items || []; - return items.map((item) => { - const { type, clusterIP, externalIP, ports } = item.spec; - return { - metadata: item.metadata, - spec: { - type, - clusterIP: clusterIP || '-', - externalIP: externalIP || '-', - ports, - }, - }; - }); - }) - .catch((err) => { - handleClusterError(err); - }); - }, - k8sWorkloads(_, { configuration, namespace }) { - const appsV1api = new AppsV1Api(configuration); - const batchV1api = new BatchV1Api(configuration); - - let promises; - - if (namespace) { - promises = [ - appsV1api.listAppsV1NamespacedDeployment(namespace), - appsV1api.listAppsV1NamespacedDaemonSet(namespace), - appsV1api.listAppsV1NamespacedStatefulSet(namespace), - appsV1api.listAppsV1NamespacedReplicaSet(namespace), - batchV1api.listBatchV1NamespacedJob(namespace), - batchV1api.listBatchV1NamespacedCronJob(namespace), - ]; - } else { - promises = [ - appsV1api.listAppsV1DeploymentForAllNamespaces(), - appsV1api.listAppsV1DaemonSetForAllNamespaces(), - appsV1api.listAppsV1StatefulSetForAllNamespaces(), - appsV1api.listAppsV1ReplicaSetForAllNamespaces(), - batchV1api.listBatchV1JobForAllNamespaces(), - batchV1api.listBatchV1CronJobForAllNamespaces(), - ]; - } - - const summaryList = { - DeploymentList: [], - DaemonSetList: [], - StatefulSetList: [], - ReplicaSetList: [], - JobList: [], - CronJobList: [], - }; - - return Promise.allSettled(promises).then((results) => { - if (results.every((res) => res.status === 'rejected')) { - const error = results[0].reason; - const errorMessage = error?.response?.data?.message ?? error; - throw new Error(errorMessage); - } - for (const promiseResult of results) { - if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) { - const { kind, items } = promiseResult.value.data; - - if (items?.length > 0) { - summaryList[kind] = mapWorkloadItems(items, kind); - } - } - } - - return summaryList; - }); - }, - k8sNamespaces(_, { configuration }) { - const coreV1Api = new CoreV1Api(new Configuration(configuration)); - const namespacesApi = coreV1Api.listCoreV1Namespace(); - - return namespacesApi - .then((res) => { - return res?.data?.items || []; - }) - .catch((err) => { - const error = err?.response?.data?.reason || err; - throw new Error(humanizeClusterErrors(error)); - }); - }, + ...baseQueries(endpoint), + ...kubernetesQueries, + ...fluxQueries, }, Mutation: { - stopEnvironmentREST(_, { environment }, { client }) { - client.writeQuery({ - query: isEnvironmentStoppingQuery, - variables: { environment }, - data: { isEnvironmentStopping: true }, - }); - return axios - .post(environment.stopPath) - .then(() => buildErrors()) - .catch(() => { - client.writeQuery({ - query: isEnvironmentStoppingQuery, - variables: { environment }, - data: { isEnvironmentStopping: false }, - }); - return buildErrors([ - s__('Environments|An error occurred while stopping the environment, please try again'), - ]); - }); - }, - deleteEnvironment(_, { environment: { deletePath } }) { - return axios - .delete(deletePath) - .then(() => buildErrors()) - .catch(() => - buildErrors([ - s__( - 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', - ), - ]), - ); - }, - rollbackEnvironment(_, { environment, isLastDeployment }) { - return axios - .post(environment?.retryUrl) - .then(() => buildErrors()) - .catch(() => { - buildErrors([ - isLastDeployment - ? s__( - 'Environments|An error occurred while re-deploying the environment, please try again', - ) - : s__( - 'Environments|An error occurred while rolling back the environment, please try again', - ), - ]); - }); - }, - setEnvironmentToStop(_, { environment }, { client }) { - client.writeQuery({ - query: environmentToStopQuery, - data: { environmentToStop: environment }, - }); - }, - action(_, { action: { playPath } }) { - return axios - .post(playPath) - .then(() => buildErrors()) - .catch(() => - buildErrors([s__('Environments|An error occurred while making the request.')]), - ); - }, - setEnvironmentToDelete(_, { environment }, { client }) { - client.writeQuery({ - query: environmentToDeleteQuery, - data: { environmentToDelete: environment }, - }); - }, - setEnvironmentToRollback(_, { environment }, { client }) { - client.writeQuery({ - query: environmentToRollbackQuery, - data: { environmentToRollback: environment }, - }); - }, - setEnvironmentToChangeCanary(_, { environment, weight }, { client }) { - client.writeQuery({ - query: environmentToChangeCanaryQuery, - data: { environmentToChangeCanary: environment, weight }, - }); - }, - cancelAutoStop(_, { autoStopUrl }) { - return axios - .post(autoStopUrl) - .then(() => buildErrors()) - .catch((err) => - buildErrors([ - err?.response?.data?.message || - s__('Environments|An error occurred while canceling the auto stop, please try again'), - ]), - ); - }, + ...baseMutations, }, }); diff --git a/app/assets/javascripts/environments/graphql/resolvers/base.js b/app/assets/javascripts/environments/graphql/resolvers/base.js new file mode 100644 index 00000000000..9752a3a6634 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers/base.js @@ -0,0 +1,165 @@ +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { + convertObjectPropsToCamelCase, + parseIntPagination, + normalizeHeaders, +} from '~/lib/utils/common_utils'; + +import pollIntervalQuery from '../queries/poll_interval.query.graphql'; +import environmentToRollbackQuery from '../queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from '../queries/environment_to_stop.query.graphql'; +import environmentToDeleteQuery from '../queries/environment_to_delete.query.graphql'; +import environmentToChangeCanaryQuery from '../queries/environment_to_change_canary.query.graphql'; +import isEnvironmentStoppingQuery from '../queries/is_environment_stopping.query.graphql'; +import pageInfoQuery from '../queries/page_info.query.graphql'; + +const buildErrors = (errors = []) => ({ + errors, + __typename: 'LocalEnvironmentErrors', +}); + +const mapNestedEnvironment = (env) => ({ + ...convertObjectPropsToCamelCase(env, { deep: true }), + __typename: 'NestedLocalEnvironment', +}); +const mapEnvironment = (env) => ({ + ...convertObjectPropsToCamelCase(env, { deep: true }), + __typename: 'LocalEnvironment', +}); + +export const baseQueries = (endpoint) => ({ + environmentApp(_context, { page, scope, search }, { cache }) { + return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => { + const headers = normalizeHeaders(res.headers); + const interval = headers['POLL-INTERVAL']; + const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; + + if (interval) { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); + } else { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); + } + + cache.writeQuery({ + query: pageInfoQuery, + data: { pageInfo }, + }); + + return { + availableCount: res.data.available_count, + environments: res.data.environments.map(mapNestedEnvironment), + reviewApp: { + ...convertObjectPropsToCamelCase(res.data.review_app), + __typename: 'ReviewApp', + }, + canStopStaleEnvironments: res.data.can_stop_stale_environments, + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentApp', + }; + }); + }, + folder(_, { environment: { folderPath }, scope, search }) { + return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ + availableCount: res.data.available_count, + environments: res.data.environments.map(mapEnvironment), + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentFolder', + })); + }, + isLastDeployment(_, { environment }) { + return environment?.lastDeployment?.isLast; + }, +}); + +export const baseMutations = { + stopEnvironmentREST(_, { environment }, { client }) { + client.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment }, + data: { isEnvironmentStopping: true }, + }); + return axios + .post(environment.stopPath) + .then(() => buildErrors()) + .catch(() => { + client.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment }, + data: { isEnvironmentStopping: false }, + }); + return buildErrors([ + s__('Environments|An error occurred while stopping the environment, please try again'), + ]); + }); + }, + deleteEnvironment(_, { environment: { deletePath } }) { + return axios + .delete(deletePath) + .then(() => buildErrors()) + .catch(() => + buildErrors([ + s__( + 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', + ), + ]), + ); + }, + rollbackEnvironment(_, { environment, isLastDeployment }) { + return axios + .post(environment?.retryUrl) + .then(() => buildErrors()) + .catch(() => { + buildErrors([ + isLastDeployment + ? s__( + 'Environments|An error occurred while re-deploying the environment, please try again', + ) + : s__( + 'Environments|An error occurred while rolling back the environment, please try again', + ), + ]); + }); + }, + setEnvironmentToStop(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToStopQuery, + data: { environmentToStop: environment }, + }); + }, + action(_, { action: { playPath } }) { + return axios + .post(playPath) + .then(() => buildErrors()) + .catch(() => buildErrors([s__('Environments|An error occurred while making the request.')])); + }, + setEnvironmentToDelete(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToDeleteQuery, + data: { environmentToDelete: environment }, + }); + }, + setEnvironmentToRollback(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToRollbackQuery, + data: { environmentToRollback: environment }, + }); + }, + setEnvironmentToChangeCanary(_, { environment, weight }, { client }) { + client.writeQuery({ + query: environmentToChangeCanaryQuery, + data: { environmentToChangeCanary: environment, weight }, + }); + }, + cancelAutoStop(_, { autoStopUrl }) { + return axios + .post(autoStopUrl) + .then(() => buildErrors()) + .catch((err) => + buildErrors([ + err?.response?.data?.message || + s__('Environments|An error occurred while canceling the auto stop, please try again'), + ]), + ); + }, +}; diff --git a/app/assets/javascripts/environments/graphql/resolvers/flux.js b/app/assets/javascripts/environments/graphql/resolvers/flux.js new file mode 100644 index 00000000000..f9ca35a3165 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers/flux.js @@ -0,0 +1,115 @@ +import axios from '~/lib/utils/axios_utils'; +import { + HELM_RELEASES_RESOURCE_TYPE, + KUSTOMIZATIONS_RESOURCE_TYPE, +} from '~/environments/constants'; + +const helmReleasesApiVersion = 'helm.toolkit.fluxcd.io/v2beta1'; +const kustomizationsApiVersion = 'kustomize.toolkit.fluxcd.io/v1beta1'; + +const handleClusterError = (err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; +}; + +const buildFluxResourceUrl = ({ + basePath, + namespace, + apiVersion, + resourceType, + environmentName = '', +}) => { + return `${basePath}/apis/${apiVersion}/namespaces/${namespace}/${resourceType}/${environmentName}`; +}; + +const getFluxResourceStatus = (configuration, url) => { + const { headers } = configuration.baseOptions; + const withCredentials = true; + + return axios + .get(url, { withCredentials, headers }) + .then((res) => { + return res?.data?.status?.conditions || []; + }) + .catch((err) => { + handleClusterError(err); + }); +}; + +const getFluxResources = (configuration, url) => { + const { headers } = configuration.baseOptions; + const withCredentials = true; + + return axios + .get(url, { withCredentials, headers }) + .then((res) => { + const items = res?.data?.items || []; + const result = items.map((item) => { + return { + apiVersion: item.apiVersion, + metadata: { + name: item.metadata?.name, + namespace: item.metadata?.namespace, + }, + }; + }); + return result || []; + }) + .catch((err) => { + const error = err?.response?.data?.reason || err; + throw new Error(error); + }); +}; + +export default { + fluxKustomizationStatus(_, { configuration, namespace, environmentName, fluxResourcePath = '' }) { + let url; + + if (fluxResourcePath) { + url = `${configuration.basePath}/apis/${fluxResourcePath}`; + } else { + url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: KUSTOMIZATIONS_RESOURCE_TYPE, + apiVersion: kustomizationsApiVersion, + namespace, + environmentName, + }); + } + return getFluxResourceStatus(configuration, url); + }, + fluxHelmReleaseStatus(_, { configuration, namespace, environmentName, fluxResourcePath }) { + let url; + + if (fluxResourcePath) { + url = `${configuration.basePath}/apis/${fluxResourcePath}`; + } else { + url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: HELM_RELEASES_RESOURCE_TYPE, + apiVersion: helmReleasesApiVersion, + namespace, + environmentName, + }); + } + return getFluxResourceStatus(configuration, url); + }, + fluxKustomizations(_, { configuration, namespace }) { + const url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: KUSTOMIZATIONS_RESOURCE_TYPE, + apiVersion: kustomizationsApiVersion, + namespace, + }); + return getFluxResources(configuration, url); + }, + fluxHelmReleases(_, { configuration, namespace }) { + const url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: HELM_RELEASES_RESOURCE_TYPE, + apiVersion: helmReleasesApiVersion, + namespace, + }); + return getFluxResources(configuration, url); + }, +}; diff --git a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js new file mode 100644 index 00000000000..9ab65d0bb7f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js @@ -0,0 +1,155 @@ +import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; +import { humanizeClusterErrors } from '../../helpers/k8s_integration_helper'; + +const mapWorkloadItems = (items, kind) => { + return items.map((item) => { + const updatedItem = { + status: {}, + spec: {}, + }; + + switch (kind) { + case 'DeploymentList': + updatedItem.status.conditions = item.status.conditions || []; + break; + case 'DaemonSetList': + updatedItem.status = { + numberMisscheduled: item.status.numberMisscheduled || 0, + numberReady: item.status.numberReady || 0, + desiredNumberScheduled: item.status.desiredNumberScheduled || 0, + }; + break; + case 'StatefulSetList': + case 'ReplicaSetList': + updatedItem.status.readyReplicas = item.status.readyReplicas || 0; + updatedItem.spec.replicas = item.spec.replicas || 0; + break; + case 'JobList': + updatedItem.status.failed = item.status.failed || 0; + updatedItem.status.succeeded = item.status.succeeded || 0; + updatedItem.spec.completions = item.spec.completions || 0; + break; + case 'CronJobList': + updatedItem.status.active = item.status.active || 0; + updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || ''; + updatedItem.spec.suspend = item.spec.suspend || 0; + break; + default: + updatedItem.status = item?.status; + updatedItem.spec = item?.spec; + break; + } + + return updatedItem; + }); +}; + +const handleClusterError = (err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; +}; + +export default { + k8sPods(_, { configuration, namespace }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const podsApi = namespace + ? coreV1Api.listCoreV1NamespacedPod(namespace) + : coreV1Api.listCoreV1PodForAllNamespaces(); + + return podsApi + .then((res) => res?.data?.items || []) + .catch((err) => { + handleClusterError(err); + }); + }, + k8sServices(_, { configuration }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + return coreV1Api + .listCoreV1ServiceForAllNamespaces() + .then((res) => { + const items = res?.data?.items || []; + return items.map((item) => { + const { type, clusterIP, externalIP, ports } = item.spec; + return { + metadata: item.metadata, + spec: { + type, + clusterIP: clusterIP || '-', + externalIP: externalIP || '-', + ports, + }, + }; + }); + }) + .catch((err) => { + handleClusterError(err); + }); + }, + k8sWorkloads(_, { configuration, namespace }) { + const appsV1api = new AppsV1Api(configuration); + const batchV1api = new BatchV1Api(configuration); + + let promises; + + if (namespace) { + promises = [ + appsV1api.listAppsV1NamespacedDeployment(namespace), + appsV1api.listAppsV1NamespacedDaemonSet(namespace), + appsV1api.listAppsV1NamespacedStatefulSet(namespace), + appsV1api.listAppsV1NamespacedReplicaSet(namespace), + batchV1api.listBatchV1NamespacedJob(namespace), + batchV1api.listBatchV1NamespacedCronJob(namespace), + ]; + } else { + promises = [ + appsV1api.listAppsV1DeploymentForAllNamespaces(), + appsV1api.listAppsV1DaemonSetForAllNamespaces(), + appsV1api.listAppsV1StatefulSetForAllNamespaces(), + appsV1api.listAppsV1ReplicaSetForAllNamespaces(), + batchV1api.listBatchV1JobForAllNamespaces(), + batchV1api.listBatchV1CronJobForAllNamespaces(), + ]; + } + + const summaryList = { + DeploymentList: [], + DaemonSetList: [], + StatefulSetList: [], + ReplicaSetList: [], + JobList: [], + CronJobList: [], + }; + + return Promise.allSettled(promises).then((results) => { + if (results.every((res) => res.status === 'rejected')) { + const error = results[0].reason; + const errorMessage = error?.response?.data?.message ?? error; + throw new Error(errorMessage); + } + for (const promiseResult of results) { + if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) { + const { kind, items } = promiseResult.value.data; + + if (items?.length > 0) { + summaryList[kind] = mapWorkloadItems(items, kind); + } + } + } + + return summaryList; + }); + }, + k8sNamespaces(_, { configuration }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const namespacesApi = coreV1Api.listCoreV1Namespace(); + + return namespacesApi + .then((res) => { + return res?.data?.items || []; + }) + .catch((err) => { + const error = err?.response?.data?.reason || err; + throw new Error(humanizeClusterErrors(error)); + }); + }, +}; diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index e2c22dda554..41f165ad1da 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -167,6 +167,11 @@ type LocalK8sNamespaces { metadata: k8sNamespaceMetadata } +type LocalFluxResourceStatus { + status: String + type: String +} + extend type Query { environmentApp(page: Int, scope: String): LocalEnvironmentApp folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder @@ -179,6 +184,16 @@ extend type Query { k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods] k8sServices(configuration: LocalConfiguration): [LocalK8sServices] k8sWorkloads(configuration: LocalConfiguration, namespace: String): LocalK8sWorkloads + fluxKustomizationStatus( + configuration: LocalConfiguration + namespace: String + environmentName: String + ): LocalFluxResourceStatus + fluxHelmReleaseStatus( + configuration: LocalConfiguration + namespace: String + environmentName: String + ): LocalFluxResourceStatus } extend type Mutation { diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index e49f1451759..164a2d98e90 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -142,7 +142,7 @@ export function getCronJobsStatuses(items) { } export function humanizeClusterErrors(reason) { - const errorReason = reason.toLowerCase(); + const errorReason = String(reason).toLowerCase(); const errorMessage = CLUSTER_AGENT_ERROR_MESSAGES[errorReason]; return errorMessage || CLUSTER_AGENT_ERROR_MESSAGES.other; } diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 3f746bc5383..0e0ea018618 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,11 +1,13 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import { parseBoolean } from '../lib/utils/common_utils'; import { apolloProvider } from './graphql/client'; import EnvironmentsApp from './components/environments_app.vue'; Vue.use(VueApollo); +Vue.use(GlToast); export default (el) => { if (el) { diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index bd8a7257d0c..1821aa073bc 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -8,6 +8,7 @@ import { GlSprintf, GlDisclosureDropdown, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_WARNING } from '~/alert'; import { __, sprintf, n__ } from '~/locale'; diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 0c9a98f3b33..9b30ec4afbb 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -16,6 +16,7 @@ import { GlPagination, } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; import AccessorUtils from '~/lib/utils/accessor'; diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue index 54b9d37be73..0e34e7ebda7 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import StackTraceEntry from './stacktrace_entry.vue'; diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index 1d8f1998583..b62618ca02f 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 3bc91a2adbf..b59bf88a140 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -10,6 +10,7 @@ import { GlLink, GlSprintf, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index da942dbd0ae..2f4d7c48cf2 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -1,5 +1,6 @@ <script> import { GlFormInput, GlIcon, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; export default { diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js index 1cd6d119657..2362cfb741f 100644 --- a/app/assets/javascripts/error_tracking_settings/store/index.js +++ b/app/assets/javascripts/error_tracking_settings/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue index 2bdc95e798c..63f61df7d01 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { sprintf, __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; diff --git a/app/assets/javascripts/feature_flags/components/empty_state.vue b/app/assets/javascripts/feature_flags/components/empty_state.vue index a66215cdae6..60aeb297700 100644 --- a/app/assets/javascripts/feature_flags/components/empty_state.vue +++ b/app/assets/javascripts/feature_flags/components/empty_state.vue @@ -81,6 +81,7 @@ export default { v-else-if="emptyState" :title="emptyTitle" :svg-path="errorStateSvgPath" + :svg-height="150" data-testid="empty-state" > <template #description> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 34e0b94af3b..daaeb5f8e85 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -1,6 +1,7 @@ <script> import { GlAlert, GlBadge, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils'; @@ -154,7 +155,6 @@ export default { variant="confirm" category="tertiary" class="gl-mb-3" - data-testid="ff-new-list-button" > {{ s__('FeatureFlags|View user lists') }} </gl-button> @@ -183,10 +183,7 @@ export default { class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" > <div class="gl-display-flex gl-align-items-center"> - <h2 - data-testid="feature-flags-tab-title" - class="page-title gl-font-size-h-display gl-my-0" - > + <h2 class="page-title gl-font-size-h-display gl-my-0"> {{ s__('FeatureFlags|Feature flags') }} </h2> <gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge> @@ -240,7 +237,6 @@ export default { 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', ) " - data-testid="feature-flags-tab" @dismissAlert="clearAlert" > <feature-flags-table :feature-flags="featureFlags" @toggle-flag="toggleFeatureFlag" /> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 35abcc3d561..7c32d41a2bb 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton } from '@gitlab/ui'; import { memoize, cloneDeep, isNumber, uniqueId } from 'lodash'; diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue index bc05e88e643..fa9c9d40c91 100644 --- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue @@ -1,5 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ROLLOUT_STRATEGY_ALL_USERS } from '../constants'; diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue index 04190d7bfda..73c32e52a56 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/default.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> export default { mounted() { diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue index 53745d3b021..68170bafeeb 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -1,6 +1,7 @@ <script> import { GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { createNamespacedHelpers } from 'vuex'; import { s__ } from '~/locale'; import ParameterFormGroup from './parameter_form_group.vue'; diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index 6c8a2d90209..4594719a247 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui'; import { isNumber } from 'lodash'; diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js index 55dad87ea5b..6aca9d1d124 100644 --- a/app/assets/javascripts/feature_flags/edit.js +++ b/app/assets/javascripts/feature_flags/edit.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import EditFeatureFlag from './components/edit_feature_flag.vue'; import createStore from './store/edit'; diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js index 5c0d9cb8624..b0f57006b7f 100644 --- a/app/assets/javascripts/feature_flags/index.js +++ b/app/assets/javascripts/feature_flags/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import csrf from '~/lib/utils/csrf'; import FeatureFlagsComponent from './components/feature_flags.vue'; diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js index f763b12fedb..c36fd7ab591 100644 --- a/app/assets/javascripts/feature_flags/new.js +++ b/app/assets/javascripts/feature_flags/new.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import NewFeatureFlag from './components/new_feature_flag.vue'; diff --git a/app/assets/javascripts/feature_flags/store/edit/index.js b/app/assets/javascripts/feature_flags/store/edit/index.js index 16b8a5ae970..6550c8a922c 100644 --- a/app/assets/javascripts/feature_flags/store/edit/index.js +++ b/app/assets/javascripts/feature_flags/store/edit/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import userLists from '../gitlab_user_list'; import * as actions from './actions'; diff --git a/app/assets/javascripts/feature_flags/store/index/index.js b/app/assets/javascripts/feature_flags/store/index/index.js index 96ccb35fa21..183305d1147 100644 --- a/app/assets/javascripts/feature_flags/store/index/index.js +++ b/app/assets/javascripts/feature_flags/store/index/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/feature_flags/store/new/index.js b/app/assets/javascripts/feature_flags/store/new/index.js index 16b8a5ae970..6550c8a922c 100644 --- a/app/assets/javascripts/feature_flags/store/new/index.js +++ b/app/assets/javascripts/feature_flags/store/new/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import userLists from '../gitlab_user_list'; import * as actions from './actions'; diff --git a/app/assets/javascripts/forks/components/forks_button.vue b/app/assets/javascripts/forks/components/forks_button.vue new file mode 100644 index 00000000000..40cf74ff4cc --- /dev/null +++ b/app/assets/javascripts/forks/components/forks_button.vue @@ -0,0 +1,86 @@ +<script> +import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButtonGroup, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + forksCount: { + default: 0, + }, + projectFullPath: { + default: '', + }, + projectForksUrl: { + default: '', + }, + userForkUrl: { + default: '', + }, + newForkUrl: { + default: '', + }, + canReadCode: { + default: false, + }, + canCreateFork: { + default: false, + }, + canForkProject: { + default: false, + }, + }, + computed: { + forkButtonUrl() { + return this.userForkUrl || this.newForkUrl; + }, + userHasForkAccess() { + return Boolean(this.userForkUrl) && this.canReadCode; + }, + userCanFork() { + return this.canReadCode && this.canCreateFork && this.canForkProject; + }, + forkButtonEnabled() { + return this.userHasForkAccess || this.userCanFork; + }, + forkButtonTooltip() { + if (!this.canForkProject) { + return s__("ProjectOverview|You don't have permission to fork this project"); + } + + if (!this.canCreateFork) { + return s__('ProjectOverview|You have reached your project limit'); + } + + if (this.userHasForkAccess) { + return s__('ProjectOverview|Go to your fork'); + } + + return s__('ProjectOverview|Create new fork'); + }, + }, +}; +</script> + +<template> + <gl-button-group :vertical="false"> + <gl-button + v-gl-tooltip + data-testid="fork-button" + :disabled="!forkButtonEnabled" + :href="forkButtonUrl" + icon="fork" + :title="forkButtonTooltip" + >{{ s__('ProjectOverview|Forks') }}</gl-button + > + <gl-button data-testid="forks-count" :disabled="!canReadCode" :href="projectForksUrl">{{ + forksCount + }}</gl-button> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/forks/init_forks_button.js b/app/assets/javascripts/forks/init_forks_button.js new file mode 100644 index 00000000000..b899d1c51db --- /dev/null +++ b/app/assets/javascripts/forks/init_forks_button.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ForksButton from './components/forks_button.vue'; + +const initForksButton = () => { + const el = document.getElementById('js-forks-button'); + + if (!el) { + return false; + } + + const { + forksCount, + projectFullPath, + projectForksUrl, + userForkUrl, + newForkUrl, + canReadCode, + canCreateFork, + canForkProject, + } = el.dataset; + + return new Vue({ + el, + provide: { + forksCount, + projectFullPath, + projectForksUrl, + userForkUrl, + newForkUrl, + canReadCode: parseBoolean(canReadCode), + canCreateFork: parseBoolean(canCreateFork), + canForkProject: parseBoolean(canForkProject), + }, + render(createElement) { + return createElement(ForksButton); + }, + }); +}; + +export default initForksButton; diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js index 1faacff84e5..3e5c9618805 100644 --- a/app/assets/javascripts/frequent_items/store/index.js +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { FREQUENT_ITEMS_DROPDOWNS } from '../constants'; import * as actions from './actions'; diff --git a/app/assets/javascripts/google_cloud/aiml/panel.vue b/app/assets/javascripts/google_cloud/aiml/panel.vue index f591c47ac40..751de20b16b 100644 --- a/app/assets/javascripts/google_cloud/aiml/panel.vue +++ b/app/assets/javascripts/google_cloud/aiml/panel.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import GoogleCloudMenu from '../components/google_cloud_menu.vue'; import IncubationBanner from '../components/incubation_banner.vue'; diff --git a/app/assets/javascripts/google_cloud/configuration/panel.vue b/app/assets/javascripts/google_cloud/configuration/panel.vue index ee046eb1988..34298e3dbf5 100644 --- a/app/assets/javascripts/google_cloud/configuration/panel.vue +++ b/app/assets/javascripts/google_cloud/configuration/panel.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import GcpRegionsList from '../gcp_regions/list.vue'; import GoogleCloudMenu from '../components/google_cloud_menu.vue'; diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue index 8b91c508871..95e18af7038 100644 --- a/app/assets/javascripts/google_cloud/databases/panel.vue +++ b/app/assets/javascripts/google_cloud/databases/panel.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import GoogleCloudMenu from '../components/google_cloud_menu.vue'; import IncubationBanner from '../components/incubation_banner.vue'; diff --git a/app/assets/javascripts/google_cloud/deployments/panel.vue b/app/assets/javascripts/google_cloud/deployments/panel.vue index 89db132ad5e..8a40351002b 100644 --- a/app/assets/javascripts/google_cloud/deployments/panel.vue +++ b/app/assets/javascripts/google_cloud/deployments/panel.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import GoogleCloudMenu from '../components/google_cloud_menu.vue'; import IncubationBanner from '../components/incubation_banner.vue'; diff --git a/app/assets/javascripts/google_cloud/gcp_regions/form.vue b/app/assets/javascripts/google_cloud/gcp_regions/form.vue index 23011e5a5b0..86850817a74 100644 --- a/app/assets/javascripts/google_cloud/gcp_regions/form.vue +++ b/app/assets/javascripts/google_cloud/gcp_regions/form.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; import { __, s__ } from '~/locale'; diff --git a/app/assets/javascripts/google_cloud/gcp_regions/list.vue b/app/assets/javascripts/google_cloud/gcp_regions/list.vue index 5d403d5cd65..2e76b32dcc4 100644 --- a/app/assets/javascripts/google_cloud/gcp_regions/list.vue +++ b/app/assets/javascripts/google_cloud/gcp_regions/list.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/google_cloud/service_accounts/form.vue b/app/assets/javascripts/google_cloud/service_accounts/form.vue index faec94e735b..2ab4ead5d14 100644 --- a/app/assets/javascripts/google_cloud/service_accounts/form.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/form.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/google_cloud/service_accounts/list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue index 4ac788aafbe..635b185d207 100644 --- a/app/assets/javascripts/google_cloud/service_accounts/list.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; import { setUrlParams, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 08733bbe620..eb807bc7540 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -203,6 +203,16 @@ export const config = { }; }, }, + Query: { + fields: { + boardList: { + keyArgs: ['id'], + }, + epicBoardList: { + keyArgs: ['id'], + }, + }, + }, } : {}), }, diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 7651bbba71c..e6c0b86d9a6 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -99,6 +99,7 @@ "DependencyProxyBlobRegistry", "DependencyProxyManifestRegistry", "DesignManagementRepositoryRegistry", + "GroupWikiRepositoryRegistry", "JobArtifactRegistry", "LfsObjectRegistry", "MergeRequestDiffRegistry", @@ -138,6 +139,7 @@ "WorkItem" ], "User": [ + "AutocompletedUser", "MergeRequestAssignee", "MergeRequestAuthor", "MergeRequestParticipant", @@ -179,6 +181,7 @@ "WorkItemWidgetHierarchy", "WorkItemWidgetIteration", "WorkItemWidgetLabels", + "WorkItemWidgetLinkedItems", "WorkItemWidgetMilestone", "WorkItemWidgetNotes", "WorkItemWidgetNotifications", diff --git a/app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql new file mode 100644 index 00000000000..39efd5eddef --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_autocomplete.query.graphql @@ -0,0 +1,20 @@ +query usersAutocomplete($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + autocompleteUsers(search: $search) { + id + avatarUrl + name + username + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + autocompleteUsers(search: $search) { + id + avatarUrl + name + username + } + } +} diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue index a4ec48ffd2f..d396169c0a3 100644 --- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -1,5 +1,5 @@ <script> -import { GlToggle, GlAlert } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf, GlToggle } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { updateGroup } from '~/api/groups_api'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -14,19 +14,29 @@ import { export default { components: { - GlToggle, GlAlert, + GlLink, + GlSprintf, + GlToggle, + }, + inject: { + groupId: {}, + groupName: {}, + groupIsEmpty: {}, + sharedRunnersSetting: {}, + + runnerEnabledValue: {}, + runnerDisabledValue: {}, + runnerAllowOverrideValue: {}, + + // Parent group, only present in sub-groups + + parentSharedRunnersSetting: { default: null }, + + // Available when user can admin parent + parentName: { default: null }, + parentSettingsPath: { default: null }, }, - inject: [ - 'groupId', - 'groupName', - 'groupIsEmpty', - 'sharedRunnersSetting', - 'parentSharedRunnersSetting', - 'runnerEnabledValue', - 'runnerDisabledValue', - 'runnerAllowOverrideValue', - ], data() { return { isLoading: false, @@ -48,6 +58,9 @@ export default { overrideToggleValue() { return this.value === this.runnerAllowOverrideValue; }, + isParentAvailable() { + return this.parentSettingsPath && this.parentName; + }, }, methods: { async onSharedRunnersToggle(enabled) { @@ -109,26 +122,28 @@ export default { <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> {{ error }} </gl-alert> - - <gl-alert - v-if="isSharedRunnersToggleDisabled" - variant="warning" - :dismissible="false" - class="gl-mb-5" - > - {{ __('Shared runners are disabled for the parent group') }} - </gl-alert> - <section class="gl-mb-5"> <gl-toggle :value="sharedRunnersToggleValue" :is-loading="isLoading" :disabled="isSharedRunnersToggleDisabled" :label="__('Enable shared runners for this group')" - :help="__('Enable shared runners for all projects and subgroups in this group.')" + :description="__('Enable shared runners for all projects and subgroups in this group.')" data-testid="shared-runners-toggle" @change="onSharedRunnersToggle" - /> + > + <template v-if="isSharedRunnersToggleDisabled" #help> + {{ s__('Runners|Shared runners are disabled.') }} + <gl-sprintf + v-if="isParentAvailable" + :message="s__('Runners|Go to %{groupLink} to enable them.')" + > + <template #groupLink> + <gl-link :href="parentSettingsPath">{{ parentName }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> </section> <section class="gl-mb-5"> @@ -137,10 +152,24 @@ export default { :is-loading="isLoading" :disabled="isOverrideToggleDisabled" :label="__('Allow projects and subgroups to override the group setting')" - :help="__('Allows projects or subgroups in this group to override the global setting.')" + :description=" + __('Allows projects or subgroups in this group to override the global setting.') + " data-testid="override-runners-toggle" @change="onOverrideToggle" - /> + > + <template v-if="isSharedRunnersToggleDisabled" #help> + {{ s__('Runners|Shared runners are disabled.') }} + <gl-sprintf + v-if="isParentAvailable" + :message="s__('Runners|Go to %{groupLink} to enable them.')" + > + <template #groupLink> + <gl-link :href="parentSettingsPath">{{ parentName }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> </section> </div> </template> diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js index 0767330cd54..334192a6f87 100644 --- a/app/assets/javascripts/group_settings/mount_shared_runners.js +++ b/app/assets/javascripts/group_settings/mount_shared_runners.js @@ -10,6 +10,8 @@ export default (containerId = 'update-shared-runners-form') => { groupName, groupIsEmpty, sharedRunnersSetting, + parentName, + parentSettingsPath, parentSharedRunnersSetting, runnerEnabledValue, runnerDisabledValue, @@ -23,10 +25,14 @@ export default (containerId = 'update-shared-runners-form') => { groupName, groupIsEmpty: parseBoolean(groupIsEmpty), sharedRunnersSetting, - parentSharedRunnersSetting, + runnerEnabledValue, runnerDisabledValue, runnerAllowOverrideValue, + + parentName, + parentSettingsPath, + parentSharedRunnersSetting, }, render(createElement) { return createElement(UpdateSharedRunnersForm); diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index c6fe16b13b5..3440bd87e6b 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -28,11 +28,6 @@ export default { required: false, default: '', }, - containerId: { - type: String, - required: false, - default: '', - }, store: { type: Object, required: true, @@ -94,10 +89,6 @@ export default { }, mounted() { this.fetchAllGroups(); - - if (this.containerId) { - this.containerEl = document.getElementById(this.containerId); - } }, beforeDestroy() { eventHub.$off(`${this.action}fetchPage`, this.fetchPage); diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 8d202194de7..af1af86d0c4 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -21,7 +21,7 @@ import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, } from '~/visibility_level/constants'; -import { ITEM_TYPE } from '../constants'; +import { ITEM_TYPE, ACTIVE_TAB_SHARED } from '../constants'; import eventHub from '../event_hub'; @@ -50,7 +50,11 @@ export default { ItemActions, ItemStats, }, - inject: ['currentGroupVisibility'], + inject: { + currentGroupVisibility: { + default: '', + }, + }, props: { parentGroup: { type: Object, @@ -114,7 +118,7 @@ export default { }, shouldShowVisibilityWarning() { return ( - this.action === 'shared' && + this.action === ACTIVE_TAB_SHARED && VISIBILITY_LEVELS_STRING_TO_INTEGER[this.group.visibility] > VISIBILITY_LEVELS_STRING_TO_INTEGER[this.currentGroupVisibility] ); @@ -201,7 +205,7 @@ export default { data-testid="group-name" :href="group.relativePath" :title="group.fullName" - class="no-expand gl-mr-3 gl-text-gray-900!" + class="no-expand gl-mr-3 gl-text-gray-900! gl-word-break-word" :itemprop="microdata.nameItemprop" > <!-- ending bracket must be by closing tag to prevent --> diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index 8d193310a98..fd633df3022 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -293,7 +293,7 @@ export default { required :name="fields.name.name" :placeholder="$options.i18n.inputs.name.placeholder" - data-qa-selector="group_name_field" + data-testid="group-name-field" :size="$options.inputSize" :state="nameFeedbackState" @invalid="handleInvalidName" @@ -376,7 +376,7 @@ export default { :state="pathFeedbackState" :size="pathInputSize" required - data-qa-selector="group_path_field" + data-testid="group-path-field" :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null" @input="handlePathInput" @invalid="handleInvalidPath" diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 5075be62214..969b41f4755 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { getParameterByName } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/groups/groups_list.js b/app/assets/javascripts/groups/groups_list.js deleted file mode 100644 index 866dd7a61ff..00000000000 --- a/app/assets/javascripts/groups/groups_list.js +++ /dev/null @@ -1,18 +0,0 @@ -import FilterableList from '~/filterable_list'; - -/** - * Makes search request for groups when user types a value in the search input. - * Updates the html content of the page with the received one. - */ -export default class GroupsList { - constructor() { - const form = document.querySelector('form#group-filter-form'); - const filter = document.querySelector('.js-groups-list-filter'); - const holder = document.querySelector('.js-groups-list-holder'); - - if (form && filter && holder) { - const list = new FilterableList(form, filter, holder); - list.initSearch(); - } - } -} diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index df2a23dc0f7..e71ff6d9107 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -7,92 +7,49 @@ import Translate from '../vue_shared/translate'; import GroupsApp from './components/app.vue'; import GroupFolderComponent from './components/group_folder.vue'; -import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants'; import GroupFilterableList from './groups_filterable_list'; import GroupsService from './service/groups_service'; import GroupsStore from './store/groups_store'; Vue.use(Translate); -export default (containerId = 'js-groups-tree', endpoint, action = '') => { - const containerEl = document.getElementById(containerId); - let dataEl; +export default () => { + const el = document.getElementById('js-groups-tree'); // eslint-disable-next-line no-new new UserCallout(); - // Don't do anything if element doesn't exist (No groups) - // This is for when the user enters directly to the page via URL - if (!containerEl) { + if (!el) { return; } - const el = action ? containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS) : containerEl; - - if (action) { - dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); - } - Vue.component('GroupFolder', GroupFolderComponent); Vue.component('GroupItem', GroupItemComponent); Vue.use(GlToast); + const { dataset } = el; + // eslint-disable-next-line no-new new Vue({ el, components: { GroupsApp, }, - provide() { - const { - dataset: { - newSubgroupPath, - newProjectPath, - newSubgroupIllustration, - newProjectIllustration, - emptyProjectsIllustration, - emptySubgroupIllustration, - canCreateSubgroups, - canCreateProjects, - currentGroupVisibility, - }, - } = this.$options.el; - - return { - newSubgroupPath, - newProjectPath, - newSubgroupIllustration, - newProjectIllustration, - emptyProjectsIllustration, - emptySubgroupIllustration, - canCreateSubgroups: parseBoolean(canCreateSubgroups), - canCreateProjects: parseBoolean(canCreateProjects), - currentGroupVisibility, - }; - }, data() { - const { dataset } = dataEl || this.$options.el; const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); const renderEmptyState = parseBoolean(dataset.renderEmptyState); - const service = new GroupsService(endpoint || dataset.endpoint); + const service = new GroupsService(dataset.endpoint); const store = new GroupsStore({ hideProjects: true, showSchemaMarkup }); return { - action, store, service, renderEmptyState, loading: true, - containerId, }; }, beforeMount() { - if (this.action) { - return; - } - - const { dataset } = dataEl || this.$options.el; let groupFilterList = null; const form = document.querySelector(dataset.formSel); const filter = document.querySelector(dataset.filterSel); @@ -102,11 +59,11 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { form, filter, holder, - filterEndpoint: endpoint || dataset.endpoint, + filterEndpoint: dataset.endpoint, pagePath: dataset.path, dropdownSel: dataset.dropdownSel, filterInputField: 'filter', - action: this.action, + action: '', }; groupFilterList = new GroupFilterableList(opts); @@ -115,11 +72,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { render(createElement) { return createElement('groups-app', { props: { - action: this.action, store: this.store, service: this.service, renderEmptyState: this.renderEmptyState, - containerId: this.containerId, }, }); }, diff --git a/app/assets/javascripts/groups/service/archived_projects_service.js b/app/assets/javascripts/groups/service/archived_projects_service.js index 5ffa3f91b06..b9d48cc660e 100644 --- a/app/assets/javascripts/groups/service/archived_projects_service.js +++ b/app/assets/javascripts/groups/service/archived_projects_service.js @@ -17,6 +17,7 @@ export default class ArchivedProjectsService { const { data: projects, headers } = await Api.groupProjects(this.groupId, query, { archived: true, + include_subgroups: true, page, order_by: supportedOrderBy[orderBy], sort, @@ -46,7 +47,7 @@ export default class ArchivedProjectsService { number_users_with_delimiter: 0, star_count: project.star_count, updated_at: project.updated_at, - marked_for_deletion: project.marked_for_deletion_at !== null, + marked_for_deletion: Boolean(project.marked_for_deletion_at), last_activity_at: project.last_activity_at, }; }), diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue index 8bc5f28ebfb..457a2db174c 100644 --- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue @@ -181,7 +181,6 @@ export default { <gl-dropdown-item v-for="group in groups" :key="`${group.id}${group.name}`" - data-testid="group-dropdown-item" :avatar-url="group.avatar_url" is-check-item :is-checked="isSelected(group)" diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue index 360af772a10..997e2bc3138 100644 --- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue +++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue @@ -241,6 +241,7 @@ export default { data-testid="transfer-locations-dropdown" block toggle-class="gl-mb-0" + class="gl-form-input-xl" @show="handleShow" > <template #header> diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 3cb0963e561..120b51f07cc 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -6,6 +6,7 @@ import { GlTooltipDirective, GlResizeObserverDirective, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { debounce } from 'lodash'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -225,7 +226,7 @@ export default { v-model="searchText" role="searchbox" class="gl-z-index-1" - data-testid="global_search_input" + data-testid="global-search-input" autocomplete="off" :placeholder="$options.i18n.SEARCH_GITLAB" :aria-activedescendant="currentFocusedId" diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 1838214def6..a785ae2a859 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -7,6 +7,7 @@ import { GlAlert, GlLoadingIcon, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue index f0d398297e9..6afee197c60 100644 --- a/app/assets/javascripts/header_search/components/header_search_default_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue @@ -1,5 +1,6 @@ <script> import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 1ef88492b23..7faef5f9bd7 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,5 +1,6 @@ <script> import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js index b83433c5b49..ca5519f529c 100644 --- a/app/assets/javascripts/header_search/store/index.js +++ b/app/assets/javascripts/header_search/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index d788104edc8..44a94f5fefe 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { leftSidebarViews } from '../constants'; diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index bdfcff3136b..5fb8e4247d7 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlIcon } from '@gitlab/ui'; diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index ce39c796386..603f2cedce2 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import Item from './item.vue'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index fcc900bbc96..bc8496e359c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,6 +1,8 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; import { s__ } from '~/locale'; import { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 7112c43bab8..44528685339 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { sprintf, __ } from '~/locale'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index 3ffbcbf99e8..ef9d9fd6048 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; export default { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index ef3da57c240..281a3054721 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlModal, GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { n__ } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 91d78a7c28c..76d3acb8e1f 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlModal, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import ListItem from './list_item.vue'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 79b6fd1ec68..69d84bcc6aa 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import getCommitIconMap from '../../commit_icon'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index 0a8fec49aac..462dab3d1cf 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { createNamespacedHelpers } from 'vuex'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index bd5d28dbb56..38b71e3da73 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -6,6 +6,7 @@ import { GlFormGroup, GlFormInput, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; export default { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index dd343bc5f79..db366a1b465 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index eba9bbcdf09..ce3d8f53fd2 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index d80ad723fce..d2d53ece4c5 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { n__ } from '~/locale'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index ba679ae7c9b..287ebc99662 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index e8b42ac9490..f58a35e7624 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -1,6 +1,8 @@ +<!-- 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'; diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 6bbad88715f..6cb26643b66 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import { diff --git a/app/assets/javascripts/ide/components/ide_file_row.vue b/app/assets/javascripts/ide/components/ide_file_row.vue index 248677d6a99..72d63f6a4ad 100644 --- a/app/assets/javascripts/ide/components/ide_file_row.vue +++ b/app/assets/javascripts/ide/components/ide_file_row.vue @@ -3,6 +3,7 @@ * This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue` * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720 */ +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import FileRow from '~/vue_shared/components/file_row.vue'; import FileRowExtra from './file_row_extra.vue'; diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index bea25d42756..be7865b09c1 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState, mapActions } from 'vuex'; import { viewerTypes } from '../constants'; import EditorModeDropdown from './editor_mode_dropdown.vue'; diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index f32d35bf774..d422c7c00d9 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -1,5 +1,6 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants'; import ActivityBar from './activity_bar.vue'; diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index edc6cc3dcdc..76b284b6185 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index da393b42dca..bd61625a530 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { isTextFile, getFileEOL } from '~/ide/utils'; import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 6998f8ef0c4..427b3743961 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { modalTypes, viewerTypes } from '../constants'; import IdeTreeList from './ide_tree_list.vue'; diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 737ff49f74c..f2a97e62190 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -1,5 +1,6 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 9676233a443..209d67b0d28 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -1,6 +1,8 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index 00059d01308..f0c5b29e210 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue index f84315b63d2..dcae6b70d4f 100644 --- a/app/assets/javascripts/ide/components/jobs/item.vue +++ b/app/assets/javascripts/ide/components/jobs/item.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton } from '@gitlab/ui'; import JobDescription from './detail/description.vue'; diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index 0ce21c5c36c..9f5da1d1217 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import Stage from './stage.vue'; diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 4d8c62d3430..ce4d657f941 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 2d9f74a06ee..61a595d3b5a 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 829a9d64cb7..be070891586 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -1,6 +1,8 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import TokenedInput from '../shared/tokened_input.vue'; diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue index f5f0db3a7a3..99ece59cbda 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -1,5 +1,6 @@ <script> import $ from 'jquery'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import NavDropdownButton from './nav_dropdown_button.vue'; import NavForm from './nav_form.vue'; diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue index 6c26cde42e3..18f0ca013a6 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue index ce80fbee2e0..06f40ce0100 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/button.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 9f83de840b9..7cd415169cc 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { modalTypes } from '../../constants'; import ItemButton from './button.vue'; diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 4d728bd35d4..854daa20628 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlModal, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { createAlert } from '~/alert'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 7c10e055e91..9664c5bc597 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { isTextFile } from '~/ide/utils'; import ItemButton from './button.vue'; diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue index bf99538a2ad..ce55d88437d 100644 --- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import IdeSidebarNav from '../ide_sidebar_nav.vue'; diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 8342b3f428c..b59b43e2691 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { __ } from '~/locale'; import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants'; diff --git a/app/assets/javascripts/ide/components/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue index 25e1698e3f4..7048246a979 100644 --- a/app/assets/javascripts/ide/components/pipelines/empty_state.vue +++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue @@ -1,5 +1,6 @@ <script> import { GlEmptyState } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 7f662f528d7..6bf51ed06a6 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlIcon, GlTabs, GlTab, GlBadge, GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import IDEServices from '~/ide/services'; diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 854ff74d0af..0452d566313 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { stageKeys } from '../constants'; import EmptyState from './commit_sidebar/empty_state.vue'; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 9e29cd94a20..137df9aa102 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,7 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { EDITOR_TYPE_DIFF, @@ -28,6 +29,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { markRaw } from '~/lib/utils/vue3compat/mark_raw'; +import { readFileAsDataURL } from '~/lib/utils/file_utility'; import { leftSidebarViews, @@ -40,7 +42,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; -import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils'; +import { getPathParent, registerSchema, isTextFile } from '../utils'; import FileAlert from './file_alert.vue'; import FileTemplatesBar from './file_templates/bar.vue'; diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 0fe909fcce8..15cb0571cbf 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTab } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 932040c7fa5..ae8becea242 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,5 +1,6 @@ <script> import { GlTabs } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import RepoTab from './repo_tab.vue'; diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index b49d743d877..660057f8f98 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import { SIDEBAR_MIN_WIDTH } from '../constants'; diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue index 384e27844c6..a1999465033 100644 --- a/app/assets/javascripts/ide/components/terminal/session.vue +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import { isEndingStatus } from '../../stores/modules/terminal/utils'; diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue index c91a98c9527..9e8b3d87397 100644 --- a/app/assets/javascripts/ide/components/terminal/terminal.vue +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { __ } from '~/locale'; import GLTerminal from '~/terminal/terminal'; diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue index fcf23eb1f73..872557cb777 100644 --- a/app/assets/javascripts/ide/components/terminal/view.vue +++ b/app/assets/javascripts/ide/components/terminal/view.vue @@ -1,4 +1,6 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import EmptyState from './empty_state.vue'; 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 67692c842b8..38e53b64503 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 @@ -1,6 +1,7 @@ <script> import { GlTooltipDirective, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { MSG_TERMINAL_SYNC_CONNECTING, diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue index afaf06f7f68..214a13a6668 100644 --- a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import TerminalSyncStatus from './terminal_sync_status.vue'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 29c44d2f596..b09cd7f6643 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -1,5 +1,6 @@ import { identity } from 'lodash'; import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { DEFAULT_BRANCH } from '~/ide/constants'; import PerformancePlugin from '~/performance/vue_performance_plugin'; diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue index ac9a3c3f82c..bfe101bc7e7 100644 --- a/app/assets/javascripts/ide/lib/alerts/environments.vue +++ b/app/assets/javascripts/ide/lib/alerts/environments.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index c2f7126159c..54ae4b5aa91 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 83a3d7f2ac3..3a42d7b3027 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -123,19 +123,6 @@ export function getPathParent(path) { return getPathParents(path, 1)[0]; } -/** - * Takes a file object and returns a data uri of its contents. - * - * @param {File} file - */ -export function readFileAsDataURL(file) { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.addEventListener('load', (e) => resolve(e.target.result), { once: true }); - reader.readAsDataURL(file); - }); -} - export function getFileEOL(content = '') { return content.includes('\r\n') ? 'CRLF' : 'LF'; } diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue index 1c31c04a416..68bdcf7ef90 100644 --- a/app/assets/javascripts/import_entities/components/group_dropdown.vue +++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue @@ -9,6 +9,8 @@ import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/cons import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +// This is added outside the component as each dropdown on the page triggers a query, +// so if multiple queries fail, we only show 1 error. const reportNamespaceLoadError = debounce( () => createAlert({ diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 6c84684dedc..94c04123112 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -1,7 +1,6 @@ <script> import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { STATISTIC_ITEMS } from '~/import/constants'; import { STATUSES } from '../constants'; @@ -58,7 +57,6 @@ export default { GlIcon, GlLink, }, - mixins: [glFeatureFlagMixin()], inject: { detailsPath: { default: undefined, @@ -116,11 +114,7 @@ export default { }, showDetails() { - return ( - Boolean(this.detailsPathForProject) && - this.glFeatures.importDetailsPage && - this.isIncomplete - ); + return Boolean(this.detailsPathForProject) && this.isIncomplete; }, detailsPathForProject() { diff --git a/app/assets/javascripts/import_entities/components/import_target_dropdown.vue b/app/assets/javascripts/import_entities/components/import_target_dropdown.vue new file mode 100644 index 00000000000..b18a106608a --- /dev/null +++ b/app/assets/javascripts/import_entities/components/import_target_dropdown.vue @@ -0,0 +1,119 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import { __, s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import { truncate } from '~/lib/utils/text_utility'; +import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +// This is added outside the component as each dropdown on the page triggers a query, +// so if multiple queries fail, we only show 1 error. +const reportNamespaceLoadError = debounce( + () => + createAlert({ + message: s__('ImportProjects|Requesting namespaces failed'), + }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, +); + +export default { + components: { + GlCollapsibleListbox, + }, + + props: { + selected: { + type: String, + required: true, + }, + userNamespace: { + type: String, + required: true, + }, + }, + + MAX_IMPORT_TARGET_LENGTH: 24, + + data() { + return { + searchTerm: '', + }; + }, + + apollo: { + namespaces: { + query: searchNamespacesWhereUserCanImportProjectsQuery, + variables() { + return { + search: this.searchTerm, + }; + }, + skip() { + const hasNotEnoughSearchCharacters = + this.searchTerm.length > 0 && this.searchTerm.length < MINIMUM_SEARCH_LENGTH; + return hasNotEnoughSearchCharacters; + }, + update(data) { + return data.currentUser.groups.nodes; + }, + error: reportNamespaceLoadError, + debounce: DEBOUNCE_DELAY, + }, + }, + + computed: { + filteredNamespaces() { + return (this.namespaces ?? []).filter((ns) => + ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()), + ); + }, + + toggleText() { + return truncate(this.selected, this.$options.MAX_IMPORT_TARGET_LENGTH); + }, + + items() { + return [ + { + text: __('Users'), + options: [{ text: this.userNamespace, value: this.userNamespace }], + }, + { + text: __('Groups'), + options: this.filteredNamespaces.map((namespace) => { + return { text: namespace.fullPath, value: namespace.fullPath }; + }), + }, + ]; + }, + }, + + methods: { + onSelect(value) { + this.$emit('select', value); + }, + + onSearch(value) { + this.searchTerm = value.trim(); + }, + }, +}; +</script> + +<template> + <gl-collapsible-listbox + :items="items" + :selected="selected" + :toggle-text="toggleText" + searchable + fluid-width + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + data-qa-selector="target_namespace_selector_dropdown" + @select="onSelect" + @search="onSearch" + /> +</template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index d91f314a86c..678efc536f2 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -1,11 +1,20 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlIcon, + GlButtonGroup, + GlButton, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; export default { components: { GlIcon, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlButtonGroup, + GlButton, }, directives: { GlTooltip, @@ -34,20 +43,31 @@ export default { <template> <span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center"> - <gl-dropdown - v-if="isAvailableForImport || isFinished" - :text="isFinished ? __('Re-import with projects') : __('Import with projects')" - :disabled="isInvalid" - variant="confirm" - category="secondary" - data-qa-selector="import_group_button" - split - @click="importGroup({ migrateProjects: true })" - > - <gl-dropdown-item @click="importGroup({ migrateProjects: false })">{{ - isFinished ? __('Re-import without projects') : __('Import without projects') - }}</gl-dropdown-item> - </gl-dropdown> + <gl-button-group v-if="isAvailableForImport || isFinished"> + <gl-button + variant="confirm" + category="secondary" + data-testid="import-group-button" + @click="importGroup({ migrateProjects: true })" + >{{ isFinished ? __('Re-import with projects') : __('Import with projects') }}</gl-button + > + <gl-disclosure-dropdown + toggle-text="Import options" + text-sr-only + :disabled="isInvalid" + icon="chevron-down" + no-caret + variant="confirm" + category="secondary" + > + <gl-disclosure-dropdown-item @action="importGroup({ migrateProjects: false })"> + <template #list-item> + {{ isFinished ? __('Re-import without projects') : __('Import without projects') }} + </template></gl-disclosure-dropdown-item + > + </gl-disclosure-dropdown> + </gl-button-group> + <gl-icon v-if="isFinished" v-gl-tooltip diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue index 20dcd0356cd..cb3476c48db 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlSearchBoxByClick, GlTabs, GlTab } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; import ImportProjectsTable from './import_projects_table.vue'; diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 1c830d8c2c5..009945f8b9b 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -6,6 +6,7 @@ import { GlModal, GlSearchBoxByClick, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { n__, __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index 735939f991f..d75ba53d727 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -5,17 +5,15 @@ import { GlFormInput, GlButton, GlLink, - GlDropdownItem, - GlDropdownDivider, - GlDropdownSectionHeader, GlTooltip, GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; -import ImportGroupDropdown from '../../components/group_dropdown.vue'; +import ImportTargetDropdown from '../../components/import_target_dropdown.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; import { isProjectImportable, isImporting, isIncompatible, getImportStatus } from '../utils'; @@ -23,13 +21,10 @@ import { isProjectImportable, isImporting, isIncompatible, getImportStatus } fro export default { name: 'ProviderRepoTableRow', components: { - ImportGroupDropdown, ImportStatus, + ImportTargetDropdown, GlFormInput, GlButton, - GlDropdownItem, - GlDropdownDivider, - GlDropdownSectionHeader, GlIcon, GlBadge, GlLink, @@ -151,6 +146,10 @@ export default { }); } }, + + onSelect(value) { + this.updateImportTarget({ targetNamespace: value }); + }, }, helpUrl: helpPagePath('/user/project/import/github.md'), @@ -188,27 +187,13 @@ export default { <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> <template v-else-if="isImportNotStarted || isSelectedForReimport"> <div class="gl-display-flex gl-align-items-stretch gl-w-full"> - <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> - <template v-if="namespaces.length"> - <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="ns in namespaces" - :key="ns.fullPath" - data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns.fullPath" - @click="updateImportTarget({ targetNamespace: ns.fullPath })" - > - {{ ns.fullPath }} - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> - <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{ - userNamespace - }}</gl-dropdown-item> - </import-group-dropdown> + <import-target-dropdown + :selected="importTarget.targetNamespace" + :user-namespace="userNamespace" + @select="onSelect" + /> <div - class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" + class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-border-gray-400" > / </div> diff --git a/app/assets/javascripts/import_entities/import_projects/store/index.js b/app/assets/javascripts/import_entities/import_projects/store/index.js index a2880e7d031..d3edb48e1db 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/index.js +++ b/app/assets/javascripts/import_entities/import_projects/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actionsFactory from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index e15cb2224f4..e5a88cf9510 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -447,7 +447,6 @@ export default { :issue-iid="item.iid" :project-path="projectPath" :sla-due-at="item.slaDueAt" - data-testid="incident-sla" class="gl-display-block gl-max-w-full gl-text-truncate" /> </template> 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 af4905deef4..fe3f4ed4bf9 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -38,7 +38,7 @@ export default { <gl-button ref="toggleBtn" class="js-settings-toggle">{{ $options.i18n.expandBtnLabel }}</gl-button> - <p ref="sectionSubHeader"> + <p ref="sectionSubHeader" class="gl-text-secondary"> {{ $options.i18n.subHeaderText }} </p> </div> diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index a4415a5a2b3..f78513a98b8 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -1,5 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; export default { diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 0a29906d5aa..bd45412a481 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,6 +1,7 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index f119668048d..fa9a59212eb 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -2,6 +2,7 @@ import { GlAlert, GlForm } from '@gitlab/ui'; import axios from 'axios'; import * as Sentry from '@sentry/browser'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -217,29 +218,24 @@ export default { @change="setOverride" /> - <section v-if="showHelpHtml" class="gl-lg-display-flex gl-justify-content-end gl-mb-6"> + <!-- helpHtml is trusted input --> + <section v-if="showHelpHtml" class="gl-mb-6"> <!-- helpHtml is trusted input --> - <div - v-safe-html:[$options.helpHtmlConfig]="helpHtml" - data-testid="help-html" - class="gl-flex-basis-two-thirds" - ></div> + <div v-safe-html:[$options.helpHtmlConfig]="helpHtml" data-testid="help-html"></div> </section> - <section v-if="!hasSections" class="gl-lg-display-flex gl-justify-content-end"> - <div class="gl-flex-basis-two-thirds"> - <active-checkbox - v-if="propsSource.showActive" - :key="`${currentKey}-active-checkbox`" - @toggle-integration-active="onToggleIntegrationState" - /> - <trigger-fields - v-if="propsSource.triggerEvents.length" - :key="`${currentKey}-trigger-fields`" - :events="propsSource.triggerEvents" - :type="propsSource.type" - /> - </div> + <section v-if="!hasSections"> + <active-checkbox + v-if="propsSource.showActive" + :key="`${currentKey}-active-checkbox`" + @toggle-integration-active="onToggleIntegrationState" + /> + <trigger-fields + v-if="propsSource.triggerEvents.length" + :key="`${currentKey}-trigger-fields`" + :events="propsSource.triggerEvents" + :type="propsSource.type" + /> </section> <template v-if="hasSections"> @@ -254,22 +250,19 @@ export default { /> </template> - <section v-if="hasFieldsWithoutSection" class="gl-lg-display-flex gl-justify-content-end"> - <div class="gl-flex-basis-two-thirds"> - <dynamic-field - v-for="field in fieldsWithoutSection" - :key="`${currentKey}-${field.name}`" - v-bind="field" - :is-validated="isValidated" - :data-qa-selector="`${field.name}_div`" - /> - </div> + <section v-if="hasFieldsWithoutSection"> + <dynamic-field + v-for="field in fieldsWithoutSection" + :key="`${currentKey}-${field.name}`" + v-bind="field" + :is-validated="isValidated" + :data-qa-selector="`${field.name}_div`" + /> </section> <integration-form-actions v-if="isEditable" :has-sections="hasSections" - :class="{ 'gl-lg-display-flex gl-justify-content-end': !hasSections }" :is-saving="isSaving" :is-testing="isTesting" :is-resetting="isResetting" diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue index e5ad5149cf7..a9d7c1ca378 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlModalDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import { integrationLevels } from '~/integrations/constants'; import ConfirmationModal from './confirmation_modal.vue'; @@ -69,75 +70,69 @@ export default { }; </script> <template> - <section> - <div :class="{ 'gl-flex-basis-two-thirds': !hasSections }"> - <div - class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between" + <section class="gl-lg-display-flex gl-justify-content-space-between"> + <div> + <template v-if="isInstanceOrGroupLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="confirm" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> + <gl-button + v-else + category="primary" + variant="confirm" + type="submit" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button" + data-qa-selector="save_changes_button" + @click.prevent="onSaveClick" > - <div> - <template v-if="isInstanceOrGroupLevel"> - <gl-button - v-gl-modal.confirmSaveIntegration - category="primary" - variant="confirm" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" - > - {{ __('Save changes') }} - </gl-button> - <confirmation-modal @submit="onSaveClick" /> - </template> - <gl-button - v-else - category="primary" - variant="confirm" - type="submit" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" - @click.prevent="onSaveClick" - > - {{ __('Save changes') }} - </gl-button> + {{ __('Save changes') }} + </gl-button> - <gl-button - v-if="showTestButton" - category="secondary" - variant="confirm" - :loading="isTesting" - :disabled="disableButtons" - data-testid="test-button" - @click.prevent="onTestClick" - > - {{ __('Test settings') }} - </gl-button> + <gl-button + v-if="showTestButton" + category="secondary" + variant="confirm" + :loading="isTesting" + :disabled="disableButtons" + data-testid="test-button" + @click.prevent="onTestClick" + > + {{ __('Test settings') }} + </gl-button> - <gl-button - :href="propsSource.cancelPath" - data-testid="cancel-button" - :disabled="disableButtons" - >{{ __('Cancel') }}</gl-button - > - </div> + <gl-button + :href="propsSource.cancelPath" + data-testid="cancel-button" + :disabled="disableButtons" + >{{ __('Cancel') }}</gl-button + > + </div> - <template v-if="showResetButton"> - <gl-button - v-gl-modal.confirmResetIntegration - category="tertiary" - variant="danger" - :loading="isResetting" - :disabled="disableButtons" - data-testid="reset-button" - > - {{ __('Reset') }} - </gl-button> + <template v-if="showResetButton"> + <gl-button + v-gl-modal.confirmResetIntegration + category="tertiary" + variant="danger" + :loading="isResetting" + :disabled="disableButtons" + data-testid="reset-button" + > + {{ __('Reset') }} + </gl-button> - <reset-confirmation-modal @reset="onResetClick" /> - </template> - </div> - </div> + <reset-confirmation-modal @reset="onResetClick" /> + </template> </section> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue index 5335b7b6ee2..d322e3b4cd2 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue @@ -1,5 +1,6 @@ <script> import { GlBadge } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { integrationFormSectionComponents, billingPlanNames } from '~/integrations/constants'; @@ -63,36 +64,29 @@ export default { }; </script> <template> - <section class="gl-lg-display-flex"> - <div class="gl-flex-basis-third gl-mr-4"> - <h4 class="gl-mt-0"> - {{ section.title - }}<gl-badge - v-if="section.plan" - :href="propsSource.aboutPricingUrl" - target="_blank" - rel="noopener noreferrer" - variant="tier" - icon="license" - class="gl-ml-3" - > - {{ $options.billingPlanNames[section.plan] }} - </gl-badge> - </h4> - <p v-safe-html="section.description"></p> - </div> + <section> + <h4 class="gl-mt-0"> + {{ section.title + }}<gl-badge + v-if="section.plan" + :href="propsSource.aboutPricingUrl" + target="_blank" + rel="noopener noreferrer" + variant="tier" + icon="license" + class="gl-ml-3" + > + {{ $options.billingPlanNames[section.plan] }} + </gl-badge> + </h4> + <p v-safe-html="section.description"></p> - <div - v-if="$options.integrationFormSectionComponents[section.type]" - class="gl-flex-basis-two-thirds" - > - <component - :is="$options.integrationFormSectionComponents[section.type]" - :fields="fieldsForSection(section)" - :is-validated="isValidated" - @toggle-integration-active="$emit('toggle-integration-active', $event)" - @request-jira-issue-types="$emit('request-jira-issue-types', $event)" - /> - </div> + <component + :is="$options.integrationFormSectionComponents[section.type]" + :fields="fieldsForSection(section)" + :is-validated="isValidated" + @toggle-integration-active="$emit('toggle-integration-active', $event)" + @request-jira-issue-types="$emit('request-jira-issue-types', $event)" + /> </section> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue index 30a39e48959..f3036e44df4 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { isEmpty } from 'lodash'; import { GlFormGroup, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui'; diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 584d23e17e1..c1c09cfa3d6 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,5 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { s__, __ } from '~/locale'; diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index c7cbdff72e3..034867f8b5f 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -7,6 +7,7 @@ import { GlLink, GlSprintf, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue index 96ba276033c..951b936f805 100644 --- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -1,5 +1,6 @@ <script> import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { s__ } from '~/locale'; import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants'; diff --git a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue index 775600a9a62..3d37bab1dce 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { sprintf, s__ } from '~/locale'; import UploadDropzoneField from '../upload_dropzone_field.vue'; diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue index b8fd8995744..052e8d8488d 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import DynamicField from '../dynamic_field.vue'; diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue index 6237f7983a6..60b8bef24dc 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/connection.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { INTEGRATION_FORM_TYPE_JIRA, jiraIntegrationAuthFields } from '~/integrations/constants'; diff --git a/app/assets/javascripts/integrations/edit/components/sections/google_play.vue b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue index 3094e24241a..20f0d3bba97 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/google_play.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { sprintf, s__ } from '~/locale'; import UploadDropzoneField from '../upload_dropzone_field.vue'; @@ -12,7 +13,7 @@ export default { }, data() { return { - dropzoneAllowList: ['.json'], + dropzoneAllowList: ['.JSON'], }; }, i18n: { @@ -23,7 +24,7 @@ export default { "GooglePlay|Error: The file you're trying to upload is not a service account key.", ), dropzoneConfirmMessage: s__('GooglePlay|Drag your key file to start the upload.'), - dropzoneEmptyInputName: s__('GooglePlay|Service account key (.json)'), + dropzoneEmptyInputName: s__('GooglePlay|Service account key (.JSON)'), dropzoneNonEmptyInputName: s__( 'GooglePlay|Upload a new service account key (replace %{currentFileName})', ), diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue index 75202209d38..859d83df5e3 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/jira_issues.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import JiraIssuesFields from '../jira_issues_fields.vue'; diff --git a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue index f36d3b1fbda..5e3d60e0489 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/jira_trigger.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import JiraTriggerFields from '../jira_trigger_fields.vue'; diff --git a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue index 00546671aa7..bc9adf5263a 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import TriggerField from '../trigger_field.vue'; diff --git a/app/assets/javascripts/integrations/edit/components/trigger_field.vue b/app/assets/javascripts/integrations/edit/components/trigger_field.vue index 57753c61587..0c5511bfebb 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_field.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_field.vue @@ -1,5 +1,6 @@ <script> import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 3820a87e5ad..96ce95856c9 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -1,5 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { placeholderForType } from 'jh_else_ce/integrations/constants'; diff --git a/app/assets/javascripts/integrations/edit/store/index.js b/app/assets/javascripts/integrations/edit/store/index.js index a8375f345c6..4f3d13f59ba 100644 --- a/app/assets/javascripts/integrations/edit/store/index.js +++ b/app/assets/javascripts/integrations/edit/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/integrations/index/components/integrations_list.vue b/app/assets/javascripts/integrations/index/components/integrations_list.vue index 7331437d484..32a4e2ae718 100644 --- a/app/assets/javascripts/integrations/index/components/integrations_list.vue +++ b/app/assets/javascripts/integrations/index/components/integrations_list.vue @@ -1,4 +1,5 @@ <script> +import { GlCard } from '@gitlab/ui'; import { s__ } from '~/locale'; import IntegrationsTable from './integrations_table.vue'; @@ -6,6 +7,7 @@ export default { name: 'IntegrationsList', components: { IntegrationsTable, + GlCard, }, props: { integrations: { @@ -40,20 +42,37 @@ export default { <template> <div> - <h4>{{ $options.i18n.activeIntegrationsHeading }}</h4> - <integrations-table - class="gl-mb-7!" - :integrations="integrationsGrouped.active" - :empty-text="$options.i18n.activeTableEmptyText" - show-updated-at - data-testid="active-integrations-table" - /> - - <h4>{{ $options.i18n.inactiveIntegrationsHeading }}</h4> - <integrations-table - :integrations="integrationsGrouped.inactive" - :empty-text="$options.i18n.inactiveTableEmptyText" - data-testid="inactive-integrations-table" - /> + <gl-card + class="gl-new-card gl-overflow-hidden" + header-class="gl-new-card-header gl-border-b-0" + body-class="gl-new-card-body gl-px-0" + > + <template #header> + <h3 class="gl-new-card-title">{{ $options.i18n.activeIntegrationsHeading }}</h3> + </template> + <integrations-table + class="gl-mb-n2" + :integrations="integrationsGrouped.active" + :empty-text="$options.i18n.activeTableEmptyText" + show-updated-at + data-testid="active-integrations-table" + /> + </gl-card> + <gl-card + class="gl-new-card gl-overflow-hidden" + header-class="gl-new-card-header gl-border-b-0" + body-class="gl-new-card-body gl-px-0" + > + <template #header> + <h3 class="gl-new-card-title">{{ $options.i18n.inactiveIntegrationsHeading }}</h3> + </template> + <integrations-table + class="gl-mb-n2" + inactive + :integrations="integrationsGrouped.inactive" + :empty-text="$options.i18n.inactiveTableEmptyText" + data-testid="inactive-integrations-table" + /> + </gl-card> </div> </template> diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue index 59a29f81727..eff64ed7c42 100644 --- a/app/assets/javascripts/integrations/index/components/integrations_table.vue +++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue @@ -30,19 +30,30 @@ export default { required: false, default: undefined, }, + inactive: { + type: Boolean, + required: false, + default: false, + }, }, computed: { fields() { - return [ + if (this.filteredIntegrations.length === 0) { + return []; + } + + const fields = []; + + fields.push( { key: 'active', label: '', - thClass: 'gl-w-10', + thClass: 'gl-w-7', }, { key: 'title', label: __('Integration'), - thClass: 'gl-w-quarter', + thClass: 'gl-w-quarter gl-xs-w-full', }, { key: 'description', @@ -50,12 +61,18 @@ export default { thClass: 'gl-display-none d-sm-table-cell', tdClass: 'gl-display-none d-sm-table-cell', }, - { + ); + + if (!this.inactive && this.filteredIntegrations.length > 0) { + fields.push({ key: 'updated_at', label: this.showUpdatedAt ? __('Last updated') : '', - thClass: 'gl-w-20p', - }, - ]; + thClass: 'gl-w-20 gl-text-right', + tdClass: 'gl-text-right', + }); + } + + return fields; }, filteredIntegrations() { return this.integrations.filter( diff --git a/app/assets/javascripts/invite_members/components/confetti.vue b/app/assets/javascripts/invite_members/components/confetti.vue index 2e5744afcd4..562f935e2b3 100644 --- a/app/assets/javascripts/invite_members/components/confetti.vue +++ b/app/assets/javascripts/invite_members/components/confetti.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import confetti from 'canvas-confetti'; diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 1369deae3f9..42257127bbc 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -1,5 +1,6 @@ <script> import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; +import axios from 'axios'; import { debounce } from 'lodash'; import { s__ } from '~/locale'; import { getGroups, getDescendentGroups } from '~/rest_api'; @@ -42,6 +43,7 @@ export default { searchTerm: '', pagination: {}, infiniteScrollLoading: false, + activeApiRequestAbortController: null, }; }, computed: { @@ -61,15 +63,13 @@ export default { methods: { retrieveGroups: debounce(async function debouncedRetrieveGroups() { this.isFetching = true; - try { const response = await this.fetchGroups(); this.pagination = this.processPagination(response); this.groups = this.processGroups(response); - } catch { - this.onApiError(); - } finally { this.isFetching = false; + } catch (e) { + this.onApiError(e); } }, SEARCH_DELAY), processGroups({ data }) { @@ -98,16 +98,32 @@ export default { this.retrieveGroups(); }, fetchGroups(options = {}) { + if (this.activeApiRequestAbortController !== null) { + this.activeApiRequestAbortController.abort(); + } + + this.activeApiRequestAbortController = new AbortController(); + const combinedOptions = { ...this.$options.defaultFetchOptions, ...options, }; + const axiosConfig = { + signal: this.activeApiRequestAbortController.signal, + }; + switch (this.groupsFilter) { case GROUP_FILTERS.DESCENDANT_GROUPS: - return getDescendentGroups(this.parentGroupId, this.searchTerm, combinedOptions); + return getDescendentGroups( + this.parentGroupId, + this.searchTerm, + combinedOptions, + undefined, + axiosConfig, + ); default: - return getGroups(this.searchTerm, combinedOptions); + return getGroups(this.searchTerm, combinedOptions, undefined, axiosConfig); } }, async onBottomReached() { @@ -117,13 +133,15 @@ export default { const response = await this.fetchGroups({ page: this.pagination.page + 1 }); this.pagination = this.processPagination(response); this.groups.push(...this.processGroups(response)); - } catch { - this.onApiError(); - } finally { this.infiniteScrollLoading = false; + } catch (e) { + this.onApiError(e); } }, - onApiError() { + onApiError(error) { + if (axios.isCancel(error)) return; + this.isFetching = false; + this.infiniteScrollLoading = false; this.$emit('error', this.$options.i18n.errorMessage); }, }, diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index 66d4a9ccc07..5599ad276f0 100644 --- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -1,5 +1,5 @@ <script> -import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup, GlModal, GlSprintf, GlAlert, GlCollapse, GlIcon, GlButton } from '@gitlab/ui'; import { uniqueId, isEmpty } from 'lodash'; import { importProjectMembers } from '~/api/projects_api'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; @@ -16,8 +16,10 @@ import { PROJECT_SELECT_LABEL_ID, IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL, + MEMBER_MODAL_LABELS, } from '../constants'; +import { responseFromSuccess } from '../utils/response_message_parser'; import UserLimitNotification from './user_limit_notification.vue'; import ProjectSelect from './project_select.vue'; @@ -27,6 +29,10 @@ export default { GlFormGroup, GlModal, GlSprintf, + GlAlert, + GlCollapse, + GlIcon, + GlButton, UserLimitNotification, ProjectSelect, }, @@ -60,6 +66,9 @@ export default { return { projectToBeImported: {}, invalidFeedbackMessage: '', + totalMembersCount: 0, + invalidMembers: {}, + isErrorsSectionExpanded: false, isLoading: false, }; }, @@ -94,6 +103,40 @@ export default { actionCancel() { return { text: this.$options.i18n.modalCancelButton }; }, + hasInvalidMembers() { + return !isEmpty(this.invalidMembers); + }, + memberErrorTitle() { + return sprintf( + s__( + 'InviteMembersModal|The following %{errorMembersLength} out of %{totalMembersCount} members could not be added', + ), + { errorMembersLength: this.errorList.length, totalMembersCount: this.totalMembersCount }, + ); + }, + errorList() { + return Object.entries(this.invalidMembers).map(([member, error]) => { + return { member, displayedMemberName: `@${member}`, message: error }; + }); + }, + errorsLimited() { + return this.errorList.slice(0, this.$options.errorsLimit); + }, + errorsExpanded() { + return this.errorList.slice(this.$options.errorsLimit); + }, + shouldErrorsSectionExpand() { + return Boolean(this.errorsExpanded.length); + }, + errorCollapseText() { + if (this.isErrorsSectionExpanded) { + return this.$options.labels.expandedErrors; + } + + return sprintf(this.$options.labels.collapsedErrors, { + count: this.errorsExpanded.length, + }); + }, }, mounted() { if (this.reloadPageOnSubmit) { @@ -113,21 +156,37 @@ export default { this.$root.$emit(BV_HIDE_MODAL, this.$options.modalId); }, resetFields() { + this.clearValidation(); this.invalidFeedbackMessage = ''; this.projectToBeImported = {}; }, - submitImport(e) { + async submitImport(event) { // We never want to hide when submitting - e.preventDefault(); + event.preventDefault(); this.isLoading = true; - return importProjectMembers(this.projectId, this.projectToBeImported.id) - .then(this.onInviteSuccess) - .catch(this.showErrorAlert) - .finally(() => { - this.isLoading = false; - this.projectToBeImported = {}; - }); + + try { + const response = await importProjectMembers(this.projectId, this.projectToBeImported.id); + + const { error, message } = responseFromSuccess(response); + + if (error) { + this.totalMembersCount = response.data.total_members_count; + this.showMemberErrors(message); + } else { + this.onInviteSuccess(); + } + } catch { + this.showErrorAlert(); + } finally { + this.isLoading = false; + this.projectToBeImported = {}; + } + }, + showMemberErrors(message) { + this.invalidMembers = message; + this.$refs.alerts.focus(); }, onInviteSuccess() { this.track('invite_successful'); @@ -151,6 +210,13 @@ export default { onClose() { this.track('click_x'); }, + clearValidation() { + this.invalidFeedbackMessage = ''; + this.invalidMembers = {}; + }, + toggleErrorExpansion() { + this.isErrorsSectionExpanded = !this.isErrorsSectionExpanded; + }, }, toastOptions() { return { @@ -173,8 +239,10 @@ export default { defaultError: s__('ImportAProjectModal|Unable to import project members'), successMessage: s__('ImportAProjectModal|Successfully imported'), }, + errorsLimit: 2, projectSelectLabelId: PROJECT_SELECT_LABEL_ID, modalId: uniqueId('import-a-project-modal-'), + labels: MEMBER_MODAL_LABELS, }; </script> @@ -186,18 +254,62 @@ export default { :title="$options.i18n.modalTitle" :action-primary="actionPrimary" :action-cancel="actionCancel" + data-testid="import-project-members-modal" no-focus-on-show @primary="submitImport" @hidden="resetFields" @cancel="onCancel" @close="onClose" > - <user-limit-notification - v-if="showUserLimitNotification" - class="gl-mb-5" - :limit-variant="limitVariant" - :users-limit-dataset="usersLimitDataset" - /> + <div ref="alerts" tabindex="-1"> + <gl-alert + v-if="hasInvalidMembers" + class="gl-mb-4" + variant="danger" + :dismissible="false" + :title="memberErrorTitle" + data-testid="alert-member-error" + > + {{ $options.labels.memberErrorListText }} + <ul class="gl-pl-5 gl-mb-0"> + <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item"> + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + <template v-if="shouldErrorsSectionExpand"> + <gl-collapse v-model="isErrorsSectionExpanded"> + <ul class="gl-pl-5 gl-mb-0"> + <li + v-for="error in errorsExpanded" + :key="error.member" + data-testid="errors-expanded-item" + > + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + </gl-collapse> + <gl-button + class="gl-text-decoration-none! gl-shadow-none! gl-mt-3" + data-testid="accordion-button" + variant="link" + @click="toggleErrorExpansion" + > + {{ errorCollapseText }} + <gl-icon + name="chevron-down" + class="gl-transition-medium" + :class="{ 'gl-rotate-180': isErrorsSectionExpanded }" + /> + </gl-button> + </template> + </gl-alert> + <user-limit-notification + v-else-if="showUserLimitNotification" + class="gl-mb-5" + :limit-variant="limitVariant" + :users-limit-dataset="usersLimitDataset" + /> + </div> <p ref="modalIntro"> <gl-sprintf :message="modalIntro"> <template #strong="{ content }"> diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue index 1d061a4b81e..5d8f2ddfe15 100644 --- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue +++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue @@ -4,15 +4,12 @@ import { n__, sprintf } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import { - INFO_ALERT_TITLE, WARNING_ALERT_TITLE, DANGER_ALERT_TITLE, REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, REACHED_LIMIT_VARIANT, CLOSE_TO_LIMIT_MESSAGE, CLOSE_TO_LIMIT_VARIANT, - NOTIFICATION_LIMIT_MESSAGE, - NOTIFICATION_LIMIT_VARIANT, } from '../constants'; export default { @@ -32,15 +29,6 @@ export default { computed: { limitAttributes() { return { - [NOTIFICATION_LIMIT_VARIANT]: { - variant: 'info', - title: this.notificationTitle( - INFO_ALERT_TITLE, - this.name, - this.usersLimitDataset.freeUsersLimit, - ), - message: this.message(NOTIFICATION_LIMIT_MESSAGE, this.usersLimitDataset.freeUsersLimit), - }, [CLOSE_TO_LIMIT_VARIANT]: { variant: 'warning', title: this.title(WARNING_ALERT_TITLE, this.usersLimitDataset.remainingSeats), @@ -55,13 +43,6 @@ export default { }, }, methods: { - notificationTitle(titleTemplate, namespaceName, dashboardLimit) { - return sprintf(titleTemplate, { - namespaceName, - dashboardLimit, - }); - }, - title(titleTemplate, count) { return sprintf(titleTemplate, { count, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index a4fe1a413aa..1cee0c32008 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -157,9 +157,6 @@ export const GROUP_MODAL_LABELS = { export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed'; export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal'; -export const INFO_ALERT_TITLE = s__( - 'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.', -); export const WARNING_ALERT_TITLE = s__( 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}', ); @@ -169,7 +166,6 @@ export const DANGER_ALERT_TITLE = s__( export const REACHED_LIMIT_VARIANT = 'reached'; export const CLOSE_TO_LIMIT_VARIANT = 'close'; -export const NOTIFICATION_LIMIT_VARIANT = 'notification'; export const REACHED_LIMIT_MESSAGE = s__( 'InviteMembersModal|To invite new users to this top-level group, you must remove existing users. You can still add existing users from the top-level group, including any subgroups and projects.', @@ -184,7 +180,3 @@ export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.co export const CLOSE_TO_LIMIT_MESSAGE = s__( 'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', ); - -export const NOTIFICATION_LIMIT_MESSAGE = s__( - 'InviteMembersModal|GitLab will enforce this limit in the future. If you are over %{dashboardLimit} users when enforcement begins, your top-level group will be placed in a %{freeUserLimitLinkStart}read-only state%{freeUserLimitLinkEnd}. To avoid being placed in a read-only state, reduce your top-level group to %{dashboardLimit} users or less, or purchase a paid tier.', -); diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index eab7d01be14..a0854be099d 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -1,5 +1,6 @@ <script> 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'; diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js index e5a2388580b..45ea5c4827e 100644 --- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js @@ -55,6 +55,7 @@ export default { sprint_id: this.form.find('input[name="update[iteration_id]"]').val(), add_label_ids: [], remove_label_ids: [], + confidential: this.form.find('input[name="update[confidentiality]"]').val(), }, }; if (assigneeIds) { diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js index 9c891bcfc9e..b45271c7fe1 100644 --- a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js @@ -4,6 +4,7 @@ import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; import { mountAssigneesDropdown, + mountConfidentialityDropdown, mountMilestoneDropdown, mountMoveIssuesButton, mountStatusDropdown, @@ -65,6 +66,7 @@ export default class IssuableBulkUpdateSidebar { mountStatusDropdown(); mountSubscriptionsDropdown(); mountAssigneesDropdown(); + mountConfidentialityDropdown(); // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at diff --git a/app/assets/javascripts/issuable/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js index 6b8f3de8d49..cc2da0a7105 100644 --- a/app/assets/javascripts/issuable/issuable_template_selector.js +++ b/app/assets/javascripts/issuable/issuable_template_selector.js @@ -1,9 +1,9 @@ import $ from 'jquery'; -import TemplateSelector from '~/blob/template_selector'; +import LegacyTemplateSelector from '~/blob/legacy_template_selector'; import { __ } from '~/locale'; import Api from '../api'; -export default class IssuableTemplateSelector extends TemplateSelector { +export default class IssuableTemplateSelector extends LegacyTemplateSelector { constructor(...args) { super(...args); diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index de0334b4ffe..98888f9f9b2 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -137,8 +137,6 @@ export default class CreateMergeRequestDropdown { this.refInput.addEventListener('keyup', this.onChangeInput.bind(this)); // Detect when user clicks inside the input to apply the suggested ref this.refInput.addEventListener('click', this.onChangeInput.bind(this)); - // Detect when user clicks outside the input to apply the suggested ref - this.refInput.addEventListener('blur', this.onChangeInput.bind(this)); // Detect when user presses tab to apply the suggested ref this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this)); } @@ -178,8 +176,16 @@ export default class CreateMergeRequestDropdown { createBranch(navigateToBranch = true) { this.isCreatingBranch = true; + const endpoint = createEndpoint( + this.projectPath, + mergeUrlParams( + { ref: this.refInput.value, branch_name: this.branchInput.value }, + this.createBranchPath, + ), + ); + return axios - .post(createEndpoint(this.projectPath, this.createBranchPath), { + .post(endpoint, { confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null, }) .then(({ data }) => { @@ -407,9 +413,6 @@ export default class CreateMergeRequestDropdown { // If the input is empty, use the original value generated by the backend. if (!value) { - this.createBranchPath = this.wrapperEl.dataset.createBranchPath; - this.createMrPath = this.wrapperEl.dataset.createMrPath; - if (target === INPUT_TARGET_BRANCH) { this.branchIsValid = true; } else { @@ -539,7 +542,6 @@ export default class CreateMergeRequestDropdown { updateBranchName(suggestedBranchName) { this.branchInput.value = suggestedBranchName; this.updateInputState(INPUT_TARGET_BRANCH, suggestedBranchName, ''); - this.updateCreatePaths(INPUT_TARGET_BRANCH, suggestedBranchName); } updateInputState(target, ref, result) { @@ -561,7 +563,6 @@ export default class CreateMergeRequestDropdown { if (ref === result) { this.refIsValid = true; this.showAvailableMessage(INPUT_TARGET_REF); - this.updateCreatePaths(INPUT_TARGET_REF, ref); } else { this.refIsValid = false; this.refInput.dataset.value = ref; @@ -585,7 +586,6 @@ export default class CreateMergeRequestDropdown { // Or user typed input contains invalid chars, // that means a new branch cannot be created as it already exists. this.showAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_BRANCH_UNAVAILABLE); - this.updateCreatePaths(INPUT_TARGET_BRANCH, ref); } else if (isInvalidString) { this.branchIsValid = false; this.showNotAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_INVALID_CHARS); @@ -594,22 +594,4 @@ export default class CreateMergeRequestDropdown { this.showNotAvailableMessage(INPUT_TARGET_BRANCH); } } - - // target - 'branch' or 'ref' - // ref - string - the new value to use as branch or ref - updateCreatePaths(target, ref) { - const pathReplacement = `$1${encodeURIComponent(ref)}`; - - this.createBranchPath = this.createBranchPath.replace( - this.regexps[target].createBranchPath, - pathReplacement, - ); - this.createMrPath = this.createMrPath.replace( - this.regexps[target].createMrPath, - pathReplacement, - ); - - this.wrapperEl.dataset.createBranchPath = this.createBranchPath; - this.wrapperEl.dataset.createMrPath = this.createMrPath; - } } 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 eb73f8e0182..9febebf7e55 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -36,6 +36,7 @@ import { getParameterByName } from '~/lib/utils/url_utility'; import { OPERATORS_IS, OPERATORS_IS_NOT_OR, + OPERATORS_AFTER_BEFORE, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -44,6 +45,8 @@ import { TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, + TOKEN_TITLE_CREATED, + TOKEN_TITLE_CLOSED, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -52,6 +55,8 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; @@ -63,6 +68,7 @@ const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); const LabelToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const DateToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/date_token.vue'); const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); @@ -89,6 +95,7 @@ export default { 'emptyStateWithoutFilterSvgPath', 'hasBlockedIssuesFeature', 'hasIssuableHealthStatusFeature', + 'hasIssueDateFilterFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialSort', @@ -318,6 +325,24 @@ export default { fetchEmojis: this.fetchEmojis, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-my_reaction', }); + + if (this.hasIssueDateFilterFeature) { + tokens.push({ + type: TOKEN_TYPE_CREATED, + title: TOKEN_TITLE_CREATED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + + tokens.push({ + type: TOKEN_TYPE_CLOSED, + title: TOKEN_TITLE_CLOSED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + } } tokens.sort((a, b) => a.title.localeCompare(b.title)); diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql index 5c331fe95e2..51e38d44c85 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -23,6 +23,10 @@ query getDashboardIssues( $beforeCursor: String $firstPageSize: Int $lastPageSize: Int + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { issues( search: $search @@ -44,6 +48,10 @@ query getDashboardIssues( before: $beforeCursor first: $firstPageSize last: $lastPageSize + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) @persist { nodes { __persist diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql index b36f546e4ab..a91f15f0c04 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql @@ -12,6 +12,10 @@ query getDashboardIssuesCount( $in: [IssuableSearchableField!] $not: NegatedIssueFilterInput $or: UnionedIssueFilterInput + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { openedIssues: issues( state: opened @@ -28,6 +32,10 @@ query getDashboardIssuesCount( in: $in not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -46,6 +54,10 @@ query getDashboardIssuesCount( in: $in not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -64,6 +76,10 @@ query getDashboardIssuesCount( in: $in not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 4d2df9e3602..eec7c6bf842 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -9,12 +9,7 @@ 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 { - initHeaderActions, - initIncidentApp, - initIssueApp, - initSentryErrorStackTrace, -} from '~/issues/show'; +import { initIncidentApp, initIssueApp, initSentryErrorStackTrace } from '~/issues/show'; import { parseIssuableData } from '~/issues/show/utils/parse_data'; import LabelsSelect from '~/labels/labels_select'; import initNotesApp from '~/notes'; @@ -58,12 +53,10 @@ export function initShow({ notesParams } = {}) { if (issueType === TYPE_INCIDENT) { initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store); - initHeaderActions(store, TYPE_INCIDENT); initLinkedResources(); initRelatedIssues(TYPE_INCIDENT); } else { initIssueApp(issuableData, store); - initHeaderActions(store); } new Issue(); // eslint-disable-line no-new 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 f7693dd7102..c50b48ca0d8 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -6,8 +6,12 @@ import { GlDisclosureDropdownGroup, GlFilteredSearchToken, GlTooltipDirective, + GlDrawer, + GlLink, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; + +import produce from 'immer'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { isEmpty } from 'lodash'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; @@ -17,6 +21,7 @@ import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_coun import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { createAlert, VARIANT_INFO } from '~/alert'; import { TYPENAME_USER } from '~/graphql_shared/constants'; +import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; @@ -36,6 +41,7 @@ import { OPERATORS_IS, OPERATORS_IS_NOT, OPERATORS_IS_NOT_OR, + OPERATORS_AFTER_BEFORE, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -47,6 +53,8 @@ import { TOKEN_TITLE_RELEASE, TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, + TOKEN_TITLE_CREATED, + TOKEN_TITLE_CLOSED, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -58,11 +66,16 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { WORK_ITEM_TYPE_ENUM_OBJECTIVE } from '~/work_items/constants'; import { CREATED_DESC, defaultTypeTokenOptions, @@ -98,6 +111,8 @@ import { getSortKey, getSortOptions, isSortKey, + mapWorkItemWidgetsToIssueFields, + updateUpvotesCount, } from '../utils'; import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin'; import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; @@ -116,6 +131,7 @@ const CrmContactToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'); const CrmOrganizationToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'); +const DateToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/date_token.vue'); export default { i18n, @@ -131,12 +147,15 @@ export default { EmptyStateWithoutAnyIssues, GlButton, GlButtonGroup, + GlDrawer, IssuableByEmail, IssuableList, IssueCardStatistics, IssueCardTimeInfo, NewResourceDropdown, LocalStorageSync, + WorkItemDetail, + GlLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -154,6 +173,7 @@ export default { 'hasAnyProjects', 'hasBlockedIssuesFeature', 'hasIssuableHealthStatusFeature', + 'hasIssueDateFilterFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialEmail', @@ -218,6 +238,7 @@ export default { }, ], }, + activeIssuable: null, }; }, apollo: { @@ -230,6 +251,7 @@ export default { return data[this.namespace]?.issues.nodes ?? []; }, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + nextFetchPolicy: fetchPolicies.CACHE_FIRST, // We need this for handling loading state when using frontend cache // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details notifyOnNetworkStatusChange: true, @@ -446,6 +468,24 @@ export default { { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, ], }); + + if (this.hasIssueDateFilterFeature) { + tokens.push({ + type: TOKEN_TYPE_CREATED, + title: TOKEN_TITLE_CREATED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + + tokens.push({ + type: TOKEN_TYPE_CLOSED, + title: TOKEN_TITLE_CLOSED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + } } if (this.canReadCrmContact) { @@ -536,6 +576,12 @@ export default { isGridView() { return this.viewType === ISSUES_GRID_VIEW_KEY; }, + isIssuableSelected() { + return !isEmpty(this.activeIssuable); + }, + issuesDrawerEnabled() { + return this.glFeatures?.issuesListDrawer; + }, }, watch: { $route(newValue, oldValue) { @@ -603,6 +649,15 @@ export default { .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, @@ -805,12 +860,108 @@ export default { // The default view is list view this.viewType = ISSUES_LIST_VIEW_KEY; }, + handleSelectIssuable(issuable) { + this.activeIssuable = issuable; + }, + updateIssuablesCache(workItem) { + const client = this.$apollo.provider.clients.defaultClient; + const issuesList = client.readQuery({ + query: getIssuesQuery, + variables: this.queryVariables, + }); + + const activeIssuable = issuesList.project.issues.nodes.find( + (issue) => issue.iid === workItem.iid, + ); + + // when we change issuable state, it's moved to a different tab + // to ensure that we show 20 items of the first page, we need to refetch issuables + if (!activeIssuable.state.includes(workItem.state.toLowerCase())) { + this.refetchIssuables(); + return; + } + + // handle all other widgets + const data = mapWorkItemWidgetsToIssueFields(issuesList, workItem); + + client.writeQuery({ query: getIssuesQuery, variables: this.queryVariables, data }); + }, + promoteToObjective(workItemIid) { + const { cache } = this.$apollo.provider.clients.defaultClient; + + cache.updateQuery({ query: getIssuesQuery, variables: this.queryVariables }, (issuesList) => + produce(issuesList, (draftData) => { + const activeItem = draftData.project.issues.nodes.find( + (issue) => issue.iid === workItemIid, + ); + + activeItem.type = WORK_ITEM_TYPE_ENUM_OBJECTIVE; + }), + ); + }, + refetchIssuables() { + this.$apollo.queries.issues.refetch(); + this.$apollo.queries.issuesCounts.refetch(); + }, + deleteIssuable({ workItemId }) { + this.$apollo + .mutate({ + mutation: deleteWorkItemMutation, + variables: { input: { id: workItemId } }, + }) + .then(({ data }) => { + if (data.workItemDelete.errors?.length) { + throw new Error(data.workItemDelete.errors[0]); + } + this.activeIssuable = null; + this.refetchIssuables(); + }) + .catch((error) => { + this.issuesError = this.$options.i18n.deleteError; + Sentry.captureException(error); + }); + }, + updateIssuableEmojis(workItem) { + const client = this.$apollo.provider.clients.defaultClient; + const issuesList = client.readQuery({ + query: getIssuesQuery, + variables: this.queryVariables, + }); + + const data = updateUpvotesCount(issuesList, workItem); + + client.writeQuery({ query: getIssuesQuery, variables: this.queryVariables, data }); + }, }, }; </script> <template> <div> + <gl-drawer + v-if="issuesDrawerEnabled" + :open="isIssuableSelected" + header-height="calc(var(--top-bar-height) + var(--performance-bar-height))" + class="gl-w-40p gl-xs-w-full" + @close="activeIssuable = null" + > + <template #title> + <gl-link :href="activeIssuable.webUrl" class="gl-text-black-normal">{{ + __('Open full view') + }}</gl-link> + </template> + <template #default> + <work-item-detail + :key="activeIssuable.iid" + :work-item-iid="activeIssuable.iid" + @work-item-updated="updateIssuablesCache" + @work-item-emoji-updated="updateIssuableEmojis" + @addChild="refetchIssuables" + @deleteWorkItem="deleteIssuable" + @promotedToObjective="promoteToObjective" + /> + </template> + </gl-drawer> <issuable-list v-if="hasAnyIssues" :namespace="fullPath" @@ -840,7 +991,9 @@ export default { :has-previous-page="pageInfo.hasPreviousPage" :show-filtered-search-friendly-text="hasOrFeature" :is-grid-view="isGridView" + :active-issuable="activeIssuable" show-work-item-type-icon + :prevent-redirect="issuesDrawerEnabled" @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @filter="handleFilter" @@ -850,6 +1003,7 @@ export default { @sort="handleSort" @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" @page-size-change="handlePageSizeChange" + @select-issuable="handleSelectIssuable" > <template #nav-actions> <local-storage-sync diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 1a3d97277c7..85e300b6474 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -9,6 +9,8 @@ import { OPERATOR_IS, OPERATOR_NOT, OPERATOR_OR, + OPERATOR_AFTER, + OPERATOR_BEFORE, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -24,6 +26,8 @@ import { TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, TOKEN_TYPE_SEARCH_WITHIN, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, @@ -115,6 +119,7 @@ export const i18n = { noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), 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'), @@ -415,4 +420,32 @@ export const filtersMap = { }, }, }, + [TOKEN_TYPE_CREATED]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'createdBefore', + [ALTERNATIVE_FILTER]: 'createdAfter', + }, + [URL_PARAM]: { + [OPERATOR_AFTER]: { + [ALTERNATIVE_FILTER]: 'created_after', + }, + [OPERATOR_BEFORE]: { + [NORMAL_FILTER]: 'created_before', + }, + }, + }, + [TOKEN_TYPE_CLOSED]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'closedBefore', + [ALTERNATIVE_FILTER]: 'closedAfter', + }, + [URL_PARAM]: { + [OPERATOR_AFTER]: { + [ALTERNATIVE_FILTER]: 'closed_after', + }, + [OPERATOR_BEFORE]: { + [NORMAL_FILTER]: 'closed_before', + }, + }, + }, }; diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js index e64870152bd..6e9a566cb5c 100644 --- a/app/assets/javascripts/issues/list/graphql.js +++ b/app/assets/javascripts/issues/list/graphql.js @@ -1,6 +1,7 @@ import produce from 'immer'; import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import { config } from '~/graphql_shared/issuable_client'; let client; @@ -27,7 +28,7 @@ const resolvers = { export async function gqlClient() { if (client) return client; client = gon.features?.frontendCaching - ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' }) - : createDefaultClient(resolvers); + ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list', ...config }) + : createDefaultClient(resolvers, config); return client; } diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index d1b45294026..8c60ad6dc4e 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -71,6 +71,7 @@ export async function mountIssuesListApp() { hasAnyProjects, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, + hasIssueDateFilterFeature, hasIssueWeightsFeature, hasIterationsFeature, hasScopedLabelsFeature, @@ -95,6 +96,8 @@ export async function mountIssuesListApp() { showNewIssueLink, signInPath, groupId = '', + reportAbusePath, + registerPath, } = el.dataset; return new Vue({ @@ -117,11 +120,15 @@ export async function mountIssuesListApp() { canReadCrmOrganization: parseBoolean(canReadCrmOrganization), emptyStateSvgPath, fullPath, + projectPath: fullPath, groupPath, + reportAbusePath, + registerPath, hasAnyIssues: parseBoolean(hasAnyIssues), hasAnyProjects: parseBoolean(hasAnyProjects), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueDateFilterFeature: parseBoolean(hasIssueDateFilterFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index 1018848fb53..23410ea0f81 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -30,6 +30,10 @@ query getIssues( $afterCursor: String $firstPageSize: Int $lastPageSize: Int + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { group(fullPath: $fullPath) @skip(if: $isProject) @persist { id @@ -57,6 +61,10 @@ query getIssues( after: $afterCursor first: $firstPageSize last: $lastPageSize + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { __persist pageInfo { @@ -96,6 +104,10 @@ query getIssues( after: $afterCursor first: $firstPageSize last: $lastPageSize + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { __persist pageInfo { diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql index fdb0eeb5970..7953dc423b6 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql @@ -18,6 +18,10 @@ query getIssuesCount( $crmOrganizationId: String $not: NegatedIssueFilterInput $or: UnionedIssueFilterInput + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { group(fullPath: $fullPath) @skip(if: $isProject) { id @@ -39,6 +43,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -60,6 +68,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -81,6 +93,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -106,6 +122,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -128,6 +148,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -150,6 +174,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index d053400dd03..37df0c8f9ff 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -1,3 +1,4 @@ +import produce from 'immer'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -5,6 +6,7 @@ import { FILTERED_SEARCH_TERM, OPERATOR_NOT, OPERATOR_OR, + OPERATOR_AFTER, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -17,6 +19,15 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants'; import { + WORK_ITEM_TO_ISSUE_MAP, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_AWARD_EMOJI, + EMOJI_THUMBSUP, + EMOJI_THUMBSDOWN, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, +} from '~/work_items/constants'; +import { ALTERNATIVE_FILTER, API_PARAM, BLOCKING_ISSUES_ASC, @@ -222,10 +233,10 @@ export const getFilterTokens = (locationSearch) => }; }); -const isNotEmptySearchToken = (token) => +export const isNotEmptySearchToken = (token) => !(token.type === FILTERED_SEARCH_TERM && !token.value.data); -const isSpecialFilter = (type, data) => { +export const isSpecialFilter = (type, data) => { const isAssigneeIdParam = type === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data) && @@ -236,8 +247,9 @@ const isSpecialFilter = (type, data) => { const getFilterType = ({ type, value: { data, operator } }) => { const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR; const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR; + const isAfter = operator === OPERATOR_AFTER; - if (isUnionedAuthor || isUnionedLabel) { + if (isUnionedAuthor || isUnionedLabel || isAfter) { return ALTERNATIVE_FILTER; } if (isSpecialFilter(type, data)) { @@ -318,3 +330,67 @@ export const convertToSearchQuery = (filterTokens) => .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) .map((token) => token.value.data) .join(' ') || undefined; + +function findWidget(type, workItem) { + return workItem?.widgets?.find((widget) => widget.type === type); +} + +export function mapWorkItemWidgetsToIssueFields(issuesList, workItem) { + return produce(issuesList, (draftData) => { + const activeItem = draftData.project.issues.nodes.find((issue) => issue.iid === workItem.iid); + + Object.keys(WORK_ITEM_TO_ISSUE_MAP).forEach((type) => { + const currentWidget = findWidget(type, workItem); + if (!currentWidget) { + return; + } + const property = WORK_ITEM_TO_ISSUE_MAP[type]; + + // handling the case for assignees and labels + if ( + property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_ASSIGNEES] || + property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_LABELS] + ) { + activeItem[property] = { + ...currentWidget[property], + nodes: currentWidget[property].nodes.map((node) => ({ + __persist: true, + ...node, + })), + }; + return; + } + + // handling the case for milestone + if (property === WORK_ITEM_TO_ISSUE_MAP[WIDGET_TYPE_MILESTONE] && currentWidget[property]) { + activeItem[property] = { __persist: true, ...currentWidget[property] }; + return; + } + activeItem[property] = currentWidget[property]; + }); + + activeItem.title = workItem.title; + activeItem.confidential = workItem.confidential; + }); +} + +export function updateUpvotesCount(issuesList, workItem) { + const type = WIDGET_TYPE_AWARD_EMOJI; + const property = WORK_ITEM_TO_ISSUE_MAP[type]; + + return produce(issuesList, (draftData) => { + const activeItem = draftData.project.issues.nodes.find((issue) => issue.iid === workItem.iid); + + const currentWidget = findWidget(type, workItem); + if (!currentWidget) { + return; + } + + const upvotesCount = + currentWidget[property].nodes.filter((emoji) => emoji.name === EMOJI_THUMBSUP)?.length ?? 0; + const downvotesCount = + currentWidget[property].nodes.filter((emoji) => emoji.name === EMOJI_THUMBSDOWN)?.length ?? 0; + activeItem.upvotes = upvotesCount; + activeItem.downvotes = downvotesCount; + }); +} 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 cbec10b4ebe..d819a371c69 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 @@ -1,5 +1,6 @@ <script> import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { sprintf, __, n__ } from '~/locale'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; diff --git a/app/assets/javascripts/issues/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js index 925cc36cd76..b0bf8986547 100644 --- a/app/assets/javascripts/issues/related_merge_requests/store/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index fcdf1f7741b..26c3db647a3 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -23,6 +23,8 @@ 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 TitleComponent from './title.vue'; @@ -32,6 +34,8 @@ export default { GlIcon, GlBadge, GlIntersectionObserver, + HeaderActions, + IssueHeader, TitleComponent, EditedComponent, FormComponent, @@ -42,6 +46,11 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + author: { + type: Object, + required: false, + default: () => ({}), + }, endpoint: { required: true, type: String, @@ -54,6 +63,11 @@ export default { required: true, type: Boolean, }, + createdAt: { + type: String, + required: false, + default: '', + }, enableAutocomplete: { type: Boolean, required: false, @@ -193,6 +207,31 @@ export default { required: false, default: null, }, + duplicatedToIssueUrl: { + type: String, + required: false, + default: '', + }, + movedToIssueUrl: { + type: String, + required: false, + default: '', + }, + promotedToEpicUrl: { + type: String, + required: false, + default: '', + }, + isFirstContribution: { + type: Boolean, + required: false, + default: false, + }, + serviceDeskReplyTo: { + type: String, + required: false, + default: '', + }, }, data() { const store = new Store({ @@ -222,6 +261,9 @@ export default { }, }, computed: { + headerClasses() { + return this.issuableType === TYPE_INCIDENT ? 'gl-mb-3' : 'gl-mb-6'; + }, issuableTemplates() { return this.store.formState.issuableTemplates; }, @@ -259,10 +301,10 @@ export default { : ''; }, statusIcon() { - if (this.issuableType === TYPE_ISSUE) { - return this.isClosed ? 'issue-closed' : 'issues'; + if (this.issuableType === TYPE_EPIC) { + return this.isClosed ? 'epic-closed' : 'epic'; } - return this.isClosed ? 'epic-closed' : 'epic'; + return this.isClosed ? 'issue-closed' : 'issues'; }, statusVariant() { return this.isClosed ? 'info' : 'success'; @@ -271,7 +313,7 @@ export default { return issuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); + return [TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); }, }, created() { @@ -509,7 +551,13 @@ export default { :can-update="canUpdate" :title-html="state.titleHtml" :title-text="state.titleText" - /> + > + <template #actions> + <slot name="actions"> + <header-actions /> + </slot> + </template> + </title-component> <gl-intersection-observer v-if="shouldShowStickyHeader" @@ -567,6 +615,25 @@ export default { </transition> </gl-intersection-observer> + <slot name="header"> + <issue-header + class="gl-p-0 gl-mt-2 gl-sm-mt-0" + :class="headerClasses" + :author="author" + :confidential="isConfidential" + :created-at="createdAt" + :duplicated-to-issue-url="duplicatedToIssueUrl" + :is-first-contribution="isFirstContribution" + :is-hidden="isHidden" + :is-locked="isLocked" + :issuable-state="issuableStatus" + :issuable-type="issuableType" + :moved-to-issue-url="movedToIssueUrl" + :promoted-to-epic-url="promotedToEpicUrl" + :service-desk-reply-to="serviceDeskReplyTo" + /> + </slot> + <pinned-links :zoom-meeting-url="zoomMeetingUrl" :published-incident-url="publishedIncidentUrl" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 3bf4dfc7a99..90f01603f96 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlToast } from '@gitlab/ui'; import Sortable from 'sortablejs'; diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 6a0edb59b65..73dbf5bc77e 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLink, GlSprintf } from '@gitlab/ui'; import { n__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index a1463d0e911..efe1619ed1f 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { __ } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue index 58d32256da4..25801b3307c 100644 --- a/app/assets/javascripts/issues/show/components/fields/title.vue +++ b/app/assets/javascripts/issues/show/components/fields/title.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import updateMixin from '../../mixins/update'; diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 576d157e0fc..4ab49e5df38 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlFormGroup, GlIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 831248d9603..047bdcdcefc 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,9 +1,10 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert } from '@gitlab/ui'; import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants'; -import { TYPE_ISSUE } from '~/issues/constants'; +import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; @@ -106,8 +107,8 @@ export default { showLockedWarning() { return this.formState.lockedWarningVisible && !this.formState.updateLoading; }, - isIssueType() { - return this.issuableType === TYPE_ISSUE; + showTypeField() { + return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType); }, resourceId() { return this.issueId && convertToGraphQLId(TYPENAME_ISSUE, this.issueId); @@ -201,7 +202,7 @@ export default { </div> </div> <div class="row gl-gap-3"> - <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0"> + <div v-if="showTypeField" class="col-12 col-md-4 pr-md-0"> <issuable-type-field ref="issue-type" /> </div> diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 719f252781d..1ade5e654e9 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -10,6 +10,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; @@ -146,7 +147,7 @@ export default { variables() { return { fullPath: this.fullPath, - iid: this.iid, + iid: String(this.iid), }; }, update(data) { @@ -289,7 +290,7 @@ export default { mutation: promoteToEpicMutation, variables: { input: { - iid: this.iid, + iid: String(this.iid), projectPath: this.projectPath, }, }, @@ -374,6 +375,7 @@ export default { <template v-if="isMrSidebarMoved"> <gl-dropdown-item :data-clipboard-text="issuableReference" + button-class="js-copy-reference" data-testid="copy-reference" @click="copyReference" >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item @@ -418,7 +420,7 @@ export default { v-gl-tooltip.bottom :title="$options.i18n.editTitleAndDescription" :aria-label="$options.i18n.editTitleAndDescription" - class="js-issuable-edit gl-display-none gl-sm-display-block" + class="js-issuable-edit gl-display-none! gl-sm-display-block!" data-testid="edit-button" @click="edit" > @@ -464,6 +466,7 @@ export default { </template> <gl-dropdown-item v-if="showToggleIssueStateButton && glFeatures.moveCloseIntoDropdown" + data-testid="toggle-issue-state-button" @click="toggleIssueState" > {{ buttonText }} @@ -485,6 +488,7 @@ export default { <template v-if="isMrSidebarMoved"> <gl-dropdown-item :data-clipboard-text="issuableReference" + button-class="js-copy-reference" data-testid="copy-reference" @click="copyReference" >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item @@ -517,7 +521,7 @@ export default { <gl-dropdown-item v-gl-modal="$options.deleteModalId" variant="danger" - data-testid="delete_issue_button" + data-testid="delete-issue-button" @click="track('click_dropdown')" > {{ deleteButtonText }} diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 2a59b7a2042..1905678209f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -270,7 +270,6 @@ export default { v-if="showSaveAndAdd" variant="confirm" category="secondary" - data-testid="save-and-add-button" :disabled="!isTimelineTextValid" :loading="isEventProcessed" @click="handleSave(true)" diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index b776822bd9a..8da4f0f44e9 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -76,7 +76,7 @@ export default { > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> - <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> + <div class="timeline-event-note timeline-event-border"> <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-3 gl-mb-2"> <h3 class="timeline-event-note-date gl-font-weight-bold gl-font-sm gl-my-0" diff --git a/app/assets/javascripts/issues/show/components/issue_header.vue b/app/assets/javascripts/issues/show/components/issue_header.vue new file mode 100644 index 00000000000..211f3217ddc --- /dev/null +++ b/app/assets/javascripts/issues/show/components/issue_header.vue @@ -0,0 +1,128 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { STATUS_OPEN, STATUS_REOPENED, WORKSPACE_PROJECT } from '~/issues/constants'; +import { __, s__ } from '~/locale'; +import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; + +export default { + WORKSPACE_PROJECT, + components: { + GlLink, + GlSprintf, + IssuableHeader, + }, + props: { + author: { + type: Object, + required: true, + }, + confidential: { + type: Boolean, + required: true, + }, + createdAt: { + type: String, + required: true, + }, + duplicatedToIssueUrl: { + type: String, + required: true, + }, + isFirstContribution: { + type: Boolean, + required: true, + }, + isHidden: { + type: Boolean, + required: true, + }, + isLocked: { + type: Boolean, + required: true, + }, + issuableState: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + movedToIssueUrl: { + type: String, + required: true, + }, + promotedToEpicUrl: { + type: String, + required: true, + }, + serviceDeskReplyTo: { + type: String, + required: false, + default: '', + }, + }, + computed: { + closedStatusLink() { + return this.duplicatedToIssueUrl || this.movedToIssueUrl || this.promotedToEpicUrl; + }, + closedStatusText() { + if (this.duplicatedToIssueUrl) { + return s__('IssuableStatus|duplicated'); + } + if (this.movedToIssueUrl) { + return s__('IssuableStatus|moved'); + } + if (this.promotedToEpicUrl) { + return s__('IssuableStatus|promoted'); + } + return ''; + }, + isOpen() { + return this.issuableState === STATUS_OPEN || this.issuableState === STATUS_REOPENED; + }, + statusIcon() { + return this.isOpen ? 'issues' : 'issue-closed'; + }, + statusText() { + if (this.isOpen) { + return __('Open'); + } + if (this.closedStatusLink) { + return s__('IssuableStatus|Closed (%{link})'); + } + return s__('IssuableStatus|Closed'); + }, + }, +}; +</script> + +<template> + <issuable-header + :author="author" + :blocked="isLocked" + :confidential="confidential" + :created-at="createdAt" + :is-first-contribution="isFirstContribution" + :is-hidden="isHidden" + :issuable-state="issuableState" + :issuable-type="issuableType" + :service-desk-reply-to="serviceDeskReplyTo" + show-work-item-type-icon + :status-icon="statusIcon" + :workspace-type="$options.WORKSPACE_PROJECT" + > + <template #status-badge> + <gl-sprintf v-if="closedStatusLink" :message="statusText"> + <template #link> + <gl-link + class="gl-reset-color! gl-reset-font-size gl-text-decoration-underline" + :href="closedStatusLink" + >{{ closedStatusText }}</gl-link + > + </template> + </gl-sprintf> + <template v-else>{{ statusText }}</template> + </template> + </issuable-header> +</template> diff --git a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue index 8262b3ac0ff..f7a324d9f3f 100644 --- a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue +++ b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue @@ -54,29 +54,27 @@ export default { </script> <template> - <div> - <gl-popover - v-if="showPopover" - target="new-actions-header-dropdown" - container="viewport" - placement="left" - :show="showPopover" - triggers="manual" - content="text" - :css-classes="['gl-p-2 new-header-popover']" + <gl-popover + v-if="showPopover" + target="new-actions-header-dropdown" + container="viewport" + placement="left" + :show="showPopover" + triggers="manual" + content="text" + :css-classes="['gl-p-2 new-header-popover']" + > + <template #title> + <div class="gl-font-base gl-font-weight-normal"> + {{ popoverText }} + </div> + </template> + <gl-button + data-testid="confirm-button" + variant="confirm" + type="submit" + @click="dismissPopover" + >{{ $options.i18n.confirmButtonText }}</gl-button > - <template #title> - <div class="gl-font-base gl-font-weight-normal"> - {{ popoverText }} - </div> - </template> - <gl-button - data-testid="confirm-button" - variant="confirm" - type="submit" - @click="dismissPopover" - >{{ $options.i18n.confirmButtonText }}</gl-button - > - </gl-popover> - </div> + </gl-popover> </template> diff --git a/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue index 1530e9a15b5..08cda8c3cdc 100644 --- a/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index c464f48d574..375180446d9 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlTooltipDirective } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -52,16 +53,19 @@ export default { </script> <template> - <div class="title-container"> + <div + class="gl-display-flex gl-align-items-flex-start gl-flex-direction-column gl-sm-flex-direction-row gl-gap-3 gl-pt-3" + > <h1 v-safe-html="titleHtml" :class="{ 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, }" - class="title gl-font-size-h-display" + class="title gl-font-size-h-display gl-m-0!" data-testid="issue-title" dir="auto" ></h1> + <slot name="actions"></slot> </div> </template> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index bc4284457f6..a27f86bd9c3 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -1,9 +1,10 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import errorTrackingStore from '~/error_tracking/store'; import { apolloProvider } from '~/graphql_shared/issuable_client'; -import { TYPE_INCIDENT } from '~/issues/constants'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import IssueApp from './components/app.vue'; import HeaderActions from './components/header_actions.vue'; @@ -29,9 +30,13 @@ export function initIncidentApp(issueData = {}, store) { return undefined; } - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + bootstrapApollo({ ...issueState, issueType: TYPE_INCIDENT }); const { + authorId, + authorName, + authorUsername, + authorWebUrl, canCreateIncident, canUpdate, canUpdateTimelineEvent, @@ -45,8 +50,8 @@ export function initIncidentApp(issueData = {}, store) { hasLinkedAlerts, slaFeatureAvailable, uploadMetricsFeatureAvailable, - state, } = issueData; + const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); const fullPath = `${projectNamespace}/${projectPath}`; const router = createRouter(currentPath, currentTab); @@ -70,6 +75,22 @@ export function initIncidentApp(issueData = {}, store) { 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']), @@ -78,8 +99,15 @@ export function initIncidentApp(issueData = {}, store) { return createElement(IssueApp, { props: { ...issueData, + author: { + id: authorId, + name: authorName, + username: authorUsername, + webUrl: authorWebUrl, + }, issueId: Number(issuableId), - issuableStatus: state, + issuableStatus: this.getNoteableData?.state, + issuableType: TYPE_INCIDENT, descriptionComponent: IncidentTabs, showTitleBorder: false, isConfidential: this.getNoteableData?.confidential, @@ -97,12 +125,17 @@ export function initIssueApp(issueData, store) { } const { fullPath, registerPath, signInPath } = el.dataset; + const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData)); scrollToTargetOnResize(); - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + bootstrapApollo({ ...issueState, issueType: TYPE_ISSUE }); const { + authorId, + authorName, + authorUsername, + authorWebUrl, canCreateIncident, hasIssueWeightsFeature, hasIterationsFeature, @@ -121,6 +154,26 @@ export function initIssueApp(issueData, store) { signInPath, hasIssueWeightsFeature, hasIterationsFeature, + // for HeaderActions component + canCreateIssue: 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, + submitAsSpamPath: headerActionsData.submitAsSpamPath, + issuableEmailAddress: headerActionsData.issuableEmailAddress, }, computed: { ...mapGetters(['getNoteableData']), @@ -129,6 +182,12 @@ export function initIssueApp(issueData, store) { return createElement(IssueApp, { props: { ...issueProps, + author: { + id: authorId, + name: authorName, + username: authorUsername, + webUrl: authorWebUrl, + }, isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, diff --git a/app/assets/javascripts/jira_connect/branches/pages/index.vue b/app/assets/javascripts/jira_connect/branches/pages/index.vue index 953e823ec96..3824e2350e8 100644 --- a/app/assets/javascripts/jira_connect/branches/pages/index.vue +++ b/app/assets/javascripts/jira_connect/branches/pages/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlEmptyState } from '@gitlab/ui'; import { sprintf } from '~/locale'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js index 184635e63f3..17e654fd6f8 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/api.js +++ b/app/assets/javascripts/jira_connect/subscriptions/api.js @@ -26,9 +26,14 @@ export const removeSubscription = async (removePath) => { }); }; -export const fetchGroups = async (groupsPath, { page, perPage, search }, accessToken = null) => { +export const fetchGroups = async ( + groupsPath, + { minAccessLevel, page, perPage, search }, + accessToken = null, +) => { return axiosInstance.get(groupsPath, { params: { + min_access_level: minAccessLevel, page, per_page: perPage, search, diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue index 3d02dcb1198..fb74306afc0 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; import { fetchGroups } from '~/jira_connect/subscriptions/api'; @@ -6,6 +7,7 @@ import { DEFAULT_GROUPS_PER_PAGE, MINIMUM_SEARCH_TERM_LENGTH, } from '~/jira_connect/subscriptions/constants'; +import { ACCESS_LEVEL_MAINTAINER_INTEGER } from '~/access_level/constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import GroupsListItem from './groups_list_item.vue'; @@ -36,10 +38,13 @@ export default { }; }, computed: { + ...mapState(['accessToken', 'currentUser']), showPagination() { return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0; }, - ...mapState(['accessToken']), + isAdmin() { + return Boolean(this.currentUser.is_admin); + }, }, mounted() { return this.loadGroups().finally(() => { @@ -52,6 +57,7 @@ export default { return fetchGroups( this.groupsPath, { + minAccessLevel: this.isAdmin ? undefined : ACCESS_LEVEL_MAINTAINER_INTEGER, page: this.page, perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, search: this.searchValue, @@ -110,7 +116,10 @@ export default { @input="onGroupSearch" /> - <p class="gl-mb-3"> + <p v-if="isAdmin" class="gl-mb-3"> + {{ s__('JiraConnect|Not seeing your groups? Only groups you have access to appear here.') }} + </p> + <p v-else class="gl-mb-3"> {{ s__( 'JiraConnect|Not seeing your groups? Only groups you have at least the Maintainer role for appear here.', diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue index cd0f4c2f66f..d283da1649f 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { GlButton } from '@gitlab/ui'; import GroupItemName from '../group_item_name.vue'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index c5f6f736626..d916e7ec798 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -1,6 +1,7 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapMutations, mapActions } from 'vuex'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; import AccessorUtilities from '~/lib/utils/accessor'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index ba264d0be34..1bf8af523a9 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapMutations } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { sprintf } from '~/locale'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue index a765040a6e7..83a26a88a4d 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlTableLite } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapMutations, mapState } from 'vuex'; import { removeSubscription } from '~/jira_connect/subscriptions/api'; import { reloadPage } from '~/jira_connect/subscriptions/utils'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue index e05eb900efa..f2a1afaffab 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapMutations } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue index ac30fa2faa0..dab987675fc 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/index.js b/app/assets/javascripts/jira_connect/subscriptions/store/index.js index abad1920bcc..8cf9ec4d28c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js index f35f79e3f53..ba17a068c72 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js @@ -1,4 +1,5 @@ export default function createState({ + accessToken = null, subscriptions = [], subscriptionsLoading = false, currentUser = null, @@ -13,6 +14,6 @@ export default function createState({ currentUser, currentUserError: null, - accessToken: null, + accessToken, }; } diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index a5a92a3c4ff..52030a0f830 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui'; 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 SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index d3b2ddc5422..356d65e1d14 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -203,7 +203,7 @@ export default { <template> <gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" /> <div v-else class="row gl-justify-content-center"> - <div class="col-10" data-testid="manual-vars-form"> + <div class="col-10"> <label>{{ $options.i18n.header }}</label> <div diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue index 1c7ba1d331b..a78cacf110f 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue @@ -1,14 +1,32 @@ <script> -import { GlButton, GlButtonGroup, GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { + i18n: { + jobArtifacts: s__('Job|Job artifacts'), + artifactsHelpText: s__( + 'Job|Job artifacts are files that are configured to be uploaded when a job finishes execution. Artifacts could be compiled files, unit tests or scanning reports, or any other files generated by a job.', + ), + expiredText: s__('Job|The artifacts were removed'), + willExpireText: s__('Job|The artifacts will be removed'), + lockedText: s__( + 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', + ), + keepText: s__('Job|Keep'), + downloadText: s__('Job|Download'), + browseText: s__('Job|Browse'), + }, + artifactsHelpPath: helpPagePath('ci/jobs/job_artifacts'), components: { GlButton, GlButtonGroup, GlIcon, GlLink, + GlPopover, TimeagoTooltip, }, mixins: [timeagoMixin], @@ -38,16 +56,28 @@ export default { </script> <template> <div> - <div class="title gl-font-weight-bold">{{ s__('Job|Job artifacts') }}</div> + <div class="title gl-font-weight-bold"> + <span class="gl-mr-2">{{ $options.i18n.jobArtifacts }}</span> + <gl-link :href="$options.artifactsHelpPath" data-testid="artifacts-help-link"> + <gl-icon id="artifacts-help" name="question-o" /> + </gl-link> + <gl-popover + target="artifacts-help" + :title="$options.i18n.jobArtifacts" + triggers="hover focus" + > + {{ $options.i18n.artifactsHelpText }} + </gl-popover> + </div> <p v-if="isExpired || willExpire" class="build-detail-row" data-testid="artifacts-remove-timeline" > - <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span> - <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{ - s__('Job|The artifacts will be removed') - }}</span> + <span v-if="isExpired">{{ $options.i18n.expiredText }}</span> + <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content"> + {{ $options.i18n.willExpireText }} + </span> <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> <gl-link :href="helpUrl" @@ -59,11 +89,9 @@ 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">{{ - s__( - 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', - ) - }}</span> + <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content"> + {{ $options.i18n.lockedText }} + </span> </p> <gl-button-group class="gl-display-flex gl-mt-3"> <gl-button @@ -71,7 +99,7 @@ export default { :href="artifact.keep_path" data-method="post" data-testid="keep-artifacts" - >{{ s__('Job|Keep') }}</gl-button + >{{ $options.i18n.keepText }}</gl-button > <gl-button v-if="artifact.download_path" @@ -79,14 +107,14 @@ export default { rel="nofollow" data-testid="download-artifacts" download - >{{ s__('Job|Download') }}</gl-button + >{{ $options.i18n.downloadText }}</gl-button > <gl-button v-if="artifact.browse_path" :href="artifact.browse_path" data-testid="browse-artifacts" data-qa-selector="browse_artifacts_button" - >{{ s__('Job|Browse') }}</gl-button + >{{ $options.i18n.browseText }}</gl-button > </gl-button-group> </div> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue index e70f9199b55..87c47f592aa 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -1,5 +1,6 @@ <script> 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'; diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue index 69271cc9022..92e1557ada2 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue @@ -1,6 +1,7 @@ <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 ArtifactsBlock from './artifacts_block.vue'; diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue index d791705d80d..56fcd8738d7 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { createAlert } from '~/alert'; import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue index 3cd90eb3bca..09335476008 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { GlBadge } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index 36b350f4d64..3c9c5097122 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -1,6 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> +import { getLocationHash } from '~/lib/utils/url_utility'; import { linkRegex } from '../../utils'; - import LineNumber from './line_number.vue'; export default { @@ -63,10 +64,19 @@ export default { }); } + if (window.location.hash) { + const hash = getLocationHash(); + const lineToMatch = `L${line.lineNumber + 1}`; + + if (hash === lineToMatch) { + applyHighlight = true; + } + } + return h( 'div', { - class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-500' : ''], + class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-700' : ''], }, [ h(LineNumber, { diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue index de774e8408b..115b090b32a 100644 --- a/app/assets/javascripts/jobs/components/log/line_header.vue +++ b/app/assets/javascripts/jobs/components/log/line_header.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { getLocationHash } from '~/lib/utils/url_utility'; import DurationBadge from './duration_badge.vue'; import LineNumber from './line_number.vue'; @@ -32,6 +33,12 @@ export default { iconName() { return this.isClosed ? 'chevron-lg-right' : 'chevron-lg-down'; }, + applyHighlight() { + const hash = getLocationHash(); + const lineToMatch = `L${this.line.lineNumber + 1}`; + + return hash === lineToMatch; + }, }, methods: { handleOnClick() { @@ -44,6 +51,7 @@ 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 }" role="button" @click="handleOnClick" > diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index ba1801f5c58..6a1101bf297 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -1,4 +1,6 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { scrollToElement } from '~/lib/utils/common_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/jobs/store/index.js b/app/assets/javascripts/jobs/store/index.js index 467c692b438..b9d76765d8d 100644 --- a/app/assets/javascripts/jobs/store/index.js +++ b/app/assets/javascripts/jobs/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js index c7c17607af6..bb3975ce61d 100644 --- a/app/assets/javascripts/labels/index.js +++ b/app/assets/javascripts/labels/index.js @@ -126,6 +126,9 @@ export function initAdminLabels() { 'ul.manage-labels-list li.label-list-item:not(.gl-display-none\\!)', ).length; + // update labels count in UI + document.querySelector('.js-admin-labels-count').innerText = labelsCount; + // display the empty state if there are no more labels if (labelsCount < 1 && !pagination && emptyState) { emptyState.classList.remove('gl-display-none'); diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js index f8ae180107c..56f6606808e 100644 --- a/app/assets/javascripts/lib/apollo/persistence_mapper.js +++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js @@ -50,7 +50,7 @@ export const persistenceMapper = async (data) => { // we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once // with different set of fields - if (Object.values(rootQuery).some((value) => value.__ref === key)) { + if (Object.values(rootQuery).some((value) => value?.__ref === key)) { const mappedEntity = {}; Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => { if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) { diff --git a/app/assets/javascripts/lib/mousetrap.js b/app/assets/javascripts/lib/mousetrap.js index ef3f54ec314..297ca00f4e4 100644 --- a/app/assets/javascripts/lib/mousetrap.js +++ b/app/assets/javascripts/lib/mousetrap.js @@ -56,4 +56,6 @@ export const clearStopCallbacksForTests = () => { additionalStopCallbacks.length = 0; }; +export const MOUSETRAP_COPY_KEYBOARD_SHORTCUT = 'mod+c'; + export { Mousetrap }; diff --git a/app/assets/javascripts/lib/print_markdown_dom.js b/app/assets/javascripts/lib/print_markdown_dom.js new file mode 100644 index 00000000000..fb5ea09b6c8 --- /dev/null +++ b/app/assets/javascripts/lib/print_markdown_dom.js @@ -0,0 +1,50 @@ +function getPrintContent(target, ignoreSelectors) { + const cloneDom = target.cloneNode(true); + cloneDom.querySelectorAll('details').forEach((detail) => { + detail.setAttribute('open', ''); + }); + + if (Array.isArray(ignoreSelectors) && ignoreSelectors.length > 0) { + cloneDom.querySelectorAll(ignoreSelectors.join(',')).forEach((ignoredNode) => { + ignoredNode.remove(); + }); + } + + cloneDom.querySelectorAll('img').forEach((img) => { + img.setAttribute('loading', 'eager'); + }); + + return cloneDom.innerHTML; +} + +function getTitleContent(title) { + const titleElement = document.createElement('h2'); + titleElement.className = 'gl-mt-0 gl-mb-5'; + titleElement.innerText = title; + return titleElement.outerHTML; +} + +export default async function printMarkdownDom({ + target, + title, + ignoreSelectors = [], + stylesheet = [], +}) { + const printJS = (await import('print-js')).default; + + const printContent = getPrintContent(target, ignoreSelectors); + + const titleElement = title ? getTitleContent(title) : ''; + + const markdownElement = `<div class="md">${printContent}</div>`; + + const printable = titleElement + markdownElement; + + printJS({ + printable, + type: 'raw-html', + documentTitle: title, + scanStyles: false, + css: stylesheet, + }); +} diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index d1e5e4eea13..aceae188b73 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -15,6 +15,7 @@ export const DATETIME_RANGE_TYPES = { export const BV_SHOW_MODAL = 'bv::show::modal'; export const BV_HIDE_MODAL = 'bv::hide::modal'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; +export const BV_SHOW_TOOLTIP = 'bv::show::tooltip'; export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; diff --git a/app/assets/javascripts/lib/utils/error_utils.js b/app/assets/javascripts/lib/utils/error_utils.js new file mode 100644 index 00000000000..82dba803c3e --- /dev/null +++ b/app/assets/javascripts/lib/utils/error_utils.js @@ -0,0 +1,149 @@ +import { isEmpty, isString, isObject } from 'lodash'; +import { sprintf, __ } from '~/locale'; + +export class ActiveModelError extends Error { + constructor(errorAttributeMap = {}, ...params) { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(...params); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ActiveModelError); + } + + this.name = 'ActiveModelError'; + // Custom debugging information + this.errorAttributeMap = errorAttributeMap; + } +} + +const DEFAULT_ERROR = { + message: __('Something went wrong. Please try again.'), + links: {}, +}; + +/** + * @typedef {Object<ErrorAttribute,ErrorType[]>} ErrorAttributeMap - Map of attributes to error details + * @typedef {string} ErrorAttribute - the error attribute https://api.rubyonrails.org/v7.0.4.2/classes/ActiveModel/Error.html + * @typedef {string} ErrorType - the error type https://api.rubyonrails.org/v7.0.4.2/classes/ActiveModel/Error.html + * + * @example { "email": ["taken", ...] } + * // returns `${UNLINKED_ACCOUNT_ERROR}`, i.e. the `EMAIL_TAKEN_ERROR_TYPE` error message + * + * @param {ErrorAttributeMap} errorAttributeMap + * @param {Object} errorDictionary + * @returns {(null|string)} null or error message if found + */ +function getMessageFromType(errorAttributeMap = {}, errorDictionary = {}) { + if (!isObject(errorAttributeMap)) { + return null; + } + + return Object.keys(errorAttributeMap).reduce((_, attribute) => { + const errorType = errorAttributeMap[attribute].find( + (type) => errorDictionary[`${attribute}:${type}`.toLowerCase()], + ); + if (errorType) { + return errorDictionary[`${attribute}:${errorType}`.toLowerCase()]; + } + + return null; + }, null); +} + +/** + * @example "Email has already been taken, Email is invalid" + * // returns `${UNLINKED_ACCOUNT_ERROR}`, i.e. the `EMAIL_TAKEN_ERROR_TYPE` error message + * + * @param {string} errorString + * @param {Object} errorDictionary + * @returns {(null|string)} null or error message if found + */ +function getMessageFromErrorString(errorString, errorDictionary = {}) { + if (isEmpty(errorString) || !isString(errorString)) { + return null; + } + + const messages = errorString.split(', '); + const errorMessage = messages.find((message) => errorDictionary[message.toLowerCase()]); + if (errorMessage) { + return errorDictionary[errorMessage.toLowerCase()]; + } + + return { + message: errorString, + links: {}, + }; +} + +/** + * Receives an Error and attempts to extract the `errorAttributeMap` in + * case it is an `ActiveModelError` and returns the message if it exists. + * If a match is not found it will attempt to map a message from the + * Error.message to be returned. + * Otherwise, it will return a general error message. + * + * @param {Error|String} systemError + * @param {Object} errorDictionary + * @param {Object} defaultError + * @returns error message + */ +export function mapSystemToFriendlyError( + systemError, + errorDictionary = {}, + defaultError = DEFAULT_ERROR, +) { + if (systemError instanceof String || typeof systemError === 'string') { + const messageFromErrorString = getMessageFromErrorString(systemError, errorDictionary); + if (messageFromErrorString) { + return messageFromErrorString; + } + return defaultError; + } + + if (!(systemError instanceof Error)) { + return defaultError; + } + + const { errorAttributeMap, message } = systemError; + const messageFromType = getMessageFromType(errorAttributeMap, errorDictionary); + if (messageFromType) { + return messageFromType; + } + + const messageFromErrorString = getMessageFromErrorString(message, errorDictionary); + if (messageFromErrorString) { + return messageFromErrorString; + } + + return defaultError; +} + +function generateLinks(links) { + return Object.keys(links).reduce((allLinks, link) => { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + const linkStart = `${link}Start`; + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + const linkEnd = `${link}End`; + + return { + ...allLinks, + [linkStart]: `<a href="${links[link]}" target="_blank" rel="noopener noreferrer">`, + [linkEnd]: '</a>', + }; + }, {}); +} + +export const generateHelpTextWithLinks = (error) => { + if (isString(error)) { + return error; + } + + if (isEmpty(error)) { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + throw new Error('The error cannot be empty.'); + } + + const links = generateLinks(error.links); + return sprintf(error.message, links, false); +}; diff --git a/app/assets/javascripts/lib/utils/file_utility.js b/app/assets/javascripts/lib/utils/file_utility.js new file mode 100644 index 00000000000..e5a41f3b042 --- /dev/null +++ b/app/assets/javascripts/lib/utils/file_utility.js @@ -0,0 +1,12 @@ +/** + * Takes a file object and returns a data uri of its contents. + * + * @param {File} file + */ +export function readFileAsDataURL(file) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener('load', (e) => resolve(e.target.result), { once: true }); + reader.readAsDataURL(file); + }); +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 42f481261a2..31e16f7b4db 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -167,36 +167,6 @@ export const truncateWidth = (string, options = {}) => { */ export const truncateSha = (sha) => sha.substring(0, 8); -const ELLIPSIS_CHAR = '…'; -export const truncatePathMiddleToLength = (text, maxWidth) => { - let returnText = text; - let ellipsisCount = 0; - - while (returnText.length >= maxWidth) { - const textSplit = returnText.split('/').filter((s) => s !== ELLIPSIS_CHAR); - - if (textSplit.length === 0) { - // There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth - const maxSegments = Math.floor((maxWidth + 1) / 2); - return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/'); - } - - const middleIndex = Math.floor(textSplit.length / 2); - - returnText = textSplit - .slice(0, middleIndex) - .concat( - new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR), - textSplit.slice(middleIndex + 1), - ) - .join('/'); - - ellipsisCount += 1; - } - - return returnText; -}; - /** * Capitalizes first character * diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 85740117c00..08c98298121 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -20,6 +20,7 @@ export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com // About Gitlab default url export const PROMO_URL = `https://${PROMO_HOST}`; +// eslint-disable-next-line no-restricted-syntax export const DOCS_URL_IN_EE_DIR = `${DOCS_URL}/ee`; // Reset the cursor in a Regex so that multiple uses before a recompile don't fail @@ -686,11 +687,23 @@ export function redirectTo(url) { } /** - * Navigates to a URL - * @param {*} url - url to navigate to + * Navigates to a URL. + * + * If destination is a querystring, it will be automatically transformed into a fully qualified URL. + * If the URL is not a safe URL (see isSafeURL implementation), this function will log an exception into Sentry. + * + * @param {*} destination - url to navigate to. This can be a fully qualified URL or a querystring. * @param {*} external - if true, open a new page or tab */ -export function visitUrl(url, external = false) { +export function visitUrl(destination, external = false) { + let url = destination; + + if (destination.startsWith('?')) { + const currentUrl = new URL(window.location.href); + currentUrl.search = destination; + url = currentUrl.toString(); + } + 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 @@ -713,3 +726,16 @@ export function visitUrl(url, external = false) { export function refreshCurrentPage() { visitUrl(window.location.href); } + +// Adds a ref_type param to the path if refType is available +export function buildURLwithRefType({ base = window.location.origin, path, refType = null }) { + const url = new URL('', base); + url.pathname = path; // This assignment does proper _escapes_ + + if (refType) { + url.searchParams.set('ref_type', refType.toLowerCase()); + } else { + url.searchParams.delete('ref_type'); + } + return url.pathname + url.search; +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js index 62054d5a80d..daea9815d60 100644 --- a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js @@ -67,7 +67,16 @@ const transformers = { const transformOptions = (options = {}) => { const defaultConfig = { - routes: [], + routes: [ + { + path: '/', + component: { + render() { + return ''; + }, + }, + }, + ], history: createWebHashHistory(), }; return Object.keys(options).reduce((acc, key) => { diff --git a/app/assets/javascripts/lib/utils/vuex_module_mappers.js b/app/assets/javascripts/lib/utils/vuex_module_mappers.js index 95a794dd268..5298eb67c2b 100644 --- a/app/assets/javascripts/lib/utils/vuex_module_mappers.js +++ b/app/assets/javascripts/lib/utils/vuex_module_mappers.js @@ -1,4 +1,5 @@ import { mapValues, isString } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; export const REQUIRE_STRING_ERROR_MESSAGE = 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 88d5384c9d5..1ae341820d1 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 @@ -1,5 +1,6 @@ <script> import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 3b4b7516934..2f10a333bf4 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { s__ } from '~/locale'; 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 4b3bb89da55..caa292b37ce 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 @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; export default { diff --git a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue index 2173974c6f4..6b0a236c586 100644 --- a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue index 627b47a1e81..920febb0e67 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue @@ -1,5 +1,6 @@ <script> import { GlDisclosureDropdownItem } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; export default { diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index c5083bc4826..a70ee8fc865 100644 --- a/app/assets/javascripts/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapMutations } from 'vuex'; import { scrollToElement } from '~/lib/utils/common_utils'; import { HIDE_ERROR } from '../store/mutation_types'; diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue index 419b7b83c0f..ae809b26f24 100644 --- a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue +++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import MembersFilteredSearchBar from './members_filtered_search_bar.vue'; import SortDropdown from './sort_dropdown.vue'; diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index 8cdaa76e673..0e5e394dd40 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { __ } from '~/locale'; import { diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue index 01f145e0862..b61b2bdd0c9 100644 --- a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue +++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue @@ -1,5 +1,6 @@ <script> import { GlSorting, GlSortingItem } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { visitUrl } from '~/lib/utils/url_utility'; import { FIELDS } from '~/members/constants'; diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 5ac8b30614d..75241d1ff26 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -1,5 +1,6 @@ <script> import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { __ } from '~/locale'; import { queryToObject } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index 8bc6aca9cc1..0f76cb6e9d8 100644 --- a/app/assets/javascripts/members/components/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index b28ca6e385b..18db8fe9cfb 100644 --- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlSprintf, GlForm } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; 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 f1da1cd8ffc..c7bd1525558 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -1,5 +1,6 @@ <script> import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { s__, __ } from '~/locale'; @@ -103,7 +104,6 @@ export default { :title="actionText" :visible="removeMemberModalVisible" data-qa-selector="remove_member_modal" - data-testid="remove-member-modal-content" @primary="submitForm" @hide="hideRemoveMemberModal" > diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue index 9f6e8979102..f28f4e6f605 100644 --- a/app/assets/javascripts/members/components/table/expiration_datepicker.vue +++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue @@ -1,5 +1,6 @@ <script> import { GlDatepicker } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { getDateInFuture } from '~/lib/utils/datetime_utility'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index f6fd84c46cb..68f624e9a3d 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -1,5 +1,6 @@ <script> import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; import { diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index c854d865869..4b39c000b8f 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -1,6 +1,7 @@ <script> import { GlCollapsibleListbox } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index c7398127727..87ae670c146 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -1,5 +1,6 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { parseDataAttributes } from '~/members/utils'; import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue index c6feb684795..4f638dfdf42 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -1,6 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue index 6c431dc8af3..2c4845b85ad 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import syntaxHighlight from '~/syntax_highlight'; diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue index f8a097a3a0f..cb45d0f3c76 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import syntaxHighlight from '~/syntax_highlight'; diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue index af66600089f..d80517c1c1f 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -1,5 +1,6 @@ <script> import { GlSprintf, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState, mapActions } from 'vuex'; import { __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; diff --git a/app/assets/javascripts/merge_conflicts/store/index.js b/app/assets/javascripts/merge_conflicts/store/index.js index 18e3351ed13..f3a0ba3f89d 100644 --- a/app/assets/javascripts/merge_conflicts/store/index.js +++ b/app/assets/javascripts/merge_conflicts/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 883b9e6919b..09fe611262c 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -183,7 +183,7 @@ function getActionFromHref(href) { } const pageBundles = { - show: () => import(/* webpackPrefetch: true */ 'ee_else_ce/mr_notes/mount_app'), + show: () => import(/* webpackPrefetch: true */ '~/mr_notes/mount_app'), diffs: () => import(/* webpackPrefetch: true */ '~/diffs'), }; diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index 362ecca6d6c..3c3bee9b108 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -1,5 +1,6 @@ <script> import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index 5c3b969655b..2f7fb542d0e 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -9,6 +9,7 @@ import { GlIcon, } from '@gitlab/ui'; import { debounce, isEqual } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { s__, __, sprintf } from '~/locale'; import createStore from '../stores'; diff --git a/app/assets/javascripts/milestones/stores/index.js b/app/assets/javascripts/milestones/stores/index.js index 2bebffc19ab..44ad5468dcd 100644 --- a/app/assets/javascripts/milestones/stores/index.js +++ b/app/assets/javascripts/milestones/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 1795363f24c..04167518d3f 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { apolloProvider } from '~/graphql_shared/issuable_client'; @@ -12,7 +13,7 @@ import NotesApp from '../notes/components/notes_app.vue'; import { getNotesFilterData } from '../notes/utils/get_notes_filter_data'; import initWidget from '../vue_merge_request_widget'; -export default ({ editorAiActions = [] } = {}) => { +export default () => { requestIdleCallback( () => { renderGFM(document.getElementById('diff-notes-app')); @@ -42,7 +43,6 @@ export default ({ editorAiActions = [] } = {}) => { provide: { reportAbusePath: notesDataset.reportAbusePath, newCommentTemplatePath: notesDataset.newCommentTemplatePath, - editorAiActions, mrFilter: true, }, data() { diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js index 1f8e61beff0..7cd8d073f54 100644 --- a/app/assets/javascripts/mr_notes/stores/index.js +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'; import diffsModule from '~/diffs/store/modules'; diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index c36c56d7e43..4e5d6b0ce6c 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -68,14 +68,14 @@ export default { <gl-disclosure-dropdown-item v-if="newNavigation" @action="toggleNav"> <div class="gl-new-dropdown-item-content"> <div - class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2!" + class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2! gl-gap-3" > {{ $options.i18n.toggleMenuItemLabel }} <gl-toggle + class="gl-flex-grow-0!" :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" - data-testid="new-navigation-toggle" /> </div> </div> @@ -89,11 +89,12 @@ export default { </div> <div - class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center" + class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-gap-3" @click.prevent.stop="toggleNav" > {{ $options.i18n.toggleMenuItemLabel }} <gl-toggle + class="gl-flex-grow-0!" :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js index 7b0cc977107..0fc946bea76 100644 --- a/app/assets/javascripts/nav/mount.js +++ b/app/assets/javascripts/nav/mount.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import ResponsiveApp from './components/responsive_app.vue'; import App from './components/top_nav_app.vue'; diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js index 527bbdd5c3f..7c8f93f042c 100644 --- a/app/assets/javascripts/nav/stores/index.js +++ b/app/assets/javascripts/nav/stores/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { createStoreOptions } from '~/frequent_items/store'; diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 2caa93c3c93..2c8b41063bd 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import katex from 'katex'; import { marked } from 'marked'; diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 74a5dd3806d..de7fbaf5090 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import Prompt from '../prompt.vue'; diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue index da7d83539d3..228385697ae 100644 --- a/app/assets/javascripts/notebook/cells/output/image.vue +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import Prompt from '../prompt.vue'; diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 0437b85913b..c8268b1a9ae 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import CodeOutput from '../code/index.vue'; import HtmlOutput from './html.vue'; diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue index 1eeb61844a4..2acd30eb3e1 100644 --- a/app/assets/javascripts/notebook/cells/prompt.vue +++ b/app/assets/javascripts/notebook/cells/prompt.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> export default { props: { diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue index 5f254cae73d..59637ee2cff 100644 --- a/app/assets/javascripts/notebook/index.vue +++ b/app/assets/javascripts/notebook/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { MarkdownCell, CodeCell } from './cells'; diff --git a/app/assets/javascripts/notes/components/attachments_warning.vue b/app/assets/javascripts/notes/components/attachments_warning.vue index aaa4b0d92b9..aa19dd58c0f 100644 --- a/app/assets/javascripts/notes/components/attachments_warning.vue +++ b/app/assets/javascripts/notes/components/attachments_warning.vue @@ -12,7 +12,7 @@ export default { </script> <template> - <div class="issuable-note-warning" data-testid="attachment-warning"> + <div class="issuable-note-warning"> {{ message }} </div> </template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index c6d94a3b7b7..a009f2975bb 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -1,6 +1,7 @@ <script> import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; import $ from 'jquery'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index e7b7ba7743e..75cacd9ace0 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,6 +1,7 @@ <script> import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; import { escape } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { truncateSha } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index b1a2ab77fa8..f08c005259c 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,5 +1,6 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index cff1043c258..d8883f90eda 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlButtonGroup, GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import { throttle } from 'lodash'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 692fd6cc500..7266cdb6405 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -5,6 +5,7 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 080787884c8..79157c3f99c 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; diff --git a/app/assets/javascripts/notes/components/email_participants_warning.vue b/app/assets/javascripts/notes/components/email_participants_warning.vue index 1875d48e7b2..cf9108992be 100644 --- a/app/assets/javascripts/notes/components/email_participants_warning.vue +++ b/app/assets/javascripts/notes/components/email_participants_warning.vue @@ -55,7 +55,7 @@ export default { </script> <template> - <div class="issuable-note-warning" data-testid="email-participants-warning"> + <div class="issuable-note-warning"> <gl-sprintf :message="message"> <template #andMore> <button type="button" class="gl-button btn-link" @click="showMoreParticipants"> diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue index 7ca0c4730a9..08d3670ae6a 100644 --- a/app/assets/javascripts/notes/components/mr_discussion_filter.vue +++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue @@ -1,5 +1,6 @@ <script> import { GlCollapsibleListbox, GlButton, GlIcon, GlSprintf, GlButtonGroup } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue index 1633b79c3be..2c2264c36f3 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_form.vue +++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue @@ -1,5 +1,6 @@ <script> import { GlFormSelect, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { getSymbol, getLineClasses } from './multiline_comment_utils'; diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 8d2d8095a44..7f23ee70086 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -5,6 +5,7 @@ import { GlDisclosureDropdown, GlDisclosureDropdownItem, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 9c04a72375b..21f226cd207 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 1c6be0cfd77..fcb9dc43e8e 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,5 +1,6 @@ <script> import { escape } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 4e816038539..8b43f068f11 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions, mapState } from 'vuex'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -245,15 +246,16 @@ export default { }, methods: { ...mapActions(['toggleResolveNote']), - shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState) { - const newResolvedStateAfterUpdate = - this.shouldBeResolved && this.shouldBeResolved(shouldResolve); - - const shouldToggleState = - newResolvedStateAfterUpdate !== undefined && - beforeSubmitDiscussionState !== newResolvedStateAfterUpdate; - - return shouldResolve || shouldToggleState; + shouldToggleResolved(beforeSubmitDiscussionState) { + return ( + this.showResolveDiscussionToggle && beforeSubmitDiscussionState !== this.newResolvedState() + ); + }, + newResolvedState() { + return ( + (this.discussionResolved && !this.isUnresolving) || + (!this.discussionResolved && this.isResolving) + ); }, editMyLastNote() { if (this.updatedNoteBody === '') { @@ -293,7 +295,7 @@ export default { } this.updatedNoteBody = ''; }, - handleUpdate(shouldResolve) { + handleUpdate() { const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; @@ -309,23 +311,13 @@ export default { () => { this.isSubmitting = false; - if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) { + if (this.shouldToggleResolved(beforeSubmitDiscussionState)) { this.resolveHandler(beforeSubmitDiscussionState); } }, this.discussionResolved ? !this.isUnresolving : this.isResolving, ); }, - shouldBeResolved(resolveStatus) { - if (this.withBatchComments) { - return ( - (this.discussionResolved && !this.isUnresolving) || - (!this.discussionResolved && this.isResolving) - ); - } - - return resolveStatus; - }, handleAddToReview() { // check if draft should resolve thread const shouldResolve = @@ -390,21 +382,22 @@ export default { /> </comment-field-layout> <div class="note-form-actions"> + <p v-if="showResolveDiscussionToggle"> + <label> + <template v-if="discussionResolved"> + <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox"> + {{ __('Unresolve thread') }} + </gl-form-checkbox> + </template> + <template v-else> + <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox"> + {{ __('Resolve thread') }} + </gl-form-checkbox> + </template> + </label> + </p> + <template v-if="showBatchCommentsActions"> - <p v-if="showResolveDiscussionToggle"> - <label> - <template v-if="discussionResolved"> - <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox"> - {{ __('Unresolve thread') }} - </gl-form-checkbox> - </template> - <template v-else> - <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox"> - {{ __('Resolve thread') }} - </gl-form-checkbox> - </template> - </label> - </p> <div class="gl-display-flex gl-flex-wrap gl-mb-n3"> <gl-button :disabled="isDisabled" @@ -451,15 +444,6 @@ export default { {{ saveButtonTitle }} </gl-button> <gl-button - v-if="discussion.resolvable" - category="secondary" - variant="default" - class="gl-sm-mr-3 gl-xs-mb-3 js-comment-resolve-button" - @click.prevent="handleUpdate(true)" - > - {{ resolveButtonTitle }} - </gl-button> - <gl-button class="note-edit-cancel js-close-discussion-note-form" category="secondary" variant="default" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 83cebb9a0e0..bdf9ea2057c 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 94636b3e47b..30d3bfcb989 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 7e79edfea15..94d5dc25b9e 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import { createAlert } from '~/alert'; diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 69c41af97ab..9a7cc1a4d37 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,6 +2,7 @@ import { GlSprintf, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import $ from 'jquery'; import { escape, isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 06c925002b6..6fb958e810b 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -231,7 +232,7 @@ export default { :ai-loading="aiLoading" @set-ai-loading="setAiLoading" /> - <ai-summary v-if="aiLoading !== null" @set-ai-loading="setAiLoading" /> + <ai-summary v-if="aiLoading !== null" :ai-loading="aiLoading" @set-ai-loading="setAiLoading" /> <ordered-layout :slot-keys="slotKeys"> <template #form> <comment-form diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 2a0a3d5414f..f60a17eb36b 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue index 59a3cc2d306..a627047faf9 100644 --- a/app/assets/javascripts/notes/components/timeline_toggle.vue +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 419b427682e..999ef8ff905 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -10,6 +10,9 @@ export const COMMENT = 'comment'; export const ISSUE_NOTEABLE_TYPE = 'Issue'; export const EPIC_NOTEABLE_TYPE = 'Epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; +export const SNIPPET_NOTEABLE_TYPE = 'Snippet'; +export const DESIGN_NOTEABLE_TYPE = 'DesignManagement::Design'; +export const COMMIT_NOTEABLE_TYPE = 'Commit'; export const INCIDENT_NOTEABLE_TYPE = 'INCIDENT'; // TODO: check if value can be converted to `Incident` export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index cb6f72538b9..34090d22cec 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils'; import { diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 8e69f1ddc88..212ca6851f6 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions, mapState } from 'vuex'; import { scrollToElement, contentTop } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js index 52b67764b70..54f1a1a0cb3 100644 --- a/app/assets/javascripts/notes/mixins/issuable_state.js +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; export default { diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 63822a31cd1..814702b724d 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -11,14 +11,6 @@ export default { return this.note.resolved; }, resolveButtonTitle() { - if (this.updatedNoteBody) { - if (this.discussionResolved) { - return __('Comment & unresolve thread'); - } - - return __('Comment & resolve thread'); - } - return this.discussionResolved ? __('Unresolve thread') : __('Resolve thread'); }, }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 1bb44988c4d..0444eca9aa7 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import Visibility from 'visibilityjs'; import Vue from 'vue'; +import actionCable from '~/actioncable_consumer'; import Api from '~/api'; import { createAlert, VARIANT_INFO } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; @@ -151,7 +152,30 @@ export const initPolling = ({ state, dispatch, getters, commit }) => { dispatch('setLastFetchedAt', getters.getNotesDataByProp('lastFetchedAt')); - dispatch('poll'); + 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, + }, + { + connected() { + dispatch('fetchUpdatedNotes'); + }, + received(data) { + if (data.event === 'updated') { + dispatch('fetchUpdatedNotes'); + } + }, + }, + ); + } else { + dispatch('poll'); + } + commit(types.SET_IS_POLLING_INITIALIZED, true); }; @@ -491,7 +515,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}} */ if (hasQuickActions && message) { - eTagPoll.makeRequest(); + if (eTagPoll) eTagPoll.makeRequest(); // synchronizing the quick action with the sidebar widget // this is a temporary solution until we have confidentiality real-time updates @@ -592,6 +616,21 @@ const getFetchDataParams = (state) => { return { endpoint, options }; }; +export const fetchUpdatedNotes = ({ commit, state, getters, dispatch }) => { + const { endpoint, options } = getFetchDataParams(state); + + return axios + .get(endpoint, options) + .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; diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index c4895f58656..483e21b340e 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import notesModule from './modules'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index a67928c387b..966f4184780 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -238,26 +238,36 @@ export default { }, [types.UPDATE_NOTE](state, note) { - const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); + const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); // Disable eslint here so we can delete the property that we no longer need // in the note object // eslint-disable-next-line no-param-reassign delete note.base_discussion; - if (noteObj.individual_note) { + if (discussion.individual_note) { if (note.type === constants.DISCUSSION_NOTE) { - noteObj.individual_note = false; + discussion.individual_note = false; } - noteObj.notes.splice(0, 1, note); + discussion.notes.splice(0, 1, note); } else { - const comment = utils.findNoteObjectById(noteObj.notes, note.id); + const comment = utils.findNoteObjectById(discussion.notes, note.id); if (!isEqual(comment, note)) { - noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); + discussion.notes.splice(discussion.notes.indexOf(comment), 1, note); } } + + if (note.resolvable && note.id === discussion.notes[0].id) { + Object.assign(discussion, { + resolvable: note.resolvable, + resolved: note.resolved, + resolved_at: note.resolved_at, + resolved_by: note.resolved_by, + resolved_by_push: note.resolved_by_push, + }); + } }, [types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) { diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue index 2138372d8ad..16dff6ef784 100644 --- a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue +++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue @@ -37,7 +37,6 @@ export default { is-check-item :is-checked="isActive" :class="{ 'is-active': isActive }" - data-testid="notification-item" @click="$emit('item-selected', level)" > <div class="gl-display-flex gl-flex-direction-column"> diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue index c4a928c5e07..12374bcf261 100644 --- a/app/assets/javascripts/oauth_application/components/oauth_secret.vue +++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue @@ -81,6 +81,7 @@ export default { v-if="secret" :copy-button-title="$options.COPY_SECRET" :value="secret" + readonly class="gl-mt-n3 gl-mb-0" > <template #description> diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index 251c165e7dd..c55600f3db2 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -1,4 +1,5 @@ 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 @@ -19,25 +20,169 @@ function isTracingEnabled() { }); } -async function fetchTraces(tracingUrl) { - const { data } = await axios.get(tracingUrl, { withCredentials: true }); - if (!Array.isArray(data.traces)) { +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, + }; +} + +async function fetchTrace(tracingUrl, traceId) { + 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 } - return data.traces.map((t) => { - // aggregating duration on the client for now, but expecting to be coming from the backend - const duration = t.spans.reduce((acc, cur) => acc + cur.duration_nano, 0); - return { - ...t, - duration: duration / 1000, - }; + + const trace = data.traces[0]; + return traceWithDuration(trace); +} + +/** + * Filters (and operators) allowed by tracing query API + */ +const SUPPORTED_FILTERS = { + durationMs: ['>', '<'], + operation: ['=', '!='], + serviceName: ['=', '!='], + period: ['='], + traceId: ['=', '!='], + // free-text 'search' temporarily ignored https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2309 +}; + +/** + * Mapping of filter name to query param + */ +const FILTER_TO_QUERY_PARAM = { + durationMs: 'duration_nano', + operation: 'operation', + serviceName: 'service_name', + period: 'period', + traceId: 'trace_id', +}; + +const FILTER_OPERATORS_PREFIX = { + '!=': 'not', + '>': 'gt', + '<': 'lt', +}; + +/** + * Builds the query param name for the given filter and operator + * + * @param {String} filterName - The filter name + * @param {String} operator - The operator + * @returns String | undefined - Query param name + */ +function getFilterParamName(filterName, operator) { + const paramKey = FILTER_TO_QUERY_PARAM[filterName]; + if (!paramKey) return undefined; + + if (operator === '=') { + return paramKey; + } + + const prefix = FILTER_OPERATORS_PREFIX[operator]; + if (prefix) { + return `${prefix}[${paramKey}]`; + } + + return undefined; +} + +/** + * Builds URLSearchParams from a filter object of type { [filterName]: undefined | null | Array<{operator: String, value: any} } + * e.g: + * + * filterObj = { + * durationMs: [{operator: '>', value: '100'}, {operator: '<', value: '1000' }], + * operation: [{operator: '=', value: 'someOp' }], + * serviceName: [{operator: '!=', value: 'foo' }] + * } + * + * It handles converting the filter to the proper supported query params + * + * @param {Object} filterObj : An Object representing filters + * @returns URLSearchParams + */ +function filterObjToQueryParams(filterObj) { + const filterParams = new URLSearchParams(); + + Object.keys(SUPPORTED_FILTERS).forEach((filterName) => { + const filterValues = filterObj[filterName] || []; + const supportedFilters = filterValues.filter((f) => + SUPPORTED_FILTERS[filterName].includes(f.operator), + ); + supportedFilters.forEach(({ operator, value: rawValue }) => { + const paramName = getFilterParamName(filterName, operator); + + let value = rawValue; + if (filterName === 'durationMs') { + // converting durationMs to duration_nano + value *= 1000; + } + + if (paramName && value) { + filterParams.append(paramName, value); + } + }); }); + return filterParams; +} + +/** + * Fetches traces with given tracing API URL and filters + * + * @param {String} tracingUrl : The API base URL + * @param {Object} filters : A filter object of type: { [filterName]: undefined | null | Array<{operator: String, value: String} } + * e.g: + * + * { + * durationMs: [ {operator: '>', value: '100'}, {operator: '<', value: '1000'}], + * operation: [ {operator: '=', value: 'someOp}], + * serviceName: [ {operator: '!=', value: 'foo}] + * } + * + * @returns Array<Trace> : A list of traces + */ +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 + } + return data.traces.map(traceWithDuration); } export function buildClient({ provisioningUrl, tracingUrl }) { return { enableTraces: () => enableTraces(provisioningUrl), isTracingEnabled: () => isTracingEnabled(provisioningUrl), - fetchTraces: () => fetchTraces(tracingUrl), + fetchTraces: (filters) => fetchTraces(tracingUrl, filters), + fetchTrace: (traceId) => fetchTrace(tracingUrl, traceId), }; } diff --git a/app/assets/javascripts/observability/components/observability_container.vue b/app/assets/javascripts/observability/components/observability_container.vue index 4306f531ab5..b7697cea299 100644 --- a/app/assets/javascripts/observability/components/observability_container.vue +++ b/app/assets/javascripts/observability/components/observability_container.vue @@ -31,8 +31,8 @@ export default { mounted() { window.addEventListener('message', this.messageHandler); - // TODO Remove once backend work done - just for testing - // https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2270 + // TODO: Improve local GDK dev experience with tracing https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2308 + // Uncomment the lines below to to test this locally // setTimeout(() => { // this.messageHandler({ // data: { type: 'AUTH_COMPLETION', status: 'success' }, diff --git a/app/assets/javascripts/observability/components/skeleton/dashboards.vue b/app/assets/javascripts/observability/components/skeleton/dashboards.vue index 8b106407953..887a0a9f094 100644 --- a/app/assets/javascripts/observability/components/skeleton/dashboards.vue +++ b/app/assets/javascripts/observability/components/skeleton/dashboards.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader } from '@gitlab/ui'; diff --git a/app/assets/javascripts/observability/components/skeleton/embed.vue b/app/assets/javascripts/observability/components/skeleton/embed.vue index 7abaf2b1bc7..965beb168bf 100644 --- a/app/assets/javascripts/observability/components/skeleton/embed.vue +++ b/app/assets/javascripts/observability/components/skeleton/embed.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader } from '@gitlab/ui'; diff --git a/app/assets/javascripts/observability/components/skeleton/explore.vue b/app/assets/javascripts/observability/components/skeleton/explore.vue index 1fcbd4fb1cb..3f748086eef 100644 --- a/app/assets/javascripts/observability/components/skeleton/explore.vue +++ b/app/assets/javascripts/observability/components/skeleton/explore.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader } from '@gitlab/ui'; diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue index 4df0f86be1f..d3c6892df50 100644 --- a/app/assets/javascripts/observability/components/skeleton/index.vue +++ b/app/assets/javascripts/observability/components/skeleton/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui'; diff --git a/app/assets/javascripts/observability/components/skeleton/manage.vue b/app/assets/javascripts/observability/components/skeleton/manage.vue index 4b029120328..cf8c900fe11 100644 --- a/app/assets/javascripts/observability/components/skeleton/manage.vue +++ b/app/assets/javascripts/observability/components/skeleton/manage.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader } from '@gitlab/ui'; diff --git a/app/assets/javascripts/observability/mock_traces.json b/app/assets/javascripts/observability/mock_traces.json index 6f83f718d96..ee59258e591 100644 --- a/app/assets/javascripts/observability/mock_traces.json +++ b/app/assets/javascripts/observability/mock_traces.json @@ -1,2807 +1,348 @@ { - "project_id": "1", - "message": "", + "project_id": "10141740", "traces": [ { - "timestamp": "2023-07-10T15:02:30.677538Z", - "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677538Z", - "span_id": "E2CB9B54BB6FCAC1", - "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 147000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677561Z", - "span_id": "4B29015A902EF378", - "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677538Z", - "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677538Z", - "span_id": "E2CB9B54BB6FCAC1", - "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 147000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677561Z", - "span_id": "4B29015A902EF378", - "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.67758Z", - "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.67758Z", - "span_id": "F0788D69026E13A1", - "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677581Z", - "span_id": "14987F8F6FDD27AE", - "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.67758Z", - "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.67758Z", - "span_id": "F0788D69026E13A1", - "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677581Z", - "span_id": "14987F8F6FDD27AE", - "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677583Z", - "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677583Z", - "span_id": "F5AB66F29F53ECAF", - "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677584Z", - "span_id": "17D79F52E57E9C6A", - "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677583Z", - "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677583Z", - "span_id": "F5AB66F29F53ECAF", - "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677584Z", - "span_id": "17D79F52E57E9C6A", - "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677585Z", - "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677585Z", - "span_id": "468B2959252EDA28", - "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677586Z", - "span_id": "7AC8860F5CB85E0A", - "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677585Z", - "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677585Z", - "span_id": "468B2959252EDA28", - "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677586Z", - "span_id": "7AC8860F5CB85E0A", - "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677588Z", - "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677588Z", - "span_id": "3411BDD296DB9370", - "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677589Z", - "span_id": "1774F16A178B8FCB", - "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677588Z", - "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677588Z", - "span_id": "3411BDD296DB9370", - "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677589Z", - "span_id": "1774F16A178B8FCB", - "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677613Z", - "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677613Z", - "span_id": "CDACF24BB78C3534", - "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677614Z", - "span_id": "7B69777569B9EA84", - "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677613Z", - "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677613Z", - "span_id": "CDACF24BB78C3534", - "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677614Z", - "span_id": "7B69777569B9EA84", - "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677616Z", - "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677616Z", - "span_id": "1265DF31CD5EC4E2", - "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677617Z", - "span_id": "3E0260222F729537", - "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677616Z", - "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677616Z", - "span_id": "1265DF31CD5EC4E2", - "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677617Z", - "span_id": "3E0260222F729537", - "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677619Z", - "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677619Z", - "span_id": "3A06AC7ABCA9D043", - "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677619Z", - "span_id": "9C99F917736586E1", - "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677619Z", - "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677619Z", - "span_id": "3A06AC7ABCA9D043", - "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677619Z", - "span_id": "9C99F917736586E1", - "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677621Z", - "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677621Z", - "span_id": "B2417463C771A704", - "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677622Z", - "span_id": "897DD866880697F0", - "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677621Z", - "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677621Z", - "span_id": "B2417463C771A704", - "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677622Z", - "span_id": "897DD866880697F0", - "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677637Z", - "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677637Z", - "span_id": "AB982E41826E4CB4", - "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677638Z", - "span_id": "8577639018E3ACE2", - "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677637Z", - "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677637Z", - "span_id": "AB982E41826E4CB4", - "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677638Z", - "span_id": "8577639018E3ACE2", - "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677651Z", - "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677651Z", - "span_id": "E4D0C62B763FC25C", - "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677653Z", - "span_id": "C059EDEE59610CB1", - "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677651Z", - "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677651Z", - "span_id": "E4D0C62B763FC25C", - "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677653Z", - "span_id": "C059EDEE59610CB1", - "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677654Z", - "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677654Z", - "span_id": "EF63FD474F6898CE", - "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677655Z", - "span_id": "694494E5AA2A2763", - "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677654Z", - "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677654Z", - "span_id": "EF63FD474F6898CE", - "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677655Z", - "span_id": "694494E5AA2A2763", - "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677657Z", - "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677657Z", - "span_id": "A7C41B19AC60C808", - "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677657Z", - "span_id": "57A5EDD6AF5CEB5B", - "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677657Z", - "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677657Z", - "span_id": "A7C41B19AC60C808", - "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677657Z", - "span_id": "57A5EDD6AF5CEB5B", - "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677659Z", - "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677659Z", - "span_id": "EA174F950C4D04D8", - "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.67766Z", - "span_id": "BA21BB5236E2EF8E", - "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677659Z", - "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677659Z", - "span_id": "EA174F950C4D04D8", - "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.67766Z", - "span_id": "BA21BB5236E2EF8E", - "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677661Z", - "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677661Z", - "span_id": "BF7E718C91691CBE", - "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 129000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677666Z", - "span_id": "50F782517AF36EA8", - "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677661Z", - "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677661Z", - "span_id": "BF7E718C91691CBE", - "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 129000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677666Z", - "span_id": "50F782517AF36EA8", - "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677668Z", - "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677668Z", - "span_id": "3B5B0841228A565D", - "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 131000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677669Z", - "span_id": "62ADBDBA30734B48", - "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 130000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677668Z", - "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677668Z", - "span_id": "3B5B0841228A565D", - "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 131000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677669Z", - "span_id": "62ADBDBA30734B48", - "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 130000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677685Z", - "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677685Z", - "span_id": "8A9D888CF37A3F57", - "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677686Z", - "span_id": "388636C7D201F9FB", - "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677685Z", - "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677685Z", - "span_id": "8A9D888CF37A3F57", - "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677686Z", - "span_id": "388636C7D201F9FB", - "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677688Z", - "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677688Z", - "span_id": "9E84A870338BBACA", - "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 128000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677693Z", - "span_id": "FC1665CC8A7536B6", - "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677688Z", - "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677688Z", - "span_id": "9E84A870338BBACA", - "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 128000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677693Z", - "span_id": "FC1665CC8A7536B6", - "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677695Z", - "trace_id": "5154401c-5126-856b-9474-29efb69b8588", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677695Z", - "span_id": "1B5140A527AC99F8", - "trace_id": "5154401c-5126-856b-9474-29efb69b8588", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677695Z", - "span_id": "6C328C8E60FBB3A8", - "trace_id": "5154401c-5126-856b-9474-29efb69b8588", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677695Z", - "trace_id": "5154401c-5126-856b-9474-29efb69b8588", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677695Z", - "span_id": "1B5140A527AC99F8", - "trace_id": "5154401c-5126-856b-9474-29efb69b8588", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677695Z", - "span_id": "6C328C8E60FBB3A8", - "trace_id": "5154401c-5126-856b-9474-29efb69b8588", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677697Z", - "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677697Z", - "span_id": "BC05CC86EF04A641", - "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 129000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677703Z", - "span_id": "3DAF5282D4311F57", - "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677697Z", - "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677697Z", - "span_id": "BC05CC86EF04A641", - "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 129000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677703Z", - "span_id": "3DAF5282D4311F57", - "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677707Z", - "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677707Z", - "span_id": "AEAF8AC47E800113", - "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677708Z", - "span_id": "D6314BCF73DC741F", - "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677707Z", - "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677707Z", - "span_id": "AEAF8AC47E800113", - "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677708Z", - "span_id": "D6314BCF73DC741F", - "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677718Z", - "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677718Z", - "span_id": "D0BEDA55261815BE", - "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677719Z", - "span_id": "E1FA20547B7056ED", - "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677718Z", - "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677718Z", - "span_id": "D0BEDA55261815BE", - "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677719Z", - "span_id": "E1FA20547B7056ED", - "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677721Z", - "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677721Z", - "span_id": "B171E7315A8B7FD1", - "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677721Z", - "span_id": "CD8D690AC2924C06", - "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677721Z", - "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677721Z", - "span_id": "B171E7315A8B7FD1", - "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677721Z", - "span_id": "CD8D690AC2924C06", - "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677723Z", - "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677723Z", - "span_id": "224B0CB973E7D237", - "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677724Z", - "span_id": "452D473BC85DDBA5", - "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677723Z", - "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677723Z", - "span_id": "224B0CB973E7D237", - "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677724Z", - "span_id": "452D473BC85DDBA5", - "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677726Z", - "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677726Z", - "span_id": "6491B39AA9F2CB97", - "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677727Z", - "span_id": "96C2E96EA8D3AF1D", - "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677726Z", - "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677726Z", - "span_id": "6491B39AA9F2CB97", - "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677727Z", - "span_id": "96C2E96EA8D3AF1D", - "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677728Z", - "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677728Z", - "span_id": "3A7F5394923A5AF0", - "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677729Z", - "span_id": "24CC4AB1032650CF", - "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677728Z", - "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677728Z", - "span_id": "3A7F5394923A5AF0", - "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677729Z", - "span_id": "24CC4AB1032650CF", - "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677743Z", - "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677743Z", - "span_id": "F783055E10DA9E2D", - "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677744Z", - "span_id": "E581CC7A539137BF", - "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677743Z", - "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677743Z", - "span_id": "F783055E10DA9E2D", - "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677744Z", - "span_id": "E581CC7A539137BF", - "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677746Z", - "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677746Z", - "span_id": "89BD651A0E16281F", - "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677747Z", - "span_id": "1FADCC9FB8DDE886", - "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677746Z", - "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677746Z", - "span_id": "89BD651A0E16281F", - "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677747Z", - "span_id": "1FADCC9FB8DDE886", - "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677749Z", - "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677749Z", - "span_id": "09E59AECCEDE7725", - "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.67775Z", - "span_id": "9885AAE43420A45B", - "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677749Z", - "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677749Z", - "span_id": "09E59AECCEDE7725", - "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.67775Z", - "span_id": "9885AAE43420A45B", - "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677751Z", - "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677751Z", - "span_id": "CC204570C29BDBF4", - "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677752Z", - "span_id": "D17C651E1245C0F9", - "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677751Z", - "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677751Z", - "span_id": "CC204570C29BDBF4", - "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677752Z", - "span_id": "D17C651E1245C0F9", - "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677755Z", - "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677755Z", - "span_id": "B9C3F1DAF9940B7F", - "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677755Z", - "span_id": "06A614DD43EC1E9A", - "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677755Z", - "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677755Z", - "span_id": "B9C3F1DAF9940B7F", - "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677755Z", - "span_id": "06A614DD43EC1E9A", - "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677757Z", - "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677757Z", - "span_id": "46A99707D5225859", - "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 131000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677763Z", - "span_id": "F489DDD88539BDA4", - "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677757Z", - "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677757Z", - "span_id": "46A99707D5225859", - "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 131000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677763Z", - "span_id": "F489DDD88539BDA4", - "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677773Z", - "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677773Z", - "span_id": "3223B8A1131D2A70", - "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677774Z", - "span_id": "82904DC8C7ED5487", - "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677773Z", - "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677773Z", - "span_id": "3223B8A1131D2A70", - "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677774Z", - "span_id": "82904DC8C7ED5487", - "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677776Z", - "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677776Z", - "span_id": "D9EC63C08230FB02", - "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677777Z", - "span_id": "F504530C5C200E2E", - "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677776Z", - "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677776Z", - "span_id": "D9EC63C08230FB02", - "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677777Z", - "span_id": "F504530C5C200E2E", - "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677778Z", - "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677778Z", - "span_id": "6F0F8D30DF04BA3E", - "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677779Z", - "span_id": "2FF73BB3675EBE65", - "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677778Z", - "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677778Z", - "span_id": "6F0F8D30DF04BA3E", - "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677779Z", - "span_id": "2FF73BB3675EBE65", - "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.67778Z", - "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.67778Z", - "span_id": "3D79FEC1831E8F1A", - "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677781Z", - "span_id": "35D99AA84BCD627A", - "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.67778Z", - "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.67778Z", - "span_id": "3D79FEC1831E8F1A", - "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677781Z", - "span_id": "35D99AA84BCD627A", - "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677783Z", - "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677783Z", - "span_id": "0077A5FDB210BAB9", - "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 130000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677783Z", - "span_id": "BD4D0517234DC84A", - "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 130000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677783Z", - "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677783Z", - "span_id": "0077A5FDB210BAB9", - "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 130000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677783Z", - "span_id": "BD4D0517234DC84A", - "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 130000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677793Z", - "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677793Z", - "span_id": "C148B50CA183BF05", - "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677794Z", - "span_id": "91C5D79D971A4A6E", - "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677793Z", - "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677793Z", - "span_id": "C148B50CA183BF05", - "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677794Z", - "span_id": "91C5D79D971A4A6E", - "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677804Z", - "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677804Z", - "span_id": "948B20672FD5954F", - "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677805Z", - "span_id": "529CA18AF8EF5017", - "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677804Z", - "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677804Z", - "span_id": "948B20672FD5954F", - "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677805Z", - "span_id": "529CA18AF8EF5017", - "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677806Z", - "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677806Z", - "span_id": "ADF60B9EFA97AD89", - "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677807Z", - "span_id": "03DE70E55CBF857C", - "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677806Z", - "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677806Z", - "span_id": "ADF60B9EFA97AD89", - "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677807Z", - "span_id": "03DE70E55CBF857C", - "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677809Z", - "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677809Z", - "span_id": "4350413FDF6C0DCD", - "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 128000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677814Z", - "span_id": "BDC8BD58C638FC78", - "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677809Z", - "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677809Z", - "span_id": "4350413FDF6C0DCD", - "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 128000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677814Z", - "span_id": "BDC8BD58C638FC78", - "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677815Z", - "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677815Z", - "span_id": "1A9A97C656E06304", - "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677816Z", - "span_id": "5C6D85FF7954A628", - "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677815Z", - "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677815Z", - "span_id": "1A9A97C656E06304", - "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677816Z", - "span_id": "5C6D85FF7954A628", - "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677819Z", - "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677819Z", - "span_id": "6ED8D4E93C42E03E", - "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.67782Z", - "span_id": "E3A02E872E9E95EA", - "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677819Z", - "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677819Z", - "span_id": "6ED8D4E93C42E03E", - "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.67782Z", - "span_id": "E3A02E872E9E95EA", - "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677824Z", - "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677824Z", - "span_id": "2AF1B764C7572560", - "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677825Z", - "span_id": "321BA81B83ABAB3C", - "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677824Z", - "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677824Z", - "span_id": "2AF1B764C7572560", - "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677825Z", - "span_id": "321BA81B83ABAB3C", - "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677835Z", - "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677835Z", - "span_id": "3A2C3D8803D79AC4", - "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677836Z", - "span_id": "B44FBDD3FD165A7D", - "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677835Z", - "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.677835Z", - "span_id": "3A2C3D8803D79AC4", - "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677836Z", - "span_id": "B44FBDD3FD165A7D", - "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677838Z", - "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", - "service_name": "tracegen", - "operation": "okey-dokey", + "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-10T15:02:30.677838Z", - "span_id": "B3541547AC06BBBE", - "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677838Z", - "span_id": "D9A66F89D75DF7A9", - "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.677838Z", - "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", - "service_name": "tracegen", - "operation": "okey-dokey", + "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-10T15:02:30.677838Z", - "span_id": "B3541547AC06BBBE", - "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677838Z", - "span_id": "D9A66F89D75DF7A9", - "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.67784Z", - "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.67784Z", - "span_id": "6319FEA0EAA4E4C2", - "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677841Z", - "span_id": "ACF1D215C677342D", - "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, + "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": 2 + "totalSpans": 1 }, { - "timestamp": "2023-07-10T15:02:30.67784Z", - "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", - "service_name": "tracegen", - "operation": "lets-go", + "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-10T15:02:30.67784Z", - "span_id": "6319FEA0EAA4E4C2", - "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677841Z", - "span_id": "ACF1D215C677342D", - "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 123000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677847Z", - "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677847Z", - "span_id": "BFC1E9D5F3C3DC01", - "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 126000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677848Z", - "span_id": "021083418A0CC7D6", - "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677847Z", - "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677847Z", - "span_id": "BFC1E9D5F3C3DC01", - "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 126000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677848Z", - "span_id": "021083418A0CC7D6", - "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 125000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677877Z", - "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677877Z", - "span_id": "D732E63B1D99C410", - "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677877Z", - "span_id": "2115BD5B480ED78A", - "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.677877Z", - "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", - "service_name": "tracegen", - "operation": "okey-dokey", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.677877Z", - "span_id": "D732E63B1D99C410", - "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677877Z", - "span_id": "2115BD5B480ED78A", - "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.67788Z", - "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.67788Z", - "span_id": "479F386B26842545", - "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 129000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677885Z", - "span_id": "AC44E5C2E91E801A", - "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 2 - }, - { - "timestamp": "2023-07-10T15:02:30.67788Z", - "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", - "service_name": "tracegen", - "operation": "lets-go", - "statusCode": "STATUS_CODE_UNSET", - "spans": [ - { - "timestamp": "2023-07-10T15:02:30.67788Z", - "span_id": "479F386B26842545", - "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", - "service_name": "tracegen", - "operation": "lets-go", - "duration_nano": 129000, - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-07-10T15:02:30.677885Z", - "span_id": "AC44E5C2E91E801A", - "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 124000, + "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": 2 + "totalSpans": 1 } ], - "totalTraces": 200 + "totalTraces": 18 } 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 2b42c821cd5..10471cc1fdd 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue +++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue @@ -1,53 +1,127 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; -import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { createAlert } from '~/alert'; -import projectsQuery from '../graphql/queries/projects.query.graphql'; +import { GlCollapsibleListbox, GlSorting, GlSortingItem } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import { s__, __ } from '~/locale'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { + filterToQueryObject, + processFilters, + urlQueryToFilter, + prepareTokens, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { + FILTERED_SEARCH_TERM, + TOKEN_EMPTY_SEARCH_TERM, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { + DISPLAY_QUERY_GROUPS, + DISPLAY_QUERY_PROJECTS, + DISPLAY_LISTBOX_ITEMS, + SORT_DIRECTION_ASC, + SORT_DIRECTION_DESC, + SORT_ITEMS, + SORT_ITEM_CREATED, + FILTERED_SEARCH_TERM_KEY, +} from '../constants'; +import GroupsPage from './groups_page.vue'; +import ProjectsPage from './projects_page.vue'; export default { i18n: { pageTitle: __('Groups and projects'), - errorMessage: s__( - 'Organization|An error occurred loading the projects. Please refresh the page to try again.', - ), + searchInputPlaceholder: s__('Organization|Search or filter list'), + displayListboxHeaderText: __('Display'), }, - components: { - ProjectsList, - GlLoadingIcon, + components: { FilteredSearchBar, GlCollapsibleListbox, GlSorting, GlSortingItem }, + filteredSearch: { + tokens: [], + namespace: 'organization_groups_and_projects', + recentSearchesStorageKey: 'organization_groups_and_projects', }, - data() { - return { - projects: [], - }; - }, - apollo: { - projects: { - query: projectsQuery, - update(data) { - return data.organization.projects.nodes; - }, - error(error) { - createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); - }, + displayListboxItems: DISPLAY_LISTBOX_ITEMS, + sortItems: SORT_ITEMS, + computed: { + routerView() { + const { display } = this.$route.query; + + switch (display) { + case DISPLAY_QUERY_GROUPS: + return GroupsPage; + + case DISPLAY_QUERY_PROJECTS: + return ProjectsPage; + + default: + return GroupsPage; + } + }, + activeSortItem() { + return this.$options.sortItems.find((sortItem) => sortItem.name === this.sortName); + }, + sortName() { + return this.$route.query.sort_name || SORT_ITEM_CREATED.name; + }, + isAscending() { + return this.$route.query.sort_direction !== SORT_DIRECTION_DESC; + }, + sortText() { + return this.activeSortItem.text; + }, + filteredSearchValue() { + const tokens = prepareTokens( + urlQueryToFilter(this.$route.query, { + filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY, + filterNamesAllowList: [FILTERED_SEARCH_TERM], + }), + ); + + return tokens.length ? tokens : [TOKEN_EMPTY_SEARCH_TERM]; + }, + displayListboxSelected() { + const { display } = this.$route.query; + + return [DISPLAY_QUERY_GROUPS, DISPLAY_QUERY_PROJECTS].includes(display) + ? display + : DISPLAY_QUERY_GROUPS; }, }, - computed: { - formattedProjects() { - return this.projects.map(({ id, nameWithNamespace, accessLevel, ...project }) => ({ - ...project, - id: getIdFromGraphQLId(id), - name: nameWithNamespace, - permissions: { - projectAccess: { - accessLevel: accessLevel.integerValue, - }, - }, - })); - }, - isLoading() { - return this.$apollo.queries.projects?.loading; + methods: { + pushQuery(query) { + const currentQuery = this.$route.query; + + if (isEqual(currentQuery, query)) { + return; + } + + this.$router.push({ query }); + }, + onDisplayListboxSelect(display) { + this.pushQuery({ display }); + }, + onSortItemClick(sortItem) { + if (this.$route.query.sort_name === sortItem.name) { + return; + } + + this.pushQuery({ ...this.$route.query, sort_name: sortItem.name }); + }, + onSortDirectionChange(isAscending) { + this.pushQuery({ + ...this.$route.query, + sort_direction: isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC, + }); + }, + onFilter(filters) { + const { display, sort_name, sort_direction } = this.$route.query; + + this.pushQuery({ + display, + sort_name, + sort_direction, + ...filterToQueryObject(processFilters(filters), { + filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY, + }), + }); }, }, }; @@ -56,7 +130,49 @@ export default { <template> <div> <h1 class="gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <projects-list v-else :projects="formattedProjects" show-project-icon /> + <div class="gl-p-5 gl-bg-gray-10 gl-border-t gl-border-b"> + <div class="gl-mx-n2 gl-my-n2 gl-md-display-flex"> + <div class="gl-p-2 gl-flex-grow-1"> + <filtered-search-bar + :namespace="$options.filteredSearch.namespace" + :tokens="$options.filteredSearch.tokens" + :initial-filter-value="filteredSearchValue" + sync-filter-and-sort + :recent-searches-storage-key="$options.filteredSearch.recentSearchesStorageKey" + :search-input-placeholder="$options.i18n.searchInputPlaceholder" + @onFilter="onFilter" + /> + </div> + <div class="gl-p-2"> + <gl-collapsible-listbox + :selected="displayListboxSelected" + :items="$options.displayListboxItems" + :header-text="$options.i18n.displayListboxHeaderText" + block + toggle-class="gl-md-w-30" + @select="onDisplayListboxSelect" + /> + </div> + <div class="gl-p-2"> + <gl-sorting + class="gl-display-flex" + dropdown-class="gl-w-full" + :text="sortText" + :is-ascending="isAscending" + @sortDirectionChange="onSortDirectionChange" + > + <gl-sorting-item + v-for="sortItem in $options.sortItems" + :key="sortItem.name" + :active="activeSortItem.name === sortItem.name" + @click="onSortItemClick(sortItem)" + > + {{ sortItem.text }} + </gl-sorting-item> + </gl-sorting> + </div> + </div> + </div> + <component :is="routerView" /> </div> </template> 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 new file mode 100644 index 00000000000..20db38403f7 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue @@ -0,0 +1,43 @@ +<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 new file mode 100644 index 00000000000..d6958ee996e --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue @@ -0,0 +1,46 @@ +<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 new file mode 100644 index 00000000000..529caa666a0 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/constants.js @@ -0,0 +1,29 @@ +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 = [ + { + value: DISPLAY_QUERY_GROUPS, + text: __('Groups'), + }, + { + value: DISPLAY_QUERY_PROJECTS, + text: __('Projects'), + }, +]; + +export const SORT_DIRECTION_ASC = 'asc'; +export const SORT_DIRECTION_DESC = 'desc'; + +export const SORT_ITEM_CREATED = { + name: 'created', + text: __('Created'), +}; + +export const SORT_ITEMS = [SORT_ITEM_CREATED]; diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql new file mode 100644 index 00000000000..842c601e326 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql @@ -0,0 +1,22 @@ +query getOrganizationGroups { + organization @client { + id + groups { + nodes { + id + fullName + parent + webUrl + descriptionHtml + avatarUrl + descendantGroupsCount + projectsCount + groupMembersCount + visibility + accessLevel { + integerValue + } + } + } + } +} diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql index b4cb8c607d4..2a7971e1106 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql +++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql @@ -15,6 +15,7 @@ query getOrganizationProjects { descriptionHtml issuesAccessLevel forkingAccessLevel + isForked accessLevel { integerValue } diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js index 794410c2a78..8a375b28797 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js @@ -1,4 +1,8 @@ -import { organizationProjects } from 'jest/organizations/groups_and_projects/components/mock_data'; +import { + organization, + organizationProjects, + organizationGroups, +} from 'jest/organizations/groups_and_projects/mock_data'; export default { Query: { @@ -8,7 +12,11 @@ export default { setTimeout(resolve, 1000); }); - return organizationProjects; + return { + ...organization, + projects: organizationProjects, + groups: organizationGroups, + }; }, }, }; diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js index d0790bcc040..f3f15c635f1 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/index.js +++ b/app/assets/javascripts/organizations/groups_and_projects/index.js @@ -1,22 +1,39 @@ 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 App from './components/app.vue'; +import { ORGANIZATION_ROOT_ROUTE_NAME } from './constants'; + +export const createRouter = () => { + const routes = [{ path: '/', name: ORGANIZATION_ROOT_ROUTE_NAME }]; + + const router = new VueRouter({ + routes, + base: '/', + mode: 'history', + }); + + return router; +}; export const initOrganizationsGroupsAndProjects = () => { const el = document.getElementById('js-organizations-groups-and-projects'); if (!el) return false; + Vue.use(VueRouter); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(resolvers), }); + const router = createRouter(); return new Vue({ el, name: 'OrganizationsGroupsAndProjects', apolloProvider, + router, render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/organizations/groups_and_projects/utils.js b/app/assets/javascripts/organizations/groups_and_projects/utils.js new file mode 100644 index 00000000000..d2a4e05e806 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/utils.js @@ -0,0 +1,23 @@ +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/projects_list/constants'; + +export const formatProjects = (projects) => + projects.map(({ id, nameWithNamespace, accessLevel, webUrl, ...project }) => ({ + ...project, + id: getIdFromGraphQLId(id), + name: nameWithNamespace, + permissions: { + projectAccess: { + accessLevel: accessLevel.integerValue, + }, + }, + webUrl, + editPath: `${webUrl}/edit`, + actions: [ACTION_EDIT, ACTION_DELETE], + })); + +export const formatGroups = (groups) => + groups.map(({ id, ...group }) => ({ + ...group, + id: getIdFromGraphQLId(id), + })); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index da88f768c03..75af0286e12 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -1,5 +1,10 @@ <script> -import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -17,6 +22,8 @@ import { CLEANUP_ONGOING_TOOLTIP, CLEANUP_UNFINISHED_TOOLTIP, CLEANUP_DISABLED_TOOLTIP, + DELETE_IMAGE_TEXT, + MORE_ACTIONS_TEXT, UNFINISHED_STATUS, UNSCHEDULED_STATUS, SCHEDULED_STATUS, @@ -29,7 +36,7 @@ import { getImageName } from '../../utils'; export default { name: 'DetailsHeader', - components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem }, + components: { GlDisclosureDropdown, GlDisclosureDropdownItem, GlIcon, TitleArea, MetadataItem }, directives: { GlTooltip: GlTooltipDirective, }, @@ -108,6 +115,10 @@ export default { return size ? numberToHumanSize(Number(size)) : null; }, }, + i18n: { + DELETE_IMAGE_TEXT, + MORE_ACTIONS_TEXT, + }, }; </script> @@ -152,20 +163,23 @@ export default { data-testid="created-and-visibility" /> </template> - <template #right-actions> - <gl-dropdown - v-if="!deleteButtonDisabled" - icon="ellipsis_v" - text="More actions" - :text-sr-only="true" + <template v-if="!deleteButtonDisabled" #right-actions> + <gl-disclosure-dropdown category="tertiary" + icon="ellipsis_v" + placement="right" + :toggle-text="$options.i18n.MORE_ACTIONS_TEXT" + text-sr-only no-caret - right > - <gl-dropdown-item variant="danger" @click="$emit('delete')"> - {{ __('Delete image repository') }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-item @action="$emit('delete')"> + <template #list-item> + <span class="gl-text-red-500"> + {{ $options.i18n.DELETE_IMAGE_TEXT }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> </title-area> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 9ea1958a0d1..b58e2249829 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -2,9 +2,11 @@ import { GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { n__ } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -26,6 +28,7 @@ import { import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import DeleteModal from '../delete_modal.vue'; +import { getPageParams, getNextPageParams, getPreviousPageParams } from '../../utils'; import TagsListRow from './tags_list_row.vue'; export default { @@ -36,6 +39,7 @@ export default { TagsListRow, TagsLoader, RegistryList, + PersistedPagination, PersistedSearch, }, mixins: [Tracking.mixin()], @@ -61,7 +65,7 @@ export default { required: false, }, }, - searchConfig: { NAME_SORT_FIELD }, + sortableFields: [NAME_SORT_FIELD], i18n: { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, @@ -69,6 +73,7 @@ export default { apollo: { containerRepository: { query: getContainerRepositoryTagsQuery, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, skip() { return !this.sort; }, @@ -85,8 +90,9 @@ export default { containerRepository: {}, filters: {}, itemsToBeDeleted: [], - mutationLoading: false, + isDeleteInProgress: false, sort: null, + pageParams: {}, }; }, computed: { @@ -108,6 +114,7 @@ export default { first: GRAPHQL_PAGE_SIZE, name: this.filters?.name, sort: this.sort, + ...this.pageParams, }; }, hasNoTags() { @@ -117,7 +124,7 @@ export default { return ( this.isImageLoading || this.$apollo.queries.containerRepository.loading || - this.mutationLoading || + this.isDeleteInProgress || !this.sort ); }, @@ -149,7 +156,7 @@ export default { async handleDeleteTag() { this.track('confirm_delete'); const { itemsToBeDeleted } = this; - this.mutationLoading = true; + this.isDeleteInProgress = true; try { const { data } = await this.$apollo.mutate({ mutation: deleteContainerRepositoryTagsMutation, @@ -176,27 +183,17 @@ export default { } catch (e) { this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS); } finally { - this.mutationLoading = false; + this.isDeleteInProgress = false; } }, fetchNextPage() { - this.$apollo.queries.containerRepository.fetchMore({ - variables: { - after: this.tagsPageInfo?.endCursor, - first: GRAPHQL_PAGE_SIZE, - }, - }); + this.pageParams = getNextPageParams(this.tagsPageInfo?.endCursor); }, fetchPreviousPage() { - this.$apollo.queries.containerRepository.fetchMore({ - variables: { - first: null, - before: this.tagsPageInfo?.startCursor, - last: GRAPHQL_PAGE_SIZE, - }, - }); + this.pageParams = getPreviousPageParams(this.tagsPageInfo?.startCursor); }, - handleSearchUpdate({ sort, filters }) { + handleSearchUpdate({ sort, filters, pageInfo }) { + this.pageParams = getPageParams(pageInfo); this.sort = sort; const parsed = { @@ -223,10 +220,8 @@ export default { <div> <persisted-search class="gl-mb-5" - :sortable-fields="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - $options.searchConfig.NAME_SORT_FIELD, - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - :default-order="$options.searchConfig.NAME_SORT_FIELD.orderBy" + :sortable-fields="$options.sortableFields" + :default-order="$options.sortableFields[0].orderBy" default-sort="asc" @update="handleSearchUpdate" /> @@ -243,11 +238,8 @@ export default { <registry-list :hidden-delete="hideBulkDelete" :title="listTitle" - :pagination="tagsPageInfo" :items="tags" id-property="name" - @prev-page="fetchPreviousPage" - @next-page="fetchNextPage" @delete="deleteTags" > <template #default="{ selectItem, isSelected, item, first }"> @@ -271,5 +263,14 @@ export default { /> </template> </template> + + <div v-if="!isDeleteInProgress" class="gl-display-flex gl-justify-content-center"> + <persisted-pagination + class="gl-mt-3" + :pagination="tagsPageInfo" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue index a3f58cc3323..c8a4f32d5a7 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue @@ -81,7 +81,6 @@ export default { extraAttrs: { class: 'gl-text-red-500!', 'data-testid': 'single-delete-button', - 'data-qa-selector': 'tag_delete_button', }, action: () => { this.$emit('delete'); @@ -143,7 +142,6 @@ export default { <div v-gl-tooltip="{ title: tag.name }" data-testid="name" - data-qa-selector="tag_name_content" class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" :class="mobileClasses" > @@ -201,7 +199,6 @@ export default { placement="right" :class="{ 'gl-opacity-0 gl-pointer-events-none': disabled }" data-testid="additional-actions" - data-qa-selector="more_actions_menu" :items="items" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue index 6f1f67e251f..ffba64f58f8 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue @@ -1,11 +1,9 @@ <script> -import { GlKeysetPagination } from '@gitlab/ui'; import ImageListRow from './image_list_row.vue'; export default { name: 'ImageList', components: { - GlKeysetPagination, ImageListRow, }, props: { @@ -18,21 +16,12 @@ export default { default: false, required: false, }, - pageInfo: { - type: Object, - required: true, - }, expirationPolicy: { type: Object, default: () => ({}), required: false, }, }, - computed: { - showPagination() { - return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; - }, - }, }; </script> @@ -46,15 +35,5 @@ export default { :expiration-policy="expirationPolicy" @delete="$emit('delete', $event)" /> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - :has-next-page="pageInfo.hasNextPage" - :has-previous-page="pageInfo.hasPreviousPage" - class="gl-mt-3" - @prev="$emit('prev-page')" - @next="$emit('next-page')" - /> - </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index f6f816f435c..d7043626446 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -133,7 +133,6 @@ export default { ref="imageName" class="gl-text-body gl-font-weight-bold" data-testid="details-link" - data-qa-selector="registry_image_content" :to="{ name: 'details', params: { id } }" > {{ imageName }} diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 3a5992d182a..ab848d209db 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js @@ -93,6 +93,7 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', ); +export const DELETE_IMAGE_TEXT = s__('ContainerRegistry|Delete image repository'); export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}', diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index 3126af69c2c..c266dbf7e98 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -1,11 +1,10 @@ <script> -import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui'; +import { GlResizeObserverDirective, GlEmptyState, GlSkeletonLoader } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; -import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; import DeleteImage from '../components/delete_image.vue'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/delete_modal.vue'; @@ -28,12 +27,12 @@ export default { name: 'RegistryDetailsPage', components: { GlEmptyState, + GlSkeletonLoader, DeleteAlert, PartialCleanupAlert, DetailsHeader, DeleteModal, TagsList, - TagsLoader, StatusAlert, DeleteImage, }, @@ -151,16 +150,17 @@ export default { <status-alert v-if="containerRepository.status" :status="containerRepository.status" /> + <div v-if="isLoading" class="gl-my-6"> + <gl-skeleton-loader /> + </div> <details-header - v-if="!isLoading" + v-else :image="containerRepository" :disabled="pageActionsAreDisabled" @delete="deleteImage" /> - <tags-loader v-if="isLoading" /> <tags-list - v-else :id="$route.params.id" :is-image-loading="isLoading" :is-mobile="isMobile" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue index dca63e1a569..ca0261f1036 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <template> <div> <router-view ref="router-view" /> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index fe29fa8fdd7..df87ee79111 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -12,7 +12,9 @@ import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import { createAlert } from '~/alert'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; +import { fetchPolicies } from '~/lib/graphql'; import Tracking from '~/tracking'; +import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import DeleteImage from '../components/delete_image.vue'; @@ -32,6 +34,7 @@ import { SETTINGS_TEXT, } from '../constants/index'; import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql'; +import { getPageParams, getNextPageParams, getPreviousPageParams } from '../utils'; export default { name: 'RegistryListPage', @@ -61,6 +64,7 @@ export default { GlSkeletonLoader, RegistryHeader, DeleteImage, + PersistedPagination, PersistedSearch, }, directives: { @@ -87,6 +91,7 @@ export default { return !this.fetchBaseQuery; }, query: getContainerRepositoriesQuery, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, variables() { return this.queryVariables; }, @@ -109,6 +114,7 @@ export default { return !this.fetchAdditionalDetails; }, query: getContainerRepositoriesDetails, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, variables() { return this.queryVariables; }, @@ -133,6 +139,7 @@ export default { mutationLoading: false, fetchBaseQuery: false, fetchAdditionalDetails: false, + pageParams: {}, }; }, computed: { @@ -158,6 +165,7 @@ export default { fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, isGroupPage: this.config.isGroupPage, first: GRAPHQL_PAGE_SIZE, + ...this.pageParams, }; }, tracking() { @@ -193,54 +201,18 @@ export default { this.deleteAlertType = null; this.itemToDelete = {}; }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; + fetchNextPage() { + this.pageParams = getNextPageParams(this.pageInfo?.endCursor); }, - async fetchNextPage() { - if (this.pageInfo?.hasNextPage) { - const variables = { - after: this.pageInfo?.endCursor, - first: GRAPHQL_PAGE_SIZE, - }; - - this.$apollo.queries.baseImages.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - - await this.$nextTick(); - - this.$apollo.queries.additionalDetails.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - } - }, - async fetchPreviousPage() { - if (this.pageInfo?.hasPreviousPage) { - const variables = { - first: null, - before: this.pageInfo?.startCursor, - last: GRAPHQL_PAGE_SIZE, - }; - this.$apollo.queries.baseImages.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - - await this.$nextTick(); - - this.$apollo.queries.additionalDetails.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - } + fetchPreviousPage() { + this.pageParams = getPreviousPageParams(this.pageInfo?.startCursor); }, startDelete() { this.track('confirm_delete'); this.mutationLoading = true; }, - handleSearchUpdate({ sort, filters }) { + handleSearchUpdate({ sort, filters, pageInfo }) { + this.pageParams = getPageParams(pageInfo); this.sorting = sort; const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM); @@ -346,11 +318,8 @@ export default { v-if="images.length" :images="images" :metadata-loading="$apollo.queries.additionalDetails.loading" - :page-info="pageInfo" :expiration-policy="config.expirationPolicy" @delete="deleteImage" - @prev-page="fetchPreviousPage" - @next-page="fetchNextPage" /> <gl-empty-state @@ -370,6 +339,15 @@ export default { </template> </template> + <div v-if="!mutationLoading" class="gl-display-flex gl-justify-content-center"> + <persisted-pagination + class="gl-mt-3" + :pagination="pageInfo" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> + <delete-image :id="itemToDelete.id" @start="startDelete" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js index 751ab5180a1..7ed4ff52b06 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js @@ -1,4 +1,5 @@ import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; +import { GRAPHQL_PAGE_SIZE } from './constants/index'; export const getImageName = (image = {}) => { return image.name || image.project?.path; @@ -10,3 +11,26 @@ export const timeTilRun = (time) => { const difference = calculateRemainingMilliseconds(time); return approximateDuration(difference / 1000); }; + +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/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 87a2eb362d5..e18e6f7ed1a 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -160,11 +160,7 @@ export default { <template> <div> - <gl-alert - v-if="showDeleteCacheAlert" - data-testid="delete-cache-alert" - @dismiss="showDeleteCacheAlert = false" - > + <gl-alert v-if="showDeleteCacheAlert" @dismiss="showDeleteCacheAlert = false"> {{ deleteCacheAlertMessage }} </gl-alert> <title-area :title="$options.i18n.pageTitle"> @@ -215,7 +211,7 @@ export default { </template> </gl-form-input-group> <template #description> - <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count"> + <span data-testid="proxy-count"> <gl-sprintf :message="$options.i18n.blobCountAndSize"> <template #count>{{ group.dependencyProxyBlobCount }}</template> <template #size>{{ group.dependencyProxyTotalSize }}</template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue index 94c958308dd..462de03d19f 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue @@ -37,11 +37,6 @@ export default { i18n: { listTitle: s__('DependencyProxy|Image list'), }, - computed: { - showPagination() { - return this.pagination.hasNextPage || this.pagination.hasPreviousPage; - }, - }, }; </script> @@ -68,7 +63,6 @@ export default { </div> <div class="gl-display-flex gl-justify-content-center"> <gl-keyset-pagination - v-if="showPagination" v-bind="pagination" class="gl-mt-3" @prev="$emit('prev-page')" diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue index dca63e1a569..ca0261f1036 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <template> <div> <router-view ref="router-view" /> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index fdc58e4bd05..cb96f3d96cb 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -9,6 +9,7 @@ import { GlTabs, GlSprintf, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue index 3e551706ed0..cd5f9f5a676 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; @@ -29,11 +30,6 @@ export default { return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0)); }, }, - methods: { - dynamicSlotName(index) { - return `metadata-tag${index}`; - }, - }, }; </script> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue index c62bf7fb722..5febda59119 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { s__ } from '~/locale'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js index 15e17bcfaac..8a283148c7d 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue index 2046b717362..ea87ec31f0f 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { LIST_KEY_PACKAGE_TYPE } from '~/packages_and_registries/infrastructure_registry/list/constants'; import { sortableFields } from '~/packages_and_registries/infrastructure_registry/list/utils'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue index 707e8f09045..6139db9f3bd 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -1,5 +1,6 @@ <script> import { GlPagination } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import Tracking from '~/tracking'; import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 37fc326f902..73a897ad7d5 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -1,5 +1,6 @@ <script> import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { createAlert, VARIANT_INFO } from '~/alert'; import { historyReplaceState } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js index 1d6a4bf831d..d462c38451a 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import getList from './getters'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue index cc52235eaf3..c92208abfc3 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue @@ -84,7 +84,7 @@ export default { <gl-link :href="packageLink" class="gl-text-body gl-min-w-0" - data-qa-selector="package_link" + data-testid="details-link" :disabled="disabledRow" > <gl-truncate :text="packageEntity.name" /> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue index e3edaa3e45e..ccd76b8fc68 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue index de7c1bc4cd3..8f92111cebb 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue index 7c3eb476a99..7135691816b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue index 1ddd419a639..eab101350a1 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue index ef35349c228..498ddbae7b1 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index 96d097eff38..c8924e6548b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -178,10 +178,6 @@ export default { first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, }; }, - showPagination() { - const { hasPreviousPage, hasNextPage } = this.pageInfo; - return hasPreviousPage || hasNextPage; - }, tracking() { return { category: packageTypeToTrackCategory(this.packageType), @@ -490,7 +486,6 @@ export default { </gl-table> <div class="gl-display-flex gl-justify-content-center"> <gl-keyset-pagination - v-if="showPagination" :disabled="isLoading" v-bind="pageInfo" :prev-text="$options.i18n.prev" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index 5eabcea9e15..cdf03d64b27 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -80,7 +80,7 @@ export default { data-qa-selector="package_title" > <template #sub-header> - <div data-testid="sub-header" class="gl-display-flex gl-gap-3"> + <div data-testid="sub-header" class="gl-display-flex gl-flex-wrap gl-gap-3"> <gl-sprintf :message="$options.i18n.packageInfo"> <template #version> {{ packageEntity.version }} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index c690e8fac43..a545ad1d09c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -1,7 +1,7 @@ <script> import { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlFormCheckbox, GlIcon, GlSprintf, @@ -28,8 +28,8 @@ import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { name: 'PackageListRow', components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlFormCheckbox, GlIcon, GlSprintf, @@ -135,7 +135,6 @@ export default { :class="errorPackageStyle" class="gl-text-body gl-min-w-0" data-testid="details-link" - data-qa-selector="package_link" :to="{ name: 'details', params: { id: packageId } }" > <gl-truncate :text="packageEntity.name" /> @@ -195,18 +194,22 @@ export default { </template> <template v-if="packageEntity.canDestroy" #right-action> - <gl-dropdown + <gl-disclosure-dropdown + category="tertiary" data-testid="delete-dropdown" icon="ellipsis_v" - :text="$options.i18n.moreActions" - :text-sr-only="true" - category="tertiary" + :toggle-text="$options.i18n.moreActions" + text-sr-only no-caret > - <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{ - $options.i18n.deletePackage - }}</gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-item data-testid="action-delete" @action="$emit('delete')"> + <template #list-item> + <span class="gl-text-red-500"> + {{ $options.i18n.deletePackage }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> </list-item> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index d96418571e1..d1982464eb9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -221,7 +221,7 @@ export default { attributes: { variant: 'danger', category: 'primary', - 'data-qa-selector': 'delete_modal_button', + 'data-testid': 'delete-modal-button', }, }, fileDeletePrimaryAction: { @@ -254,7 +254,6 @@ export default { v-gl-modal="'delete-modal'" variant="danger" category="primary" - data-qa-selector="delete_button" data-testid="delete-package" > {{ __('Delete') }} @@ -264,7 +263,7 @@ export default { <gl-tabs> <gl-tab :title="__('Detail')"> - <div data-qa-selector="package_information_content"> + <div data-testid="package-information-content"> <package-history :package-entity="packageEntity" :project-name="projectName" /> <installation-commands :package-entity="packageEntity" /> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue index a14d0c32cbe..1e0715ff544 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <template> <div> <router-view /> 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 486c3ef31c5..6de89748708 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_INFO } from '~/alert'; @@ -189,7 +190,11 @@ export default { @delete="deletePackages" > <template #empty-state> - <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> + <gl-empty-state + :title="emptyStateTitle" + :svg-path="emptyListIllustration" + :svg-height="150" + > <template #description> <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" /> <gl-sprintf v-else :message="$options.i18n.noResultsText"> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index fa73c01c5c4..bfb57e3ac1c 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -45,7 +45,7 @@ export const PACKAGE_FORWARDING_FORM_BUTTON = __('Save changes'); export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy'); export const DEPENDENCY_PROXY_DESCRIPTION = s__( - 'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.', + 'DependencyProxy|Enable the Dependency Proxy to cache container images from Docker Hub and automatically clear the cache.', ); export const PACKAGE_FORWARDING_FIELDS = [ diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue new file mode 100644 index 00000000000..01c2c751cac --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_pagination.vue @@ -0,0 +1,56 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +export default { + name: 'PersistedPagination', + components: { + GlKeysetPagination, + UrlSync, + }, + inheritAttrs: false, + props: { + pagination: { + type: Object, + default: () => ({}), + required: false, + }, + }, + computed: { + attrs() { + return { + ...this.pagination, + ...this.$attrs, + }; + }, + }, + methods: { + onPrev(updateQuery) { + updateQuery({ + before: this.pagination?.startCursor, + after: null, + }); + this.$emit('prev'); + }, + onNext(updateQuery) { + updateQuery({ + after: this.pagination?.endCursor, + before: null, + }); + this.$emit('next'); + }, + }, +}; +</script> + +<template> + <url-sync> + <template #default="{ updateQuery }"> + <gl-keyset-pagination + v-bind="attrs" + @prev="onPrev(updateQuery)" + @next="onNext(updateQuery)" + /> + </template> + </url-sync> +</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 363304c20ce..95343a3a09b 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 @@ -1,7 +1,11 @@ <script> import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; -import { extractFilterAndSorting, getQueryParams } from '~/packages_and_registries/shared/utils'; +import { + extractFilterAndSorting, + extractPageInfo, + getQueryParams, +} from '~/packages_and_registries/shared/utils'; export default { components: { RegistrySearch, UrlSync }, @@ -31,6 +35,7 @@ export default { orderBy: this.defaultOrder, sort: this.defaultSort, }, + pageInfo: {}, mountRegistrySearch: false, }; }, @@ -40,27 +45,49 @@ export default { return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase(); }, }, + watch: { + $route(newValue, oldValue) { + if (newValue.fullPath !== oldValue.fullPath) { + this.updateDataFromUrl(); + this.emitUpdate(); + } + }, + }, mounted() { - const queryParams = getQueryParams(window.document.location.search); - const { sorting, filters } = extractFilterAndSorting(queryParams); - this.updateSorting(sorting); - this.updateFilters(filters); + this.updateDataFromUrl(); this.mountRegistrySearch = true; this.emitUpdate(); }, methods: { + updateDataFromUrl() { + const queryParams = getQueryParams(window.location.search); + const { sorting, filters } = extractFilterAndSorting(queryParams); + const pageInfo = extractPageInfo(queryParams); + this.updateSorting(sorting); + this.updateFilters(filters); + this.updatePageInfo(pageInfo); + }, updateFilters(newValue) { + this.updatePageInfo({}); this.filters = newValue; }, updateSorting(newValue) { + this.updatePageInfo({}); this.sorting = { ...this.sorting, ...newValue }; }, + updatePageInfo(newValue) { + this.pageInfo = newValue; + }, updateSortingAndEmitUpdate(newValue) { this.updateSorting(newValue); this.emitUpdate(); }, emitUpdate() { - this.$emit('update', { sort: this.parsedSorting, filters: this.filters }); + this.$emit('update', { + sort: this.parsedSorting, + filters: this.filters, + pageInfo: this.pageInfo, + }); }, }, }; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue index 1c8f80972df..f67bee77eb6 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -47,9 +47,6 @@ export default { }; }, computed: { - showPagination() { - return this.pagination.hasPreviousPage || this.pagination.hasNextPage; - }, disableDeleteButton() { return this.isLoading || this.selectedItems.length === 0; }, @@ -131,7 +128,6 @@ export default { <div class="gl-display-flex gl-justify-content-center"> <gl-keyset-pagination - v-if="showPagination" v-bind="pagination" class="gl-mt-3" @prev="$emit('prev-page')" diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index adffab277cc..bda0839092e 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -30,6 +30,14 @@ export const extractFilterAndSorting = (queryObject) => { return { filters, sorting }; }; +export const extractPageInfo = (queryObject) => { + const { before, after } = queryObject; + return { + before, + after, + }; +}; + export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : ''); export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js index 05078191e5c..9b60b1f51a8 100644 --- a/app/assets/javascripts/pages/explore/groups/index.js +++ b/app/assets/javascripts/pages/explore/groups/index.js @@ -1,9 +1,7 @@ import initGroupsList from '~/groups'; -import GroupsList from '~/groups/groups_list'; import Landing from '~/groups/landing'; function exploreGroups() { - new GroupsList(); // eslint-disable-line no-new initGroupsList(); const landingElement = document.querySelector('.js-explore-groups-landing'); if (!landingElement) return; diff --git a/app/assets/javascripts/pages/groups/work_items/index.js b/app/assets/javascripts/pages/groups/work_items/index.js new file mode 100644 index 00000000000..a95070b1857 --- /dev/null +++ b/app/assets/javascripts/pages/groups/work_items/index.js @@ -0,0 +1,3 @@ +import { mountWorkItemsListApp } from '~/work_items/list'; + +mountWorkItemsListApp(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 582aee3c9a3..1d0eaae4c57 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -15,21 +15,21 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { getBulkImportsHistory } from '~/rest_api'; import ImportStatus from '~/import_entities/components/import_status.vue'; +import { StatusPoller } from '~/import_entities/import_groups/services/status_poller'; + import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { isImporting } from '../utils'; import { DEFAULT_ERROR } from '../utils/error_messages'; const DEFAULT_PER_PAGE = 20; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; const HISTORY_PAGINATION_SIZE_PERSIST_KEY = 'gl-bulk-imports-history-per-page'; const tableCell = (config) => ({ - thClass: `${DEFAULT_TH_CLASSES}`, tdClass: (value, key, item) => { return { // eslint-disable-next-line no-underscore-dangle @@ -57,6 +57,8 @@ export default { GlTooltip, }, + inject: ['realtimeChangesPath'], + data() { return { loading: true, @@ -73,12 +75,12 @@ export default { tableCell({ key: 'source_full_path', label: s__('BulkImport|Source'), - thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`, + thClass: `gl-w-30p`, }), tableCell({ key: 'destination_name', label: s__('BulkImport|Destination'), - thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`, + thClass: `gl-w-40p`, }), tableCell({ key: 'created_at', @@ -95,6 +97,12 @@ export default { hasHistoryItems() { return this.historyItems.length > 0; }, + + importingHistoryItemIds() { + return this.historyItems + .filter((item) => isImporting(item.status)) + .map((item) => item.bulk_import_id); + }, }, watch: { @@ -104,10 +112,43 @@ export default { }, deep: true, }, + + importingHistoryItemIds(value) { + if (value.length > 0) { + this.statusPoller.startPolling(); + } else { + this.statusPoller.stopPolling(); + } + }, }, mounted() { this.loadHistoryItems(); + + this.statusPoller = new StatusPoller({ + pollPath: this.realtimeChangesPath, + updateImportStatus: (update) => { + if (!this.importingHistoryItemIds.includes(update.id)) { + return; + } + + const updateItemIndex = this.historyItems.findIndex( + (item) => item.bulk_import_id === update.id, + ); + const updateItem = this.historyItems[updateItemIndex]; + + if (updateItem.status !== update.status_name) { + this.$set(this.historyItems, updateItemIndex, { + ...updateItem, + status: update.status_name, + }); + } + }, + }); + }, + + beforeDestroy() { + this.statusPoller.stopPolling(); }, methods: { diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/index.js index 5a67aa99baa..cc12723572d 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/index.js +++ b/app/assets/javascripts/pages/import/bulk_imports/history/index.js @@ -4,8 +4,14 @@ import BulkImportHistoryApp from './components/bulk_imports_history_app.vue'; function mountImportHistoryApp(mountElement) { if (!mountElement) return undefined; + const { realtimeChangesPath } = mountElement.dataset; + return new Vue({ el: mountElement, + name: 'BulkImportHistoryRoot', + provide: { + realtimeChangesPath, + }, render(createElement) { return createElement(BulkImportHistoryApp); }, diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js new file mode 100644 index 00000000000..09cba40dd36 --- /dev/null +++ b/app/assets/javascripts/pages/import/bulk_imports/history/utils/index.js @@ -0,0 +1,7 @@ +import { STATUSES } from '~/import_entities/constants'; + +export function isImporting(status) { + return [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.CREATED, STATUSES.STARTED].includes( + status, + ); +} diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index 28b1aa02dfa..b79acfd5c57 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -12,6 +12,7 @@ function initSshKeyValidation() { const warning = document.querySelector('.js-add-ssh-key-validation-warning'); const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit'); const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit'); + const cancelButton = input.form.querySelector('.js-add-ssh-key-validation-cancel'); const addSshKeyValidation = new AddSshKeyValidation( supportedAlgorithms, @@ -19,6 +20,7 @@ function initSshKeyValidation() { warning, originalSubmit, confirmSubmit, + cancelButton, ); addSshKeyValidation.register(); } diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index f5e09d972a9..a3d930433c3 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; @@ -71,6 +72,7 @@ if (viewBlobEl) { resourceId, userId, explainCodeAvailable, + refType, ...dataset } = viewBlobEl.dataset; @@ -94,6 +96,7 @@ if (viewBlobEl) { props: { path: blobPath, projectPath, + refType, }, }); }, diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js index 43fd5375222..e28834b1ccd 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js @@ -1,6 +1,7 @@ /* eslint-disable no-new */ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import EditUserList from '~/user_lists/components/edit_user_list.vue'; import createStore from '~/user_lists/store/edit'; diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js index 519e04e14fb..1f6ae5ee287 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js @@ -1,6 +1,7 @@ /* eslint-disable no-new */ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import UserLists from '~/user_lists/components/user_lists.vue'; import createStore from '~/user_lists/store/index'; diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js index e855447d5ce..86d2b5038d0 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js @@ -1,6 +1,7 @@ /* eslint-disable no-new */ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import NewUserList from '~/user_lists/components/new_user_list.vue'; import createStore from '~/user_lists/store/new'; diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 9eaf490abb2..cacfb00fa2c 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,4 +1,4 @@ -import mountNotesApp from 'ee_else_ce/mr_notes/mount_app'; +import mountNotesApp from '~/mr_notes/mount_app'; import { initMrPage } from 'ee_else_ce/pages/projects/merge_requests/page'; initMrPage(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js deleted file mode 100644 index 6dd21380bec..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initForm from '../shared/init_form'; - -initForm(); 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 a79f20d596c..5f6a73782c3 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 @@ -96,7 +96,7 @@ export default { }, { value: KEY_CUSTOM, - text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more.%{linkEnd})'), + text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more%{linkEnd}.)'), link: this.cronSyntaxUrl, }, ]; @@ -168,9 +168,7 @@ export default { > <gl-sprintf v-if="option.link" :message="option.text"> <template #link="{ content }"> - <gl-link :href="option.link" target="_blank" class="gl-font-sm"> - {{ content }} - </gl-link> + <gl-link :href="option.link" target="_blank" class="gl-font-sm">{{ content }}</gl-link> </template> </gl-sprintf> diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js deleted file mode 100644 index 6dd21380bec..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initForm from '../shared/init_form'; - -initForm(); 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 b2681267e06..4a5d5580c08 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 @@ -14,6 +14,7 @@ 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'; // Initialize expandable settings panels initSettingsPanels(); @@ -51,3 +52,4 @@ initRefSwitcherBadges(); initInstallRunner(); initTokenAccess(); initCiSecureFiles(); +initGeneralPipelinesOptions(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index c54596488af..6ff48b7de95 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -277,7 +277,7 @@ export default { requestAccessEnabled: true, enforceAuthChecksOnUploads: true, highlightChangesClass: false, - emailsDisabled: false, + emailsEnabled: true, cveIdRequestEnabled: true, featureAccessLevelEveryone, featureAccessLevelMembers, @@ -370,7 +370,10 @@ export default { return this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; }, packageRegistryApiForEveryoneEnabledShown() { - return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER; + return ( + this.packageRegistryAllowAnyoneToPullOption && + this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER + ); }, monitorOperationsFeatureAccessLevelOptions() { return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel); @@ -1001,14 +1004,19 @@ export default { :full-path="confirmationPhrase" /> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> - <label class="js-emails-disabled"> - <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> - <input v-model="emailsDisabled" type="checkbox" /> - {{ s__('ProjectSettings|Disable email notifications') }} + <label class="js-emails-enabled"> + <input + :value="emailsEnabled" + type="hidden" + name="project[project_setting_attributes][emails_enabled]" + /> + <gl-form-checkbox v-model="emailsEnabled"> + {{ s__('ProjectSettings|Enable email notifications') }} + <template #help>{{ + s__('ProjectSettings|Enable sending email notifications for this project') + }}</template> + </gl-form-checkbox> </label> - <span class="form-text text-muted">{{ - s__('ProjectSettings|Override user notification preferences for all project members.') - }}</span> </project-setting-row> <project-setting-row class="mb-3"> <input @@ -1020,10 +1028,10 @@ export default { v-model="showDefaultAwardEmojis" name="project[project_setting_attributes][show_default_award_emojis]" > - {{ s__('ProjectSettings|Show default award emojis') }} + {{ s__('ProjectSettings|Show default emoji reactions') }} <template #help>{{ s__( - 'ProjectSettings|Always show thumbs-up and thumbs-down award emoji buttons on issues, merge requests, and snippets.', + 'ProjectSettings|Always show thumbs-up and thumbs-down emoji buttons on issues, merge requests, and snippets.', ) }}</template> </gl-form-checkbox> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index e17f5255c54..c43a0eb597c 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -8,6 +8,8 @@ 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 if (document.getElementById('js-tree-list')) { import(/* webpackChunkName: 'treeList' */ 'ee_else_ce/repository') @@ -57,3 +59,5 @@ if (document.querySelector('.js-autodevops-banner')) { }) .catch(() => {}); } + +initForksButton(); diff --git a/app/assets/javascripts/pages/projects/tracing/show/index.js b/app/assets/javascripts/pages/projects/tracing/show/index.js new file mode 100644 index 00000000000..107c004aa5f --- /dev/null +++ b/app/assets/javascripts/pages/projects/tracing/show/index.js @@ -0,0 +1,4 @@ +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/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index a8b4dca0845..1d5d885753c 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -3,6 +3,7 @@ import initVueAlerts from '~/vue_alerts'; import NoEmojiValidator from '~/emoji/no_emoji_validator'; import { initLanguageSwitcher } from '~/language_switcher'; import LengthValidator from '~/validators/length_validator'; +import mountEmailVerificationApplication from '~/sessions/new'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; import SigninTabsMemoizer from './signin_tabs_memoizer'; @@ -22,3 +23,4 @@ new OAuthRememberMe({ preserveUrlFragment(window.location.hash); initVueAlerts(); initLanguageSwitcher(); +mountEmailVerificationApplication(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue new file mode 100644 index 00000000000..4d13f25c4cb --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue @@ -0,0 +1,40 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; +import printMarkdownDom from '~/lib/print_markdown_dom'; + +export default { + components: { + GlDisclosureDropdown, + }, + inject: ['target', 'title', 'stylesheet'], + computed: { + dropdownItems() { + return [ + { + text: __('Print as PDF'), + action: this.print, + }, + ]; + }, + }, + methods: { + print() { + printMarkdownDom({ + target: document.querySelector(this.target), + title: this.title, + stylesheet: this.stylesheet, + }); + }, + }, +}; +</script> +<template> + <gl-disclosure-dropdown + :items="dropdownItems" + icon="ellipsis_v" + category="tertiary" + placement="right" + no-caret + /> +</template> diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 5bc630c61cb..553cb1f0464 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -344,7 +344,7 @@ export default { </div> </div> - <div class="row" data-testid="wiki-form-content-fieldset"> + <div class="row"> <div class="col-sm-12 row-sm-5"> <gl-form-group> <markdown-editor diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js index 9906cb595f8..9bc399d07b3 100644 --- a/app/assets/javascripts/pages/shared/wikis/show.js +++ b/app/assets/javascripts/pages/shared/wikis/show.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Wikis from './wikis'; import WikiContent from './components/wiki_content.vue'; +import WikiExport from './components/wiki_export.vue'; const mountWikiContentApp = () => { const el = document.querySelector('.js-async-wiki-page-content'); @@ -20,8 +21,28 @@ const mountWikiContentApp = () => { } }; +const mountWikiExportApp = () => { + const el = document.querySelector('#js-export-actions'); + + if (!el) return false; + const { target, title, stylesheet } = JSON.parse(el.dataset.options); + + return new Vue({ + el, + provide: { + target, + title, + stylesheet, + }, + render(createElement) { + return createElement(WikiExport); + }, + }); +}; + export const mountApplications = () => { // eslint-disable-next-line no-new new Wikis(); mountWikiContentApp(); + mountWikiExportApp(); }; diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index ec085eae199..b32cc700e16 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,6 +1,5 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Tracking from '~/tracking'; -import showToast from '~/vue_shared/plugins/global_toast'; import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; const TRACKING_EVENT_NAME = 'view_wiki_page'; @@ -31,7 +30,6 @@ export default class Wikis { this.renderSidebar(); Wikis.trackPageView(); - Wikis.showToasts(); Wikis.initShortcuts(); } @@ -73,11 +71,6 @@ export default class Wikis { }); } - static showToasts() { - const toasts = document.querySelectorAll('.js-toast-message'); - toasts.forEach((toast) => showToast(toast.dataset.message)); - } - static initShortcuts() { new ShortcutsWiki(); // eslint-disable-line no-new } diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index f35f9341fa1..43457faff4a 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/legacy/build/pdf'; diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue index ae0a7f0298b..46affdc588a 100644 --- a/app/assets/javascripts/pdf/page/index.vue +++ b/app/assets/javascripts/pdf/page/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> export default { props: { diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 69d60a7caf9..5bef7e6e322 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -171,7 +171,7 @@ export default { sprintf(__('%{duration}ms'), { duration: item.duration }) }}</span> </td> - <td data-testid="performance-item-content"> + <td> <div> <div v-for="(key, keyIndex) in keys" diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index fac070d6e47..128c744f282 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -84,6 +84,11 @@ export default { keys: ['request', 'body'], }, { + metric: 'ch', + header: s__('PerformanceBar|ClickHouse queries'), + keys: ['sql', 'database', 'statistics'], + }, + { metric: 'external-http', title: 'external', header: s__('PerformanceBar|External Http calls'), diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue index e68458a494f..f1cdb4630fd 100644 --- a/app/assets/javascripts/pipeline_wizard/components/commit.vue +++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue @@ -177,7 +177,6 @@ export default { <gl-form-group :invalid-feedback="$options.i18n.fieldRequiredFeedback" :label="$options.i18n.commitMessageLabel" - data-testid="commit_message_group" label-for="commit_message" > <gl-form-textarea @@ -192,7 +191,6 @@ export default { <gl-form-group :invalid-feedback="$options.i18n.fieldRequiredFeedback" :label="$options.i18n.branchSelectorLabel" - data-testid="branch_selector_group" label-for="branch" > <ref-selector id="branch" v-model="branch" :project-id="projectPath" data-testid="branch" /> diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue index 26235b20ce9..0542aa461ab 100644 --- a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue @@ -105,7 +105,7 @@ export default { </script> <template> - <div data-testid="text-widget"> + <div> <gl-form-group :description="description" :invalid-feedback="invalidFeedbackMessage" diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index be12df68f76..afb5aa05098 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 02d0c07ea54..d4852224df5 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -99,7 +99,7 @@ export default { :dropdown-length="group.size" :job="job" :type="$options.jobItemTypes.singleJob" - css-class-job-name="mini-pipeline-graph-dropdown-item" + css-class-job-name="pipeline-job-item" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </li> diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index ec7000120f1..f84ae13180d 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -3,10 +3,11 @@ import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; 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 } from '../../constants'; +import { DEFAULT_FIELDS, TRACKING_CATEGORIES } from '../../constants'; export default { fields: DEFAULT_FIELDS, @@ -20,6 +21,7 @@ export default { directives: { SafeHtml, }, + mixins: [Tracking.mixin()], props: { failedJobs: { type: Array, @@ -28,6 +30,8 @@ export default { }, methods: { async retryJob(id) { + this.track('click_retry', { label: TRACKING_CATEGORIES.failed }); + try { const { data: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue deleted file mode 100644 index 91630d4cfd4..00000000000 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue +++ /dev/null @@ -1,149 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { __ } from '~/locale'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; -import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; -import PipelineMiniGraph from './pipeline_mini_graph.vue'; - -export default { - i18n: { - linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'), - stagesFetchError: __('There was a problem fetching the pipeline stages.'), - }, - components: { - GlLoadingIcon, - PipelineMiniGraph, - }, - props: { - pipelineEtag: { - type: String, - required: true, - }, - fullPath: { - type: String, - required: true, - }, - iid: { - type: String, - required: true, - }, - isMergeTrain: { - type: Boolean, - required: false, - default: false, - }, - pollInterval: { - type: Number, - required: false, - default: PIPELINE_MINI_GRAPH_POLL_INTERVAL, - }, - }, - data() { - return { - linkedPipelines: null, - pipelineStages: [], - }; - }, - apollo: { - linkedPipelines: { - context() { - return getQueryHeaders(this.pipelineEtag); - }, - query: getLinkedPipelinesQuery, - pollInterval() { - return this.pollInterval; - }, - variables() { - return { - fullPath: this.fullPath, - iid: this.iid, - }; - }, - update({ project }) { - return project?.pipeline || this.linkedpipelines; - }, - error() { - createAlert({ message: this.$options.i18n.linkedPipelinesFetchError }); - }, - }, - pipelineStages: { - context() { - return getQueryHeaders(this.pipelineEtag); - }, - query: getPipelineStagesQuery, - pollInterval() { - return this.pollInterval; - }, - variables() { - return { - fullPath: this.fullPath, - iid: this.iid, - }; - }, - update({ project }) { - return project?.pipeline?.stages?.nodes || this.pipelineStages; - }, - error() { - createAlert({ message: this.$options.i18n.stagesFetchError }); - }, - }, - }, - computed: { - downstreamPipelines() { - return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes); - }, - formattedStages() { - return this.pipelineStages.map((stage) => { - const { name, detailedStatus } = stage; - return { - // TODO: Once we fetch stage by ID with GraphQL, - // this method will change. - // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853 - id: stage.id, - dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, - name, - path: `${this.pipelinePath}#${name}`, - status: { - details_path: `${this.pipelinePath}#${name}`, - has_details: detailedStatus?.hasDetails || false, - ...detailedStatus, - }, - title: `${name}: ${detailedStatus?.text || ''}`, - }; - }); - }, - pipelinePath() { - return this.linkedPipelines?.path || ''; - }, - upstreamPipeline() { - return this.linkedPipelines?.upstream; - }, - }, - mounted() { - toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines); - toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages); - }, -}; -</script> - -<template> - <div> - <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" /> - <pipeline-mini-graph - v-else - data-testid="graphql-pipeline-mini-graph" - :downstream-pipelines="downstreamPipelines" - :is-merge-train="isMergeTrain" - :pipeline-path="pipelinePath" - :stages="formattedStages" - :upstream-pipeline="upstreamPipeline" - /> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue index 66bf5068149..7f97097def6 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue @@ -1,197 +1,13 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; -import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { __, sprintf } from '~/locale'; -import { reportToSentry } from '../../utils'; -import ActionComponent from '../jobs_shared/action_component.vue'; -import JobNameComponent from '../jobs_shared/job_name_component.vue'; - -/** - * Renders the badge for the pipeline graph and the job's dropdown. - * - * The following object should be provided as `job`: - * - * { - * "id": 4256, - * "name": "test", - * "status": { - * "icon": "status_success", - * "text": "passed", - * "label": "passed", - * "group": "success", - * "tooltip": "passed", - * "details_path": "/root/ci-mock/builds/4256", - * "action": { - * "icon": "retry", - * "title": "Retry", - * "path": "/root/ci-mock/builds/4256/retry", - * "method": "post" - * } - * } - * } - */ - export default { - i18n: { - runAgainTooltipText: __('Run again'), - }, - hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', - components: { - ActionComponent, - JobNameComponent, - GlLink, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [delayedJobMixin], props: { job: { type: Object, required: true, }, - cssClassJobName: { - type: String, - required: false, - default: '', - }, - dropdownLength: { - type: Number, - required: false, - default: Infinity, - }, - jobHovered: { - type: String, - required: false, - default: '', - }, - pipelineExpanded: { - type: Object, - required: false, - default: () => ({}), - }, - pipelineId: { - type: Number, - required: false, - default: -1, - }, - }, - computed: { - boundary() { - return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; - }, - detailsPath() { - return this.status.details_path; - }, - hasDetails() { - return this.status.has_details; - }, - status() { - return this.job && this.job.status ? this.job.status : {}; - }, - tooltipText() { - const textBuilder = []; - const { name: jobName } = this.job; - - if (jobName) { - textBuilder.push(jobName); - } - - const { tooltip: statusTooltip } = this.status; - if (jobName && statusTooltip) { - textBuilder.push('-'); - } - - if (statusTooltip) { - if (this.isDelayedJob) { - textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); - } else { - textBuilder.push(statusTooltip); - } - } - - return textBuilder.join(' '); - }, - /** - * Verifies if the provided job has an action path - * - * @return {Boolean} - */ - hasAction() { - return this.job.status && this.job.status.action && this.job.status.action.path; - }, - relatedDownstreamHovered() { - return this.job.name === this.jobHovered; - }, - relatedDownstreamExpanded() { - return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; - }, - jobClasses() { - return this.relatedDownstreamHovered || this.relatedDownstreamExpanded - ? `${this.$options.hoverClass} ${this.cssClassJobName}` - : this.cssClassJobName; - }, - jobActionTooltipText() { - const { group } = this.status; - const { title, icon } = this.status.action; - - return icon === 'retry' && group === 'success' - ? this.$options.i18n.runAgainTooltipText - : title; - }, - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); - }, - methods: { - hideTooltips() { - this.$root.$emit(BV_HIDE_TOOLTIP); - }, }, }; </script> <template> - <div - class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" - data-qa-selector="job_item_container" - > - <gl-link - v-if="hasDetails" - v-gl-tooltip="{ - boundary: 'viewport', - placement: 'bottom', - customClass: 'gl-pointer-events-none', - }" - :href="detailsPath" - :title="tooltipText" - :class="jobClasses" - class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" - data-testid="job-with-link" - @click.stop="hideTooltips" - @mouseout="hideTooltips" - > - <job-name-component :name="job.name" :status="job.status" /> - </gl-link> - - <div - v-else - v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" - :title="tooltipText" - :class="jobClasses" - class="js-job-component-tooltip non-details-job-component menu-item" - data-testid="job-without-link" - @mouseout="hideTooltips" - > - <job-name-component :name="job.name" :status="job.status" /> - </div> - - <action-component - v-if="hasAction" - :tooltip-text="jobActionTooltipText" - :link="status.action.path" - :action-icon="status.action.icon" - data-qa-selector="action_button" - /> - </div> + <div>{{ job.id }}</div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue new file mode 100644 index 00000000000..d6e585d093b --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_job_item.vue @@ -0,0 +1,168 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import delayedJobMixin from '~/jobs/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'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + i18n: { + runAgainTooltipText: s__('Pipeline|Run again'), + }, + tooltipConfig: { + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }, + components: { + ActionComponent, + JobNameComponent, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + cssClassJobName: { + type: String, + required: false, + default: '', + }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, + }, + computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, + detailsPath() { + return this.status?.details_path; + }, + hasDetails() { + return this.status?.has_details; + }, + status() { + return this.job?.status ? this.job.status : {}; + }, + tooltipText() { + const textBuilder = []; + const { name: jobName } = this.job; + + if (jobName) { + textBuilder.push(jobName); + } + + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { + textBuilder.push('-'); + } + + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } + } + + return textBuilder.join(' '); + }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasJobAction() { + return Boolean(this.job?.status?.action?.path); + }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === ICONS.RETRY && group === ICONS.SUCCESS + ? this.$options.i18n.runAgainTooltipText + : title; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); + }, +}; +</script> +<template> + <div + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-qa-selector="job_item_container" + > + <gl-link + v-if="hasDetails" + v-gl-tooltip="$options.tooltipConfig" + :href="detailsPath" + :title="tooltipText" + :class="cssClassJobName" + class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" + data-testid="job-with-link" + > + <job-name-component :name="job.name" :status="job.status" /> + </gl-link> + + <div + v-else + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" + :title="tooltipText" + :class="cssClassJobName" + class="js-job-component-tooltip non-details-job-component menu-item" + data-testid="job-without-link" + > + <job-name-component :name="job.name" :status="job.status" /> + </div> + + <action-component + v-if="hasJobAction" + :tooltip-text="jobActionTooltipText" + :link="status.action.path" + :action-icon="status.action.icon" + data-qa-selector="action_button" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue new file mode 100644 index 00000000000..8c0e65d1d39 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue @@ -0,0 +1,98 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import PipelineStages from './pipeline_stages.vue'; +import LinkedPipelinesMiniList from './linked_pipelines_mini_list.vue'; +/** + * Renders the pipeline mini graph. + * TODO: After all apps have updated to GraphQL data and use the `pipeline_mini_graph.vue` file as an entry, + * we should rename this file to `pipeline_mini_graph_wrapper.vue` + */ +export default { + components: { + GlIcon, + LinkedPipelinesMiniList, + PipelineStages, + }, + arrowStyles: [ + 'arrow-icon gl-display-inline-block gl-mx-1 gl-text-gray-500 gl-vertical-align-middle!', + ], + props: { + downstreamPipelines: { + type: Array, + required: false, + default: () => [], + }, + isGraphql: { + type: Boolean, + required: false, + default: false, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pipelinePath: { + type: String, + required: false, + default: '', + }, + stages: { + type: Array, + required: true, + default: () => [], + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + upstreamPipeline: { + type: Object, + required: false, + default: () => {}, + }, + }, + computed: { + hasDownstreamPipelines() { + return Boolean(this.downstreamPipelines.length); + }, + }, +}; +</script> +<template> + <div data-testid="pipeline-mini-graph"> + <linked-pipelines-mini-list + v-if="upstreamPipeline" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + upstreamPipeline, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + data-testid="pipeline-mini-graph-upstream" + /> + <gl-icon + v-if="upstreamPipeline" + :class="$options.arrowStyles" + name="long-arrow" + data-testid="upstream-arrow-icon" + /> + <pipeline-stages + :is-graphql="isGraphql" + :is-merge-train="isMergeTrain" + :stages="stages" + :update-dropdown="updateDropdown" + @miniGraphStageClick="$emit('miniGraphStageClick')" + /> + <gl-icon + v-if="hasDownstreamPipelines" + :class="$options.arrowStyles" + name="long-arrow" + data-testid="downstream-arrow-icon" + /> + <linked-pipelines-mini-list + v-if="hasDownstreamPipelines" + :triggered="downstreamPipelines" + :pipeline-path="pipelinePath" + data-testid="pipeline-mini-graph-downstream" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue new file mode 100644 index 00000000000..048e42731c7 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue @@ -0,0 +1,176 @@ +<script> +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { createAlert } from '~/alert'; +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 { + i18n: { + errorMessage: __('Something went wrong on our end.'), + loadingText: __('Loading...'), + mergeTrainMessage: s__('Pipeline|Merge train pipeline jobs can not be retried'), + stage: __('Stage:'), + viewStageLabel: __('View Stage: %{title}'), + }, + dropdownPopperOpts: { + placement: 'bottom', + positionFixed: true, + }, + components: { + CiIcon, + GlLoadingIcon, + GlDropdown, + LegacyJobItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + stage: { + type: Object, + required: true, + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isDropdownOpen: false, + isLoading: false, + dropdownContent: [], + stageName: '', + }; + }, + watch: { + updateDropdown() { + if (this.updateDropdown && this.isDropdownOpen && !this.isLoading) { + this.fetchJobs(); + } + }, + }, + methods: { + onHideDropdown() { + this.isDropdownOpen = false; + }, + onShowDropdown() { + eventHub.$emit('clickedDropdown'); + this.isDropdownOpen = true; + this.isLoading = true; + this.fetchJobs(); + + // used for tracking and is separate from event hub + // to avoid complexity with mixin + this.$emit('miniGraphStageClick'); + }, + fetchJobs() { + axios + .get(this.stage.dropdown_path) + .then(({ data }) => { + this.dropdownContent = data.latest_statuses; + this.stageName = data.name; + this.isLoading = false; + }) + .catch(() => { + this.$refs.dropdown.hide(); + this.isLoading = false; + + createAlert({ + message: this.$options.i18n.errorMessage, + }); + }); + }, + stageAriaLabel(title) { + return sprintf(this.$options.i18n.viewStageLabel, { title }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + v-gl-tooltip.hover.ds0 + v-gl-tooltip="stage.title" + data-testid="mini-pipeline-graph-dropdown" + variant="link" + :aria-label="stageAriaLabel(stage.title)" + :lazy="true" + :popper-opts="$options.dropdownPopperOpts" + :toggle-class="['gl-rounded-full!']" + menu-class="mini-pipeline-graph-dropdown-menu" + @hide="onHideDropdown" + @show="onShowDropdown" + > + <template #button-content> + <ci-icon + is-borderless + is-interactive + css-classes="gl-rounded-full" + :is-active="isDropdownOpen" + :size="24" + :status="stage.status" + class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1" + /> + </template> + <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state"> + <gl-loading-icon size="sm" class="gl-mr-3" /> + <p class="gl-line-height-normal gl-mb-0">{{ $options.i18n.loadingText }}</p> + </div> + <ul + v-else + class="js-builds-dropdown-list scrollable-menu" + data-testid="mini-pipeline-graph-dropdown-menu-list" + > + <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3"> + <span class="gl-mr-1">{{ $options.i18n.stage }}</span> + <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span> + </div> + <li v-for="job in dropdownContent" :key="job.id"> + <legacy-job-item + :dropdown-length="dropdownContent.length" + :job="job" + css-class-job-name="pipeline-job-item" + /> + </li> + <template v-if="isMergeTrain"> + <li class="gl-dropdown-divider" role="presentation"> + <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" /> + </li> + <li> + <div + class="gl-display-flex gl-align-items-center" + data-testid="warning-message-merge-trains" + > + <div class="menu-item gl-font-sm gl-text-gray-300!"> + {{ $options.i18n.mergeTrainMessage }} + </div> + </div> + </li> + </template> + </ul> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue index 827adf9f7f7..7cdaec81466 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue @@ -1,91 +1,150 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import PipelineStages from './pipeline_stages.vue'; -import LinkedPipelinesMiniList from './linked_pipelines_mini_list.vue'; -/** - * Renders the pipeline mini graph. - */ +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 LegacyPipelineMiniGraph from './legacy_pipeline_mini_graph.vue'; + export default { + i18n: { + linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'), + stagesFetchError: __('There was a problem fetching the pipeline stages.'), + }, components: { - GlIcon, - LinkedPipelinesMiniList, - PipelineStages, + GlLoadingIcon, + LegacyPipelineMiniGraph, }, - arrowStyles: [ - 'arrow-icon gl-display-inline-block gl-mx-1 gl-text-gray-500 gl-vertical-align-middle!', - ], props: { - downstreamPipelines: { - type: Array, - required: false, - default: () => [], - }, - isMergeTrain: { - type: Boolean, - required: false, - default: false, + pipelineEtag: { + type: String, + required: true, }, - pipelinePath: { + fullPath: { type: String, - required: false, - default: '', + required: true, }, - stages: { - type: Array, + iid: { + type: String, required: true, - default: () => [], }, - updateDropdown: { + isMergeTrain: { type: Boolean, required: false, default: false, }, - upstreamPipeline: { - type: Object, + pollInterval: { + type: Number, required: false, - default: () => {}, + default: PIPELINE_MINI_GRAPH_POLL_INTERVAL, + }, + }, + data() { + return { + linkedPipelines: null, + pipelineStages: [], + }; + }, + apollo: { + linkedPipelines: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getLinkedPipelinesQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline || this.linkedpipelines; + }, + error() { + createAlert({ message: this.$options.i18n.linkedPipelinesFetchError }); + }, + }, + pipelineStages: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineStagesQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline?.stages?.nodes || this.pipelineStages; + }, + error() { + createAlert({ message: this.$options.i18n.stagesFetchError }); + }, }, }, computed: { - hasDownstreamPipelines() { - return Boolean(this.downstreamPipelines.length); + downstreamPipelines() { + return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes); + }, + formattedStages() { + return this.pipelineStages.map((stage) => { + const { name, detailedStatus } = stage; + return { + // TODO: Once we fetch stage by ID with GraphQL, + // this method will change. + // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853 + id: stage.id, + dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, + name, + path: `${this.pipelinePath}#${name}`, + status: { + details_path: `${this.pipelinePath}#${name}`, + has_details: detailedStatus?.hasDetails || false, + ...detailedStatus, + }, + title: `${name}: ${detailedStatus?.text || ''}`, + }; + }); + }, + pipelinePath() { + return this.linkedPipelines?.path || ''; }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines); + toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages); }, }; </script> + <template> - <div data-testid="pipeline-mini-graph"> - <linked-pipelines-mini-list - v-if="upstreamPipeline" - :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - upstreamPipeline, - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - data-testid="pipeline-mini-graph-upstream" - /> - <gl-icon - v-if="upstreamPipeline" - :class="$options.arrowStyles" - name="long-arrow" - data-testid="upstream-arrow-icon" - /> - <pipeline-stages + <div> + <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" /> + <legacy-pipeline-mini-graph + v-else + data-testid="pipeline-mini-graph" + is-graphql + :downstream-pipelines="downstreamPipelines" :is-merge-train="isMergeTrain" - :stages="stages" - :update-dropdown="updateDropdown" - data-testid="pipeline-stages" - @miniGraphStageClick="$emit('miniGraphStageClick')" - /> - <gl-icon - v-if="hasDownstreamPipelines" - :class="$options.arrowStyles" - name="long-arrow" - data-testid="downstream-arrow-icon" - /> - <linked-pipelines-mini-list - v-if="hasDownstreamPipelines" - :triggered="downstreamPipelines" :pipeline-path="pipelinePath" - data-testid="pipeline-mini-graph-downstream" + :stages="formattedStages" + :upstream-pipeline="upstreamPipeline" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue index 936cd6f0be5..8e22f440089 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue @@ -1,173 +1,84 @@ <script> -/** - * Renders each stage of the pipeline mini graph. - * - * Given the provided endpoint will make a request to - * fetch the dropdown data when the stage is clicked. - * - * Request is made inside this component to make it reusable between: - * 1. Pipelines main table - * 2. Pipelines table in commit and Merge request views - * 3. Merge request widget - * 4. Commit widget - */ - -import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { __, sprintf } from '~/locale'; -import eventHub from '../../event_hub'; +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 JobItem from './job_item.vue'; export default { i18n: { - stage: __('Stage:'), - loadingText: __('Loading, please wait.'), - }, - dropdownPopperOpts: { - placement: 'bottom', - positionFixed: true, + stageFetchError: __('There was a problem fetching the pipeline stage.'), }, + components: { - CiIcon, - GlLoadingIcon, - GlDropdown, JobItem, }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { - stage: { - type: Object, - required: true, - }, - updateDropdown: { + isMergeTrain: { type: Boolean, required: false, default: false, }, - isMergeTrain: { - type: Boolean, + pipelineEtag: { + type: String, + required: true, + }, + pollInterval: { + type: Number, required: false, - default: false, + default: PIPELINE_MINI_GRAPH_POLL_INTERVAL, + }, + stageId: { + type: String, + required: true, }, }, data() { return { - isDropdownOpen: false, - isLoading: false, - dropdownContent: [], - stageName: '', + jobs: [], + stage: null, }; }, - watch: { - updateDropdown() { - if (this.updateDropdown && this.isDropdownOpen && !this.isLoading) { - this.fetchJobs(); - } + apollo: { + stage: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineStageQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + id: this.stageId, + }; + }, + skip() { + return !this.stageId; + }, + update(data) { + this.jobs = data?.ciPipelineStage?.jobs.nodes; + return data?.ciPipelineStage; + }, + error() { + createAlert({ message: this.$options.i18n.stageFetchError }); + }, }, }, - methods: { - onHideDropdown() { - this.isDropdownOpen = false; - }, - onShowDropdown() { - eventHub.$emit('clickedDropdown'); - this.isDropdownOpen = true; - this.isLoading = true; - this.fetchJobs(); - - // used for tracking and is separate from event hub - // to avoid complexity with mixin - this.$emit('miniGraphStageClick'); - }, - fetchJobs() { - axios - .get(this.stage.dropdown_path) - .then(({ data }) => { - this.dropdownContent = data.latest_statuses; - this.stageName = data.name; - this.isLoading = false; - }) - .catch(() => { - this.$refs.dropdown.hide(); - this.isLoading = false; - - createAlert({ - message: __('Something went wrong on our end.'), - }); - }); - }, - stageAriaLabel(title) { - return sprintf(__('View Stage: %{title}'), { title }); - }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.stage); }, }; </script> <template> - <gl-dropdown - ref="dropdown" - v-gl-tooltip.hover.ds0 - v-gl-tooltip="stage.title" - data-testid="mini-pipeline-graph-dropdown" - variant="link" - :aria-label="stageAriaLabel(stage.title)" - :lazy="true" - :popper-opts="$options.dropdownPopperOpts" - :toggle-class="['gl-rounded-full!']" - menu-class="mini-pipeline-graph-dropdown-menu" - @hide="onHideDropdown" - @show="onShowDropdown" - > - <template #button-content> - <ci-icon - is-borderless - is-interactive - css-classes="gl-rounded-full" - :is-active="isDropdownOpen" - :size="24" - :status="stage.status" - class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1" - /> - </template> - <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state"> - <gl-loading-icon size="sm" class="gl-mr-3" /> - <p class="gl-line-height-normal gl-mb-0">{{ $options.i18n.loadingText }}</p> - </div> - <ul - v-else - class="js-builds-dropdown-list scrollable-menu" - data-testid="mini-pipeline-graph-dropdown-menu-list" - > - <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3"> - <span class="gl-mr-1">{{ $options.i18n.stage }}</span> - <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span> - </div> - <li v-for="job in dropdownContent" :key="job.id"> - <job-item - :dropdown-length="dropdownContent.length" - :job="job" - css-class-job-name="mini-pipeline-graph-dropdown-item" - /> - </li> - <template v-if="isMergeTrain"> - <li class="gl-dropdown-divider" role="presentation"> - <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" /> - </li> - <li> - <div - class="gl-display-flex gl-align-items-center" - data-testid="warning-message-merge-trains" - > - <div class="menu-item gl-font-sm gl-text-gray-300!"> - {{ s__('Pipeline|Merge train pipeline jobs can not be retried') }} - </div> - </div> - </li> - </template> + <div data-testid="pipeline-stage"> + <ul v-for="job in jobs" :key="job.id"> + <job-item :job="job" /> </ul> - </gl-dropdown> + </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue index ba549d9b423..02dba9ba30f 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue @@ -1,10 +1,12 @@ <script> import PipelineStage from './pipeline_stage.vue'; +import LegacyPipelineStage from './legacy_pipeline_stage.vue'; /** * Renders the pipeline stages portion of the pipeline mini graph. */ export default { components: { + LegacyPipelineStage, PipelineStage, }, props: { @@ -17,22 +19,40 @@ export default { required: false, default: false, }, + isGraphql: { + type: Boolean, + required: false, + default: false, + }, isMergeTrain: { type: Boolean, required: false, default: false, }, + pipelineEtag: { + type: String, + required: false, + default: '', + }, }, }; </script> <template> - <div data-testid="pipeline-stages" class="gl-display-inline gl-vertical-align-middle"> + <div class="gl-display-inline gl-vertical-align-middle"> <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" > <pipeline-stage + v-if="isGraphql" + :stage-id="stage.id" + :is-merge-train="isMergeTrain" + :pipeline-etag="pipelineEtag" + @miniGraphStageClick="$emit('miniGraphStageClick')" + /> + <legacy-pipeline-stage + v-else :stage="stage" :update-dropdown="updateDropdown" :is-merge-train="isMergeTrain" diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue index d2ec3c352fe..35dde6379dd 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue @@ -1,12 +1,14 @@ <script> import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; import { failedJobsTabName, jobsTabName, needsTabName, pipelineTabName, testReportTabName, + TRACKING_CATEGORIES, } from '../constants'; export default { @@ -31,6 +33,7 @@ export default { GlTab, GlTabs, }, + mixins: [Tracking.mixin()], inject: ['defaultTabValue', 'failedJobsCount', 'totalJobCount', 'testsCount'], data() { return { @@ -52,8 +55,20 @@ export default { return tabName === this.activeTab; }, navigateTo(tabName) { + if (this.isActive(tabName)) return; + this.$router.push({ name: tabName }); }, + failedJobsTabClick() { + this.track('click_tab', { label: TRACKING_CATEGORIES.failed }); + + this.navigateTo(this.$options.tabNames.failures); + }, + testsTabClick() { + this.track('click_tab', { label: TRACKING_CATEGORIES.tests }); + + this.navigateTo(this.$options.tabNames.tests); + }, }, }; </script> @@ -98,7 +113,7 @@ export default { :active="isActive($options.tabNames.failures)" data-testid="failed-jobs-tab" lazy - @click="navigateTo($options.tabNames.failures)" + @click="failedJobsTabClick" > <template #title> <span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span> @@ -110,7 +125,7 @@ export default { :active="isActive($options.tabNames.tests)" data-testid="tests-tab" lazy - @click="navigateTo($options.tabNames.tests)" + @click="testsTabClick" > <template #title> <span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue index 6b5e3d77b92..edf4cc87a87 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue @@ -1,17 +1,17 @@ <script> -import { GlButton, GlCollapse, GlIcon, GlLink, GlTooltip } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLink, GlTooltip } from '@gitlab/ui'; import { createAlert } from '~/alert'; 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'; export default { components: { CiIcon, GlButton, - GlCollapse, GlIcon, GlLink, GlTooltip, @@ -33,17 +33,14 @@ export default { }; }, computed: { - activeClass() { - return this.isHovered ? 'gl-bg-gray-50' : ''; - }, canReadBuild() { return this.job.userPermissions.readBuild; }, canRetryJob() { - return this.job.retryable && this.job.userPermissions.updateBuild; + return this.job.retryable && this.job.userPermissions.updateBuild && !this.isBridgeJob; }, - isVisibleId() { - return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`; + isBridgeJob() { + return this.job.kind === BRIDGE_KIND; }, jobChevronName() { return this.isJobLogVisible ? 'chevron-down' : 'chevron-right'; @@ -58,6 +55,11 @@ export default { parsedJobId() { return getIdFromGraphQLId(this.job.id); }, + tooltipErrorText() { + return this.isBridgeJob + ? this.$options.i18n.cannotRetryTrigger + : this.$options.i18n.cannotRetry; + }, tooltipText() { return sprintf(this.$options.i18n.jobActionTooltipText, { jobName: this.job.name }); }, @@ -102,8 +104,9 @@ export default { }, }, i18n: { - cannotReadBuild: s__("Job|You do not have permission to read this job's log"), - cannotRetry: s__('Job|You do not have permission to retry this job'), + cannotReadBuild: s__("Job|You do not have permission to read this job's log."), + cannotRetry: s__('Job|You do not have permission to run this job again.'), + cannotRetryTrigger: s__('Job|You cannot rerun trigger jobs from this list.'), jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'), noTraceText: s__('Job|No job log'), retry: __('Retry'), @@ -114,8 +117,7 @@ export default { <template> <div class="container-fluid gl-grid-tpl-rows-auto"> <div - class="row gl-py-4 gl-cursor-pointer gl-display-flex gl-align-items-center" - :class="activeClass" + class="row gl-my-3 gl-cursor-pointer gl-display-flex gl-align-items-center" :aria-pressed="isJobLogVisible" role="button" tabindex="0" @@ -127,22 +129,23 @@ export default { @mouseout="resetActiveRow" > <div class="col-6 gl-text-gray-900 gl-font-weight-bold gl-text-left"> - <gl-icon :name="jobChevronName" class="gl-fill-blue-500" /> + <gl-icon :name="jobChevronName" /> <ci-icon :status="job.detailedStatus" /> {{ job.name }} </div> <div class="col-2 gl-text-left">{{ job.stage.name }}</div> <div class="col-2 gl-text-left"> - <gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link> + <gl-link :href="job.detailedStatus.detailsPath">#{{ parsedJobId }}</gl-link> </div> <gl-tooltip v-if="!canRetryJob" :target="() => $refs.retryBtn" placement="top"> - {{ $options.i18n.cannotRetry }} + {{ tooltipErrorText }} </gl-tooltip> - <div class="col-2 gl-text-left"> + <div class="col-2 gl-text-right"> <span ref="retryBtn"> <gl-button :disabled="!canRetryJob" icon="retry" + category="tertiary" :loading="isLoadingAction" :title="$options.i18n.retry" :aria-label="$options.i18n.retry" @@ -151,14 +154,12 @@ export default { </span> </div> </div> - <div class="row"> - <gl-collapse :visible="isJobLogVisible" class="gl-w-full"> - <pre - v-safe-html="jobTrace" - class="gl-bg-gray-900 gl-text-white" - :data-testid="isVisibleId" - ></pre> - </gl-collapse> + <div v-if="isJobLogVisible" class="row"> + <pre + v-safe-html="jobTrace" + class="gl-bg-gray-900 gl-text-white gl-w-full" + data-testid="job-log" + ></pre> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue index 36687129cdd..2c5aa84bc4f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue @@ -9,9 +9,8 @@ import FailedJobDetails from './failed_job_details.vue'; const POLL_INTERVAL = 10000; -const JOB_ACTION_HEADER = __('Actions'); -const JOB_ID_HEADER = __('Job ID'); -const JOB_NAME_HEADER = __('Job name'); +const JOB_ID_HEADER = __('ID'); +const JOB_NAME_HEADER = __('Name'); const STAGE_HEADER = __('Stage'); export default { @@ -19,8 +18,12 @@ export default { GlLoadingIcon, FailedJobDetails, }, - inject: ['fullPath', 'graphqlPath'], + inject: ['graphqlPath'], props: { + failedJobsCount: { + required: true, + type: Number, + }, isPipelineActive: { required: true, type: Boolean, @@ -29,6 +32,10 @@ export default { type: Number, required: true, }, + projectPath: { + type: String, + required: true, + }, }, data() { return { @@ -46,7 +53,7 @@ export default { pollInterval: POLL_INTERVAL, variables() { return { - fullPath: this.fullPath, + fullPath: this.projectPath, pipelineIid: this.pipelineIid, }; }, @@ -92,6 +99,13 @@ export default { isActive(flag) { this.handlePolling(flag); }, + failedJobsCount(count) { + // If the REST data is updated first, we force a refetch + // to keep them in sync + if (this.failedJobs.length !== count) { + this.$apollo.queries.failedJobs.refetch(); + } + }, }, mounted() { if (!this.isActive && !this.isPipelineActive) { @@ -129,7 +143,6 @@ export default { { text: JOB_NAME_HEADER, class: 'col-6' }, { text: STAGE_HEADER, class: 'col-2' }, { text: JOB_ID_HEADER, class: 'col-2' }, - { text: JOB_ACTION_HEADER, class: 'col-2' }, ], i18n: { fetchError: __('There was a problem fetching failed jobs'), @@ -141,10 +154,10 @@ export default { <template> <div> - <gl-loading-icon v-if="isInitialLoading" /> - <div v-else-if="!hasFailedJobs">{{ $options.i18n.noFailedJobs }}</div> + <gl-loading-icon v-if="isInitialLoading" class="gl-p-4" /> + <div v-else-if="!hasFailedJobs" class="gl-p-4">{{ $options.i18n.noFailedJobs }}</div> <div v-else class="container-fluid gl-grid-tpl-rows-auto"> - <div class="row gl-mb-6 gl-text-gray-900"> + <div class="row gl-my-4 gl-text-gray-900"> <div v-for="col in $options.columns" :key="col.text" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue index 5e49c05f47d..60c429459bf 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue @@ -1,12 +1,12 @@ <script> -import { GlButton, GlCollapse, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlCard, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import FailedJobsList from './failed_jobs_list.vue'; export default { components: { GlButton, - GlCollapse, + GlCard, GlIcon, GlLink, GlPopover, @@ -31,6 +31,10 @@ export default { required: true, type: String, }, + projectPath: { + required: true, + type: String, + }, }, data() { return { @@ -44,7 +48,7 @@ export default { return this.isExpanded ? '' : 'gl-display-none'; }, failedJobsCountText() { - return sprintf(this.$options.i18n.showFailedJobs, { count: this.currentFailedJobsCount }); + return sprintf(this.$options.i18n.failedJobsLabel, { count: this.currentFailedJobsCount }); }, iconName() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; @@ -71,37 +75,47 @@ export default { 'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.', ), additionalInfoTitle: __('Limitation on this view'), - showFailedJobs: __('Show failed jobs (%{count})'), + failedJobsLabel: __('Failed jobs (%{count})'), }, }; </script> <template> - <div class="gl-border-none!"> - <gl-button variant="link" @click="toggleWidget"> - <gl-icon :name="iconName" /> - {{ failedJobsCountText }} - <gl-icon :id="popoverId" name="information-o" /> - <gl-popover :target="popoverId" placement="top"> - <template #title> {{ $options.i18n.additionalInfoTitle }} </template> - <slot> - <gl-sprintf :message="$options.i18n.additionalInfoPopover"> - <template #link="{ content }"> - <gl-link class="gl-font-sm" :href="pipelinePath"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </slot> - </gl-popover> - </gl-button> - <gl-collapse - v-model="isExpanded" - class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3" - > - <failed-jobs-list - v-if="isExpanded" - :is-pipeline-active="isPipelineActive" - :pipeline-iid="pipelineIid" - @failed-jobs-count="setFailedJobsCount" - /> - </gl-collapse> - </div> + <gl-card + class="gl-new-card" + :class="{ 'gl-border-white gl-hover-border-gray-100': !isExpanded }" + header-class="gl-new-card-header gl-px-3 gl-py-3" + body-class="gl-new-card-body" + data-testid="failed-jobs-card" + :aria-expanded="isExpanded.toString()" + > + <template #header> + <gl-button + variant="link" + class="gl-text-gray-700! gl-font-weight-semibold" + @click="toggleWidget" + > + <gl-icon :name="iconName" /> + {{ failedJobsCountText }} + <gl-icon :id="popoverId" name="information-o" class="gl-ml-2" /> + <gl-popover :target="popoverId" placement="top"> + <template #title> {{ $options.i18n.additionalInfoTitle }} </template> + <slot> + <gl-sprintf :message="$options.i18n.additionalInfoPopover"> + <template #link="{ content }"> + <gl-link class="gl-font-sm" :href="pipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </slot> + </gl-popover> + </gl-button> + </template> + <failed-jobs-list + v-if="isExpanded" + :failed-jobs-count="failedJobsCount" + :is-pipeline-active="isPipelineActive" + :pipeline-iid="pipelineIid" + :project-path="projectPath" + @failed-jobs-count="setFailedJobsCount" + /> + </gl-card> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index 73a255f392b..747d94d92f2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -16,6 +16,9 @@ import { TRACKING_CATEGORIES } from '../../constants'; export const i18n = { downloadArtifacts: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), + artifactsFetchWarningMessage: s__( + 'Pipelines|Failed to update. Please reload page to update the list of artifacts.', + ), emptyArtifactsMessage: __('No artifacts found'), }; @@ -52,6 +55,7 @@ export default { hasError: false, isLoading: false, searchQuery: '', + isNewPipeline: false, }; }, computed: { @@ -64,13 +68,24 @@ export default { : this.artifacts; }, }, + watch: { + pipelineId() { + this.isNewPipeline = true; + }, + }, methods: { fetchArtifacts() { // refactor tracking based on action once this dropdown supports // actions other than artifacts this.track('click_artifacts_dropdown', { label: TRACKING_CATEGORIES.table }); + // Preserve the last good list and present it if a request fails + const oldArtifacts = [...this.artifacts]; + this.artifacts = []; + + this.hasError = false; this.isLoading = true; + // Replace the placeholder with the ID of the pipeline we are viewing const endpoint = this.artifactsEndpoint.replace( this.artifactsEndpointPlaceholder, @@ -80,9 +95,13 @@ export default { .get(endpoint) .then(({ data }) => { this.artifacts = data.artifacts; + this.isNewPipeline = false; }) .catch(() => { this.hasError = true; + if (!this.isNewPipeline) { + this.artifacts = oldArtifacts; + } }) .finally(() => { this.isLoading = false; @@ -108,10 +127,10 @@ export default { right lazy text-sr-only - @show.once="fetchArtifacts" + @show="fetchArtifacts" @shown="handleDropdownShown" > - <gl-alert v-if="hasError" variant="danger" :dismissible="false"> + <gl-alert v-if="hasError && !hasArtifacts" variant="danger" :dismissible="false"> {{ $options.i18n.artifactsFetchErrorMessage }} </gl-alert> @@ -136,5 +155,18 @@ export default { > {{ artifact.name }} </gl-dropdown-item> + + <template #footer> + <gl-dropdown-item + v-if="hasError && hasArtifacts" + class="gl-list-style-none" + disabled + data-testid="artifacts-fetch-warning" + > + <span class="gl-font-sm"> + {{ $options.i18n.artifactsFetchWarningMessage }} + </span> + </gl-dropdown-item> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 7d41700c492..574d291a767 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { isEqual } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index dbb0b443235..c03085e6419 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,10 +1,11 @@ <script> import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; +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 PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +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'; @@ -21,8 +22,8 @@ const DEFAULT_TH_CLASSES = export default { components: { GlTableLite, + LegacyPipelineMiniGraph, PipelineFailedJobsWidget, - PipelineMiniGraph, PipelineOperations, PipelinesStatusBadge, PipelineStopModal, @@ -146,6 +147,9 @@ export default { const downstream = pipeline.triggered; return keepLatestDownstreamPipelines(downstream); }, + getProjectPath(item) { + return cleanLeadingSeparator(item.project.full_path); + }, failedJobsCount(pipeline) { return pipeline?.failed_builds?.length || 0; }, @@ -204,7 +208,7 @@ export default { </template> <template #cell(stages)="{ item }"> - <pipeline-mini-graph + <legacy-pipeline-mini-graph :downstream-pipelines="getDownstreamPipelines(item)" :pipeline-path="item.path" :stages="item.details.stages" @@ -225,6 +229,8 @@ export default { :is-pipeline-active="item.active" :pipeline-iid="item.iid" :pipeline-path="item.path" + :project-path="getProjectPath(item)" + class="gl-ml-n4 gl-mt-n3 gl-mb-n1" /> </template> </gl-table-lite> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 3f2c013d44a..a7737d33285 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 19318cb0c8b..d8af926a796 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -10,6 +10,7 @@ import { GlEmptyState, GlSprintf, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 2b7b2d78424..9141947ea04 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index a6dd835bb15..93ca3738ff0 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -13,6 +13,8 @@ export const ICONS = { TAG: 'tag', MR: 'git-merge', BRANCH: 'branch', + RETRY: 'retry', + SUCCESS: 'success', }; export const TestStatus = { @@ -109,6 +111,8 @@ 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 diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql index 3d69c5e451b..6b553866f63 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql @@ -4,13 +4,14 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { pipeline(iid: $pipelineIid) { id active - jobs(statuses: [FAILED], retried: false, jobKind: BUILD) { + jobs(statuses: [FAILED], retried: false) { count nodes { id allowFailure detailedStatus { id + detailsPath group icon action { @@ -19,6 +20,7 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { icon } } + kind name retried retryable @@ -33,7 +35,6 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { readBuild updateBuild } - webPath } } } diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql new file mode 100644 index 00000000000..64a5964dbeb --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stage.query.graphql @@ -0,0 +1,32 @@ +query getPipelineStage($id: CiStageID!) { + ciPipelineStage(id: $id) { + id + name + detailedStatus { + id + group + icon + } + jobs { + nodes { + id + detailedStatus { + id + action { + id + icon + path + title + } + detailsPath + hasDetails + group + icon + tooltip + } + name + } + } + status + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index 33bdedee764..00a1810926c 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import { GlToast } from '@gitlab/ui'; diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index b8276327843..38be5becfb8 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,7 +1,12 @@ import * as Sentry from '@sentry/browser'; import { pickBy } from 'lodash'; import { parseUrlPathname } from '~/lib/utils/url_utility'; -import { NEEDS_PROPERTY, SUPPORTED_FILTER_PARAMETERS, validPipelineTabNames } from './constants'; +import { + NEEDS_PROPERTY, + SUPPORTED_FILTER_PARAMETERS, + validPipelineTabNames, + pipelineTabName, +} 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. @@ -144,9 +149,8 @@ export const getPipelineDefaultTab = (url) => { const regexp = /\w*$/; const [tabName] = strippedUrl.match(regexp); - if (tabName && validPipelineTabNames.includes(tabName)) { - return tabName; - } + if (tabName && validPipelineTabNames.includes(tabName)) return tabName; + if (tabName === '') return pipelineTabName; return null; }; diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue index 7ec54231e65..f91a9e1e33a 100644 --- a/app/assets/javascripts/popovers/components/popovers.vue +++ b/app/assets/javascripts/popovers/components/popovers.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlPopover } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/profile/add_ssh_key_validation.js b/app/assets/javascripts/profile/add_ssh_key_validation.js index 628dd159db8..8e395a1c6e9 100644 --- a/app/assets/javascripts/profile/add_ssh_key_validation.js +++ b/app/assets/javascripts/profile/add_ssh_key_validation.js @@ -5,6 +5,7 @@ export default class AddSshKeyValidation { warningElement, originalSubmitElement, confirmSubmitElement, + cancelButtonElement, ) { this.inputElement = inputElement; this.form = inputElement.form; @@ -16,6 +17,7 @@ export default class AddSshKeyValidation { this.originalSubmitElement = originalSubmitElement; this.confirmSubmitElement = confirmSubmitElement; + this.cancelButtonElement = cancelButtonElement; this.isValid = false; } @@ -44,6 +46,7 @@ export default class AddSshKeyValidation { toggleWarning(isVisible) { this.warningElement.classList.toggle('hide', !isVisible); this.originalSubmitElement.classList.toggle('hide', isVisible); + this.cancelButtonElement?.classList.toggle('hide', isVisible); } isPublicKey(value) { diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue index 2673ab6fbf4..d57f884e345 100644 --- a/app/assets/javascripts/profile/components/follow.vue +++ b/app/assets/javascripts/profile/components/follow.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAvatarLabeled, diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue index ab29d94c41c..6b39f137880 100644 --- a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue +++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue @@ -1,10 +1,182 @@ <script> -export default {}; +import { nextTick } from 'vue'; +import { GlForm, GlButton } from '@gitlab/ui'; +import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { readFileAsDataURL } from '~/lib/utils/file_utility'; +import SetStatusForm from '~/set_status_modal/set_status_form.vue'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; +import { isUserBusy, computedClearStatusAfterValue } from '~/set_status_modal/utils'; +import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; + +import { i18n, statusI18n } from '../constants'; +import UserAvatar from './user_avatar.vue'; + +export default { + components: { + UserAvatar, + GlForm, + GlButton, + SettingsBlock, + SetStatusForm, + }, + inject: [ + 'currentEmoji', + 'currentMessage', + 'currentAvailability', + 'defaultEmoji', + 'currentClearStatusAfter', + ], + props: { + profilePath: { + type: String, + required: true, + }, + userPath: { + type: String, + required: true, + }, + }, + data() { + return { + uploadingProfile: false, + avatarBlob: null, + status: { + emoji: this.currentEmoji, + message: this.currentMessage, + availability: isUserBusy(this.currentAvailability), + clearStatusAfter: null, + }, + }; + }, + computed: { + shouldIncludeClearStatusAfterInApiRequest() { + return this.status.clearStatusAfter !== null; + }, + clearStatusAfterApiRequestValue() { + return computedClearStatusAfterValue(this.status.clearStatusAfter); + }, + }, + methods: { + async onSubmit() { + // TODO: Do validation before organizing data. + this.uploadingProfile = true; + const formData = new FormData(); + + // Setting up status data + const statusFieldNameBase = 'user[status]'; + formData.append(`${statusFieldNameBase}[emoji]`, this.status.emoji); + formData.append(`${statusFieldNameBase}[message]`, this.status.message); + formData.append( + `${statusFieldNameBase}[availability]`, + this.status.availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, + ); + + if (this.shouldIncludeClearStatusAfterInApiRequest) { + formData.append( + `${statusFieldNameBase}[clear_status_after]`, + this.clearStatusAfterApiRequestValue, + ); + } + + if (this.avatarBlob) { + formData.append('user[avatar]', this.avatarBlob, 'avatar.png'); + } + + try { + const { data } = await axios.put(this.profilePath, formData); + + if (this.avatarBlob) { + this.syncHeaderAvatars(); + } + createAlert({ + message: data.message, + variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO, + }); + + nextTick(() => { + window.scrollTo(0, 0); + this.uploadingProfile = false; + }); + } catch (e) { + createAlert({ + message: e.message, + variant: VARIANT_DANGER, + }); + this.updateProfileSettings = false; + } + }, + async syncHeaderAvatars() { + const dataURL = await readFileAsDataURL(this.avatarBlob); + + // TODO: implement sync for super sidebar + ['.header-user-avatar', '.js-sidebar-user-avatar'].forEach((selector) => { + const node = document.querySelector(selector); + if (!node) return; + + node.setAttribute('src', dataURL); + node.setAttribute('srcset', dataURL); + }); + }, + onBlobChange(blob) { + this.avatarBlob = blob; + }, + onMessageInput(value) { + this.status.message = value; + }, + onEmojiClick(emoji) { + this.status.emoji = emoji; + }, + onClearStatusAfterClick(after) { + this.status.clearStatusAfter = after; + }, + onAvailabilityInput(value) { + this.status.availability = value; + }, + }, + i18n: { + ...i18n, + ...statusI18n, + }, +}; </script> <template> - <!-- This is left empty intensionally --> - <!-- It will be implemented in the upcoming MRs --> - <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 --> - <div></div> + <gl-form class="edit-user" @submit.prevent="onSubmit"> + <user-avatar @blob-change="onBlobChange" /> + <settings-block class="js-search-settings-section"> + <template #title>{{ $options.i18n.setStatusTitle }}</template> + <template #description>{{ $options.i18n.setStatusDescription }}</template> + <div class="gl-max-w-80"> + <set-status-form + :default-emoji="defaultEmoji" + :emoji="status.emoji" + :message="status.message" + :availability="status.availability" + :clear-status-after="status.clearStatusAfter" + :current-clear-status-after="currentClearStatusAfter" + @message-input="onMessageInput" + @emoji-click="onEmojiClick" + @clear-status-after-click="onClearStatusAfterClick" + @availability-input="onAvailabilityInput" + /> + </div> + </settings-block> + <!-- TODO: to implement profile editing form fields --> + <!-- It will be implemented in the upcoming MRs --> + <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 --> + <div class="js-hide-when-nothing-matches-search gl-border-t gl-py-6"> + <gl-button + variant="confirm" + type="submit" + class="gl-mr-3 js-password-prompt-btn" + :disabled="uploadingProfile" + > + {{ $options.i18n.updateProfileSettings }} + </gl-button> + <gl-button :href="userPath" data-testid="cancel-edit-button"> + {{ $options.i18n.cancel }} + </gl-button> + </div> + </gl-form> </template> diff --git a/app/assets/javascripts/profile/edit/components/user_avatar.vue b/app/assets/javascripts/profile/edit/components/user_avatar.vue new file mode 100644 index 00000000000..f0ff972336b --- /dev/null +++ b/app/assets/javascripts/profile/edit/components/user_avatar.vue @@ -0,0 +1,174 @@ +<script> +import $ from 'jquery'; +import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import SafeHtmlDirective from '~/vue_shared/directives/safe_html'; + +import { avatarI18n } from '../constants'; + +export default { + name: 'EditProfileUserAvatar', + components: { + GlAvatar, + GlAvatarLink, + GlButton, + GlLink, + GlSprintf, + }, + directives: { + SafeHtml: SafeHtmlDirective, + }, + inject: [ + 'avatarUrl', + 'brandProfileImageGuidelines', + 'cropperCssPath', + 'hasAvatar', + 'gravatarEnabled', + 'gravatarLink', + 'profileAvatarPath', + ], + computed: { + avatarHelpText() { + const { changeOrRemoveAvatar, changeAvatar, uploadOrChangeAvatar, uploadAvatar } = avatarI18n; + if (this.hasAvatar) { + return this.gravatarEnabled ? changeOrRemoveAvatar : changeAvatar; + } + return this.gravatarEnabled ? uploadOrChangeAvatar : uploadAvatar; + }, + }, + + mounted() { + this.initializeCropper(); + loadCSSFile(this.cropperCssPath); + }, + + methods: { + initializeCropper() { + const cropOpts = { + filename: '.js-avatar-filename', + previewImage: '.avatar-image .gl-avatar', + modalCrop: '.modal-profile-crop', + pickImageEl: '.js-choose-user-avatar-button', + uploadImageBtn: '.js-upload-user-avatar', + modalCropImg: '.modal-profile-crop-image', + onBlobChange: this.onBlobChange, + }; + // This has to be used with jQuery, considering migrate that from jQuery to Vue in the future. + $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); + }, + onBlobChange(blob) { + this.$emit('blob-change', blob); + }, + }, + i18n: avatarI18n, +}; +</script> + +<template> + <div class="js-search-settings-section gl-pb-6"> + <div class="profile-settings-sidebar"> + <h4 class="gl-my-0"> + {{ $options.i18n.publicAvatar }} + </h4> + <p class="gl-text-secondary"> + <gl-sprintf :message="avatarHelpText"> + <template #gravatar_link> + <gl-link :href="gravatarLink.url" target="__blank"> + {{ gravatarLink.hostname }} + </gl-link> + </template> + </gl-sprintf> + </p> + <div + v-if="brandProfileImageGuidelines" + v-safe-html="brandProfileImageGuidelines" + class="md gl-mb-5" + data-testid="brand-profile-image-guidelines" + ></div> + </div> + <div class="gl-display-flex"> + <div class="avatar-image"> + <gl-avatar-link :href="avatarUrl" target="blank"> + <gl-avatar class="gl-mr-5" :src="avatarUrl" :size="96" shape="circle" /> + </gl-avatar-link> + </div> + <div class="gl-flex-grow-1"> + <h5 class="gl-mt-0"> + {{ $options.i18n.uploadNewAvatar }} + </h5> + <div class="gl-display-flex gl-align-items-center gl-my-3"> + <gl-button class="js-choose-user-avatar-button"> + {{ $options.i18n.chooseFile }} + </gl-button> + <span class="gl-ml-3 js-avatar-filename">{{ $options.i18n.noFileChosen }}</span> + <input + id="user_avatar" + class="js-user-avatar-input hidden" + accept="image/*" + type="file" + name="user[avatar]" + /> + </div> + <p class="gl-mb-0 gl-text-gray-500">{{ $options.i18n.maximumFileSize }}</p> + <gl-button + v-if="hasAvatar" + class="gl-mt-3" + category="secondary" + variant="danger" + data-method="delete" + rel="nofollow" + data-testid="remove-avatar-button" + :data-confirm="$options.i18n.removeAvatarConfirmation" + :href="profileAvatarPath" + > + {{ $options.i18n.removeAvatar }} + </gl-button> + </div> + </div> + <!-- For bs.modal to take over --> + <div class="modal modal-profile-crop" :data-cropper-css-path="cropperCssPath"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title"> + {{ $options.i18n.cropAvatarTitle }} + </h4> + <gl-button + category="tertiary" + icon="close" + class="close" + data-dismiss="modal" + :aria-label="__('Close')" + /> + </div> + <div class="modal-body"> + <div class="profile-crop-image-container"> + <img :alt="$options.i18n.cropAvatarImageAltText" class="modal-profile-crop-image" /> + </div> + <div class="gl-text-center gl-mt-4"> + <div class="btn-group"> + <gl-button + :aria-label="__('Zoom out')" + icon="search-minus" + data-method="zoom" + data-option="-0.1" + /> + <gl-button + :aria-label="__('Zoom in')" + icon="search-plus" + data-method="zoom" + data-option="0.1" + /> + </div> + </div> + </div> + <div class="modal-footer"> + <gl-button class="js-upload-user-avatar" variant="confirm">{{ + $options.i18n.cropAvatarSetAsNewAvatar + }}</gl-button> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/profile/edit/constants.js b/app/assets/javascripts/profile/edit/constants.js new file mode 100644 index 00000000000..e07615273f7 --- /dev/null +++ b/app/assets/javascripts/profile/edit/constants.js @@ -0,0 +1,34 @@ +import { s__, __ } from '~/locale'; + +export const avatarI18n = { + publicAvatar: s__('Profiles|Public avatar'), + changeOrRemoveAvatar: s__( + 'Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}', + ), + changeAvatar: s__('Profiles|You can change your avatar here'), + uploadOrChangeAvatar: s__( + 'Profiles|You can upload your avatar here or change it at %{gravatar_link}', + ), + uploadAvatar: s__('Profiles|You can upload your avatar here'), + uploadNewAvatar: s__('Profiles|Upload new avatar'), + chooseFile: s__('Profiles|Choose file...'), + noFileChosen: s__('Profiles|No file chosen.'), + maximumFileSize: s__('Profiles|The maximum file size allowed is 200KB.'), + removeAvatar: s__('Profiles|Remove avatar'), + removeAvatarConfirmation: s__('Profiles|Avatar will be removed. Are you sure?'), + cropAvatarTitle: s__('Profiles|Position and size your new avatar'), + cropAvatarImageAltText: s__('Profiles|Avatar cropper'), + cropAvatarSetAsNewAvatar: s__('Profiles|Set new profile picture'), +}; + +export const statusI18n = { + setStatusTitle: s__('Profiles|Current status'), + setStatusDescription: s__( + 'Profiles|This emoji and message will appear on your profile and throughout the interface.', + ), +}; + +export const i18n = { + updateProfileSettings: s__('Profiles|Update profile settings'), + cancel: __('Cancel'), +}; diff --git a/app/assets/javascripts/profile/edit/index.js b/app/assets/javascripts/profile/edit/index.js index b46a395d6f5..27b410c3a12 100644 --- a/app/assets/javascripts/profile/edit/index.js +++ b/app/assets/javascripts/profile/edit/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import ProfileEditApp from './components/profile_edit_app.vue'; export const initProfileEdit = () => { @@ -6,11 +7,38 @@ export const initProfileEdit = () => { if (!mountEl) return false; + const { + profilePath, + userPath, + currentEmoji, + currentMessage, + currentAvailability, + defaultEmoji, + currentClearStatusAfter, + ...provides + } = mountEl.dataset; + return new Vue({ el: mountEl, name: 'ProfileEditRoot', + provide: { + ...provides, + currentEmoji, + currentMessage, + currentAvailability, + defaultEmoji, + currentClearStatusAfter, + hasAvatar: parseBoolean(provides.hasAvatar), + gravatarEnabled: parseBoolean(provides.gravatarEnabled), + gravatarLink: JSON.parse(provides.gravatarLink), + }, render(createElement) { - return createElement(ProfileEditApp); + return createElement(ProfileEditApp, { + props: { + profilePath, + userPath, + }, + }); }, }); }; diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 107bfd159dd..ea1a5199ece 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -25,6 +25,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; exportHeight = 200, cropBoxWidth = 200, cropBoxHeight = 200, + onBlobChange = () => {}, } = {}, ) { this.onUploadImageBtnClick = this.onUploadImageBtnClick.bind(this); @@ -54,6 +55,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; this.uploadImageBtn = isString(uploadImageBtn) ? $(uploadImageBtn) : uploadImageBtn; this.modalCropImg = isString(modalCropImg) ? $(modalCropImg) : modalCropImg; this.cropActionsBtn = this.modalCrop.find('[data-method]'); + this.onBlobChange = onBlobChange; this.bindEvents(); } @@ -75,6 +77,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; const btn = this; return _this.onActionBtnClick(btn); }); + this.onBlobChange(null); return (this.croppedImageBlob = null); } @@ -187,7 +190,10 @@ import { loadCSSFile } from '../lib/utils/css_utils'; height: 200, }) .toDataURL('image/png'); - return (this.croppedImageBlob = this.dataURLtoBlob(this.dataURL)); + + this.croppedImageBlob = this.dataURLtoBlob(this.dataURL); + this.onBlobChange(this.croppedImageBlob); + return this.croppedImageBlob; } getBlob() { diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index 77e809e88ce..c44d97c9bf8 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -1,5 +1,6 @@ <script> import { GlCollapsibleListbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { debounce, uniqBy } from 'lodash'; import { diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 28bbf67c090..44b8ccb57ca 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import api from '~/api'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue index fe54b62e2c8..e2b004e0892 100644 --- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue @@ -1,5 +1,6 @@ <script> import { GlCollapsibleListbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import { debounce, uniqBy } from 'lodash'; import { diff --git a/app/assets/javascripts/projects/commit/store/index.js b/app/assets/javascripts/projects/commit/store/index.js index 83802f6a36f..450b40091dd 100644 --- a/app/assets/javascripts/projects/commit/store/index.js +++ b/app/assets/javascripts/projects/commit/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; 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 84e7edb48c1..6ff9bd7390f 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -7,7 +7,7 @@ import { toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue'; +import 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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; @@ -23,7 +23,7 @@ export default { }, components: { GlLoadingIcon, - GraphqlPipelineMiniGraph, + LegacyPipelineMiniGraph, PipelineMiniGraph, }, mixins: [glFeatureFlagsMixin()], @@ -139,14 +139,14 @@ export default { <div> <gl-loading-icon v-if="$apollo.queries.pipeline.loading" /> <template v-else> - <graphql-pipeline-mini-graph + <pipeline-mini-graph v-if="isUsingPipelineMiniGraphQueries" data-testid="commit-box-pipeline-mini-graph" :pipeline-etag="graphqlResourceEtag" :full-path="fullPath" :iid="iid" /> - <pipeline-mini-graph + <legacy-pipeline-mini-graph v-else data-testid="commit-box-pipeline-mini-graph" :downstream-pipelines="downstreamPipelines" diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index cf251bc7465..8bc7a27bcad 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -1,6 +1,7 @@ <script> import { GlAvatar, GlCollapsibleListbox, GlTooltipDirective } from '@gitlab/ui'; import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { queryToObject, visitUrl } from '~/lib/utils/url_utility'; import { n__, __ } from '~/locale'; diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js index d37c1800718..ff7ad67c0a5 100644 --- a/app/assets/javascripts/projects/commits/index.js +++ b/app/assets/javascripts/projects/commits/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { visitUrl } from '~/lib/utils/url_utility'; import RefSelector from '~/ref/components/ref_selector.vue'; diff --git a/app/assets/javascripts/projects/commits/store/index.js b/app/assets/javascripts/projects/commits/store/index.js index e864ef5716e..4fb1bc093c7 100644 --- a/app/assets/javascripts/projects/commits/store/index.js +++ b/app/assets/javascripts/projects/commits/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue index 06c0230c8e0..c749034d2a8 100644 --- a/app/assets/javascripts/projects/components/shared/delete_button.vue +++ b/app/assets/javascripts/projects/components/shared/delete_button.vue @@ -1,19 +1,14 @@ <script> -import { GlModal, GlModalDirective, GlFormInput, GlButton, GlAlert, GlSprintf } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { GlButton, GlForm } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; +import DeleteModal from './delete_modal.vue'; export default { components: { - GlAlert, - GlModal, - GlFormInput, GlButton, - GlSprintf, - }, - directives: { - GlModal: GlModalDirective, + GlForm, + DeleteModal, }, props: { confirmPhrase: { @@ -47,139 +42,54 @@ export default { }, data() { return { - userInput: null, - modalId: uniqueId('delete-project-modal-'), + isModalVisible: false, }; }, computed: { - confirmDisabled() { - return this.userInput !== this.confirmPhrase; - }, csrfToken() { return csrf.token; }, - modalActionProps() { - return { - primary: { - text: __('Yes, delete project'), - attributes: { - variant: 'danger', - disabled: this.confirmDisabled, - 'data-qa-selector': 'confirm_delete_button', - }, - }, - cancel: { - text: __('Cancel, keep project'), - }, - }; - }, }, methods: { submitForm() { - this.$refs.form.submit(); + this.$refs.form.$el.submit(); + }, + onButtonClick() { + this.isModalVisible = true; }, }, - strings: { + i18n: { deleteProject: __('Delete project'), - title: __('Are you absolutely sure?'), - confirmText: __('Enter the following to confirm:'), - isForkAlertTitle: __('You are about to delete this forked project containing:'), - isNotForkAlertTitle: __('You are about to delete this project containing:'), - isForkAlertBody: __('This process deletes the project repository and all related resources.'), - isNotForkAlertBody: __( - 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.', - ), - isNotForkMessage: __( - 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', - ), }, }; </script> <template> - <form ref="form" :action="formPath" method="post"> + <gl-form ref="form" :action="formPath" method="post"> <input type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <delete-modal + v-model="isModalVisible" + :confirm-phrase="confirmPhrase" + :is-fork="isFork" + :issues-count="issuesCount" + :merge-requests-count="mergeRequestsCount" + :forks-count="forksCount" + :stars-count="starsCount" + @primary="submitForm" + > + <template #modal-footer> + <slot name="modal-footer"></slot> + </template> + </delete-modal> + <gl-button - v-gl-modal="modalId" category="primary" variant="danger" data-qa-selector="delete_button" - >{{ $options.strings.deleteProject }}</gl-button + @click="onButtonClick" + >{{ $options.i18n.deleteProject }}</gl-button > - - <gl-modal - ref="removeModal" - :modal-id="modalId" - ok-variant="danger" - footer-class="gl-bg-gray-10 gl-p-5" - title-class="gl-text-red-500" - :action-primary="modalActionProps.primary" - :action-cancel="modalActionProps.cancel" - @ok="submitForm" - > - <template #modal-title>{{ $options.strings.title }}</template> - <div> - <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> - <h4 v-if="isFork" data-testid="delete-alert-title" class="gl-alert-title"> - {{ $options.strings.isForkAlertTitle }} - </h4> - <h4 v-else data-testid="delete-alert-title" class="gl-alert-title"> - {{ $options.strings.isNotForkAlertTitle }} - </h4> - <ul> - <li> - <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> - <template #issuesCount>{{ issuesCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf - :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" - > - <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> - <template #forksCount>{{ forksCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> - <template #starsCount>{{ starsCount }}</template> - </gl-sprintf> - </li> - </ul> - <gl-sprintf - v-if="isFork" - data-testid="delete-alert-body" - :message="$options.strings.isForkAlertBody" - /> - <gl-sprintf - v-else - data-testid="delete-alert-body" - :message="$options.strings.isNotForkAlertBody" - > - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </gl-alert> - <p class="gl-mb-1">{{ $options.strings.confirmText }}</p> - <p> - <code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code> - </p> - <gl-form-input - id="confirm_name_input" - v-model="userInput" - name="confirm_name_input" - type="text" - data-qa-selector="confirm_name_field" - /> - <slot name="modal-footer"></slot> - </div> - </gl-modal> - </form> + </gl-form> </template> diff --git a/app/assets/javascripts/projects/components/shared/delete_modal.vue b/app/assets/javascripts/projects/components/shared/delete_modal.vue new file mode 100644 index 00000000000..44e29d00d45 --- /dev/null +++ b/app/assets/javascripts/projects/components/shared/delete_modal.vue @@ -0,0 +1,155 @@ +<script> +import { GlModal, GlAlert, GlSprintf, GlFormInput } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; +import { __ } from '~/locale'; + +export default { + i18n: { + deleteProject: __('Delete project'), + title: __('Are you absolutely sure?'), + confirmText: __('Enter the following to confirm:'), + isForkAlertTitle: __('You are about to delete this forked project containing:'), + isNotForkAlertTitle: __('You are about to delete this project containing:'), + isForkAlertBody: __('This process deletes the project repository and all related resources.'), + isNotForkAlertBody: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.', + ), + isNotForkMessage: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', + ), + }, + components: { GlModal, GlAlert, GlSprintf, GlFormInput }, + model: { + prop: 'visible', + event: 'change', + }, + props: { + visible: { + type: Boolean, + required: true, + }, + confirmPhrase: { + type: String, + required: true, + }, + isFork: { + type: Boolean, + required: true, + }, + issuesCount: { + type: [Number, String], + required: false, + default: null, + }, + mergeRequestsCount: { + type: [Number, String], + required: false, + default: null, + }, + forksCount: { + type: [Number, String], + required: false, + default: null, + }, + starsCount: { + type: [Number, String], + required: false, + default: null, + }, + }, + data() { + return { + userInput: null, + modalId: uniqueId('delete-project-modal-'), + }; + }, + computed: { + confirmDisabled() { + return this.userInput !== this.confirmPhrase; + }, + modalActionProps() { + return { + primary: { + text: __('Yes, delete project'), + attributes: { + variant: 'danger', + disabled: this.confirmDisabled, + 'data-qa-selector': 'confirm_delete_button', + }, + }, + cancel: { + text: __('Cancel, keep project'), + }, + }; + }, + }, +}; +</script> + +<template> + <gl-modal + :visible="visible" + :modal-id="modalId" + footer-class="gl-bg-gray-10 gl-p-5" + title-class="gl-text-red-500" + :action-primary="modalActionProps.primary" + :action-cancel="modalActionProps.cancel" + @primary="$emit('primary', $event)" + @change="$emit('change', $event)" + > + <template #modal-title>{{ $options.i18n.title }}</template> + <div> + <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> + <h4 v-if="isFork" class="gl-alert-title"> + {{ $options.i18n.isForkAlertTitle }} + </h4> + <h4 v-else class="gl-alert-title"> + {{ $options.i18n.isNotForkAlertTitle }} + </h4> + <ul> + <li v-if="issuesCount !== null"> + <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> + <template #issuesCount>{{ issuesCount }}</template> + </gl-sprintf> + </li> + <li v-if="mergeRequestsCount !== null"> + <gl-sprintf + :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" + > + <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> + </gl-sprintf> + </li> + <li v-if="forksCount !== null"> + <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> + <template #forksCount>{{ forksCount }}</template> + </gl-sprintf> + </li> + <li v-if="starsCount !== null"> + <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> + <template #starsCount>{{ starsCount }}</template> + </gl-sprintf> + </li> + </ul> + <gl-sprintf v-if="isFork" :message="$options.i18n.isForkAlertBody" /> + <gl-sprintf v-else :message="$options.i18n.isNotForkAlertBody"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-alert> + <p class="gl-mb-1">{{ $options.i18n.confirmText }}</p> + <p> + <code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code> + </p> + + <gl-form-input + id="confirm_name_input" + v-model="userInput" + name="confirm_name_input" + type="text" + data-qa-selector="confirm_name_field" + /> + <slot name="modal-footer"></slot> + </div> + </gl-modal> +</template> diff --git a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js index 2bd3e57322d..59210b31d32 100644 --- a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js +++ b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import UserList from '~/user_lists/components/user_list.vue'; import createStore from '~/user_lists/store/show'; diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index d6d88b5b297..ef2a2aa5526 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -157,7 +157,7 @@ export default { <gl-dropdown class="js-group-namespace-dropdown gl-flex-grow-1" :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`" - data-qa-selector="select_namespace_dropdown" + data-testid="select-namespace-dropdown" @show="trackDropdownShow" @shown="handleDropdownShown" > @@ -173,7 +173,7 @@ export default { ref="search" v-model.trim="search" :is-loading="$apollo.queries.currentUser.loading" - data-qa-selector="select_namespace_dropdown_search_field" + data-testid="select-namespace-dropdown-search-field" /> <template v-if="!$apollo.queries.currentUser.loading"> <template v-if="hasGroupMatches"> diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 0cfea401be6..35c8046bfe7 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -2,6 +2,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import ProjectPipelinesCharts from './components/app.vue'; Vue.use(VueApollo); @@ -12,6 +14,7 @@ const apolloProvider = new VueApollo({ const mountPipelineChartsApp = (el) => { const { + projectId, projectPath, failedPipelinesLink, coverageChartPath, @@ -22,6 +25,7 @@ const mountPipelineChartsApp = (el) => { const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary); + const contextId = convertToGraphQLId(TYPENAME_PROJECT, projectId); return new Vue({ el, @@ -39,6 +43,7 @@ const mountPipelineChartsApp = (el) => { defaultBranch, testRunsEmptyStateImagePath, projectQualitySummaryFeedbackImagePath, + contextId, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js index 4f62aa29ce4..90f9290ffb8 100644 --- a/app/assets/javascripts/projects/project_name_rules.js +++ b/app/assets/javascripts/projects/project_name_rules.js @@ -8,7 +8,7 @@ export const START_RULE = { export const CONTAINS_RULE = { reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, msg: __( - 'Name can contain only lowercase or uppercase letters, digits, emojis, spaces, dots, underscores, dashes, or pluses.', + 'Name can contain only lowercase or uppercase letters, digits, emoji, spaces, dots, underscores, dashes, or pluses.', ), }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 33320f59b0f..2b5e2dcb301 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -76,7 +76,7 @@ const namespaceError = () => document.querySelector('.js-group-namespace-error') const validateGroupNamespaceDropdown = (e) => { if (selectedNamespaceId() && !selectedNamespaceId().attributes.value) { - document.querySelector('input[data-qa-selector="project_name"]').reportValidity(); + document.querySelector('#project_name').reportValidity(); e.preventDefault(); dropdownButton().classList.add(invalidDropdownClass); namespaceButton().classList.add(invalidDropdownClass); diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue index b8e7e9e15db..a02a33992b5 100644 --- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue +++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlToggle } from '@gitlab/ui'; +import { GlAlert, GlLink, GlToggle, GlSprintf } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; import { CC_VALIDATION_REQUIRED_ERROR } from '../constants'; @@ -15,7 +15,9 @@ export default { }, components: { GlAlert, + GlLink, GlToggle, + GlSprintf, CcValidationRequiredAlert: () => import('ee_component/billings/components/cc_validation_required_alert.vue'), }, @@ -36,6 +38,16 @@ export default { type: String, required: true, }, + groupName: { + type: String, + required: false, + default: null, + }, + groupSettingsPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -57,6 +69,9 @@ export default { !this.ccAlertDismissed ); }, + isGroupSettingsAvailable() { + return this.groupSettingsPath && this.groupName; + }, }, methods: { creditCardValidated() { @@ -103,16 +118,6 @@ export default { {{ errorMessage }} </gl-alert> - <gl-alert - v-if="isDisabledAndUnoverridable" - data-testid="unoverridable-alert" - variant="warning" - :dismissible="false" - class="gl-mb-5" - > - {{ s__('Runners|Shared runners are disabled in the group settings') }} - </gl-alert> - <gl-toggle ref="sharedRunnersToggle" :disabled="isDisabledAndUnoverridable" @@ -121,7 +126,19 @@ export default { :value="isSharedRunnerEnabled" data-testid="toggle-shared-runners" @change="toggleSharedRunners" - /> + > + <template v-if="isDisabledAndUnoverridable" #help> + {{ s__('Runners|Shared runners are disabled in the group settings.') }} + <gl-sprintf + v-if="isGroupSettingsAvailable" + :message="s__('Runners|Go to %{groupLink} to enable them.')" + > + <template #groupLink> + <gl-link :href="groupSettingsPath">{{ groupName }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> </section> </div> </template> diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js index 54120b3525d..ace5fd5c6e4 100644 --- a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js +++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js @@ -9,10 +9,15 @@ export default (containerId = 'toggle-shared-runners-form') => { } const { + // required isDisabledAndUnoverridable, isEnabled, updatePath, isCreditCardValidationRequired, + + // optional + groupName, + groupSettingsPath, } = containerEl.dataset; return new Vue({ @@ -24,6 +29,9 @@ export default (containerId = 'toggle-shared-runners-form') => { isEnabled: parseBoolean(isEnabled), isCreditCardValidationRequired: parseBoolean(isCreditCardValidationRequired), updatePath, + + groupName, + groupSettingsPath, }, }); }, diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index dcf5155644d..7753b850744 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective, GlCard, GlIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; import { expandSection } from '~/settings_panels'; @@ -14,6 +14,8 @@ export default { BranchRule, GlButton, GlModal, + GlCard, + GlIcon, }, directives: { GlModal: GlModalDirective, @@ -55,29 +57,47 @@ export default { </script> <template> - <div class="settings-content gl-mb-0"> - <branch-rule - v-for="(rule, index) in branchRules" - :key="`${rule.name}-${index}`" - :name="rule.name" - :is-default="rule.isDefault" - :branch-protection="rule.branchProtection" - :status-checks-total="rule.externalStatusChecks ? rule.externalStatusChecks.nodes.length : 0" - :approval-rules-total="rule.approvalRules ? rule.approvalRules.nodes.length : 0" - :matching-branches-count="rule.matchingBranchesCount" - /> - - <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div> - - <gl-button - v-gl-modal="$options.modalId" - class="gl-mt-5" - data-qa-selector="add_branch_rule_button" - category="secondary" - variant="info" - >{{ $options.i18n.addBranchRule }}</gl-button - > - + <gl-card + class="gl-new-card gl-overflow-hidden" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + > + <template #header> + <div class="gl-new-card-title-wrapper" data-testid="title"> + <h3 class="gl-new-card-title"> + {{ __('Branch Rules') }} + </h3> + <div class="gl-new-card-count"> + <gl-icon name="branch" class="gl-mr-2" /> + {{ branchRules.length }} + </div> + </div> + <gl-button + v-gl-modal="$options.modalId" + size="small" + class="gl-ml-3" + data-qa-selector="add_branch_rule_button" + >{{ $options.i18n.addBranchRule }}</gl-button + > + </template> + <ul class="content-list"> + <branch-rule + v-for="(rule, index) in branchRules" + :key="`${rule.name}-${index}`" + :name="rule.name" + :is-default="rule.isDefault" + :branch-protection="rule.branchProtection" + :status-checks-total=" + rule.externalStatusChecks ? rule.externalStatusChecks.nodes.length : 0 + " + :approval-rules-total="rule.approvalRules ? rule.approvalRules.nodes.length : 0" + :matching-branches-count="rule.matchingBranchesCount" + class="gl-px-5! gl-py-4!" + /> + <div v-if="!branchRules.length" class="gl-new-card-empty gl-px-5 gl-py-4" data-testid="empty"> + {{ $options.i18n.emptyState }} + </div> + </ul> <gl-modal :ref="$options.modalId" :modal-id="$options.modalId" @@ -88,5 +108,5 @@ export default { <p>{{ $options.i18n.branchRuleModalDescription }}</p> <p>{{ $options.i18n.branchRuleModalContent }}</p> </gl-modal> - </div> + </gl-card> </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index a5ff478a826..f45a5b12db6 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -6,7 +6,7 @@ import { getAccessLevels } from '../../../utils'; export const i18n = { defaultLabel: s__('BranchRules|default'), protectedLabel: s__('BranchRules|protected'), - detailsButtonLabel: s__('BranchRules|Details'), + detailsButtonLabel: s__('BranchRules|View details'), allowForcePush: s__('BranchRules|Allowed to force push'), codeOwnerApprovalRequired: s__('BranchRules|Requires CODEOWNERS approval'), statusChecks: s__('BranchRules|%{total} status %{subject}'), @@ -153,28 +153,36 @@ export default { </script> <template> - <div - class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between" - data-qa-selector="branch_content" - :data-qa-branch-name="name" - > - <div> - <strong class="gl-font-monospace">{{ name }}</strong> + <li> + <div + class="gl-display-flex gl-justify-content-space-between" + data-qa-selector="branch_content" + :data-qa-branch-name="name" + > + <div> + <strong class="gl-font-monospace">{{ name }}</strong> - <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ - $options.i18n.defaultLabel - }}</gl-badge> + <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ + $options.i18n.defaultLabel + }}</gl-badge> - <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ - $options.i18n.protectedLabel - }}</gl-badge> + <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ + $options.i18n.protectedLabel + }}</gl-badge> - <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> - <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> - </ul> + <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> + <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> + </ul> + </div> + <gl-button + class="gl-align-self-start" + category="tertiary" + size="small" + data-qa-selector="details_button" + :href="detailsPath" + > + {{ $options.i18n.detailsButtonLabel }}</gl-button + > </div> - <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath"> - {{ $options.i18n.detailsButtonLabel }}</gl-button - > - </div> + </li> </template> diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue index 47477d39b8a..2f980e20c1e 100644 --- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue +++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue @@ -60,7 +60,7 @@ export default { return this.selectedTokens.length ? '' : this.$options.i18n.placeholder; }, topicsHelpUrl() { - return helpPagePath('user/admin_area/index.html', { + return helpPagePath('administration/index', { anchor: 'administering-topics', }); }, diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue new file mode 100644 index 00000000000..f7a9949db4b --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue @@ -0,0 +1,139 @@ +<script> +import { GlBadge, GlButton, GlSprintf, GlToggle } from '@gitlab/ui'; +import { + I18N_STATE_INTRO_PARAGRAPH, + I18N_STATE_VERIFICATION_FINISHED_INTRO_PARAGRAPH, + I18N_STATE_VERIFICATION_STARTED, + I18N_RESET_BUTTON_LABEL, + I18N_STATE_VERIFICATION_FAILED, + I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL, + I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP, + I18N_STATE_RESET_PARAGRAPH, + I18N_VERIFICATION_ERRORS, +} from '../custom_email_constants'; + +export default { + components: { + GlBadge, + GlButton, + GlSprintf, + GlToggle, + }, + I18N_STATE_VERIFICATION_STARTED, + I18N_STATE_VERIFICATION_FAILED, + I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL, + I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP, + I18N_RESET_BUTTON_LABEL, + props: { + customEmail: { + type: String, + required: true, + }, + smtpAddress: { + type: String, + required: true, + }, + verificationState: { + type: String, + required: true, + }, + verificationError: { + type: String, + required: false, + default: '', + }, + isEnabled: { + type: Boolean, + required: false, + default: false, + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isVerificationFailed() { + return this.verificationState === 'failed'; + }, + isVerificationFinished() { + return this.verificationState === 'finished'; + }, + containerClass() { + return this.isVerificationFinished ? '' : 'gl-text-center'; + }, + introNote() { + return this.isVerificationFinished + ? I18N_STATE_VERIFICATION_FINISHED_INTRO_PARAGRAPH + : I18N_STATE_INTRO_PARAGRAPH; + }, + badgeVariant() { + return this.isVerificationFailed ? 'danger' : 'info'; + }, + badgeContent() { + return this.isVerificationFailed + ? I18N_STATE_VERIFICATION_FAILED + : I18N_STATE_VERIFICATION_STARTED; + }, + verificationErrorI18nObject() { + return I18N_VERIFICATION_ERRORS[this.verificationError]; + }, + errorLabel() { + return this.verificationErrorI18nObject?.label; + }, + errorDescription() { + return this.verificationErrorI18nObject?.description; + }, + resetNote() { + return I18N_STATE_RESET_PARAGRAPH[this.verificationState]; + }, + }, +}; +</script> + +<template> + <div :class="containerClass"> + <p> + <gl-sprintf :message="introNote"> + <template #customEmail> + <strong>{{ customEmail }}</strong> + </template> + <template #smtpAddress> + <strong>{{ smtpAddress }}</strong> + </template> + <template #badge="{ content }"> + <gl-badge variant="success">{{ content }}</gl-badge> + </template> + </gl-sprintf> + </p> + + <div v-if="!isVerificationFinished" class="gl-mb-5"> + <gl-badge :variant="badgeVariant">{{ badgeContent }}</gl-badge> + </div> + + <template v-if="isVerificationFinished"> + <gl-toggle + :value="isEnabled" + :is-loading="isSubmitting" + :label="$options.I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL" + :help="$options.I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP" + label-position="top" + @change="$emit('toggle', $event)" + /> + <hr /> + </template> + + <template v-if="verificationError"> + <p class="gl-mb-0"> + <strong>{{ errorLabel }}</strong> + </p> + <p>{{ errorDescription }}</p> + </template> + + <p>{{ resetNote }}</p> + <gl-button :loading="isSubmitting" @click="$emit('reset')"> + {{ $options.I18N_RESET_BUTTON_LABEL }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue new file mode 100644 index 00000000000..2fb1ea52e05 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_confirm_modal.vue @@ -0,0 +1,74 @@ +<script> +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { + I18N_MODAL_TITLE, + I18N_MODAL_CANCEL_BUTTON_LABEL, + I18N_RESET_BUTTON_LABEL, + I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH, + I18N_MODAL_SET_UP_AGAIN_PARAGRAPH, +} from '../custom_email_constants'; + +export default { + components: { + GlModal, + GlSprintf, + }, + I18N_MODAL_TITLE, + I18N_RESET_BUTTON_LABEL, + I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH, + I18N_MODAL_SET_UP_AGAIN_PARAGRAPH, + props: { + visible: { + type: Boolean, + required: true, + }, + customEmail: { + type: String, + required: false, + default: '', + }, + }, + computed: { + primaryButtonAttributes() { + return { + text: I18N_RESET_BUTTON_LABEL, + attributes: { + variant: 'danger', + }, + }; + }, + cancelButtonAttributes() { + return { + text: I18N_MODAL_CANCEL_BUTTON_LABEL, + }; + }, + }, +}; +</script> + +<template> + <gl-modal + modal-id="custom-email-confirm-modal" + :title="$options.I18N_MODAL_TITLE" + :action-primary="primaryButtonAttributes" + :action-cancel="cancelButtonAttributes" + :visible="visible" + @primary="$emit('remove')" + @canceled="$emit('cancel')" + @hidden="$emit('cancel')" + > + <p> + <gl-sprintf :message="$options.I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #customEmail> + <code>{{ customEmail }}</code> + </template> + </gl-sprintf> + </p> + <p> + {{ $options.I18N_MODAL_SET_UP_AGAIN_PARAGRAPH }} + </p> + </gl-modal> +</template> 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 new file mode 100644 index 00000000000..4affcd926d4 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue @@ -0,0 +1,291 @@ +<script> +import { GlButton, GlForm, GlFormGroup, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; +import { isEmptyValue, hasMinimumLength, isIntegerGreaterThan, isEmail } from '~/lib/utils/forms'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { + I18N_FORM_INTRODUCTION_PARAGRAPH, + I18N_FORM_CUSTOM_EMAIL_LABEL, + I18N_FORM_CUSTOM_EMAIL_DESCRIPTION, + I18N_FORM_FORWARDING_LABEL, + I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE, + I18N_FORM_SMTP_ADDRESS_LABEL, + I18N_FORM_SMTP_PORT_LABEL, + I18N_FORM_SMTP_PORT_DESCRIPTION, + I18N_FORM_SMTP_USERNAME_LABEL, + I18N_FORM_SMTP_PASSWORD_LABEL, + I18N_FORM_SMTP_PASSWORD_DESCRIPTION, + I18N_FORM_SUBMIT_LABEL, + I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL, + I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS, + I18N_FORM_INVALID_FEEDBACK_SMTP_PORT, + I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME, + I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD, +} from '../custom_email_constants'; + +export default { + components: { + ClipboardButton, + GlButton, + GlForm, + GlFormGroup, + GlFormInputGroup, + GlFormInput, + }, + I18N_FORM_INTRODUCTION_PARAGRAPH, + I18N_FORM_CUSTOM_EMAIL_LABEL, + I18N_FORM_CUSTOM_EMAIL_DESCRIPTION, + I18N_FORM_FORWARDING_LABEL, + I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE, + I18N_FORM_SMTP_ADDRESS_LABEL, + I18N_FORM_SMTP_PORT_LABEL, + I18N_FORM_SMTP_PORT_DESCRIPTION, + I18N_FORM_SMTP_USERNAME_LABEL, + I18N_FORM_SMTP_PASSWORD_LABEL, + I18N_FORM_SMTP_PASSWORD_DESCRIPTION, + I18N_FORM_SUBMIT_LABEL, + I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL, + I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS, + I18N_FORM_INVALID_FEEDBACK_SMTP_PORT, + I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME, + I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD, + props: { + incomingEmail: { + type: String, + required: false, + default: '', + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + customEmail: '', + forwardingConfigured: false, + smtpAddress: '', + smtpPort: '587', + smtpUsername: '', + smtpPassword: '', + validationState: { + customEmail: null, + smtpAddress: null, + smtpPort: true, + smtpUsername: null, + smtpPassword: null, + }, + }; + }, + computed: { + isFormValid() { + return Object.values(this.validationState).every(Boolean); + }, + }, + methods: { + onSubmit() { + this.triggerVerification(); + + if (!this.isFormValid) { + return; + } + + this.$emit('submit', this.getRequestFormData()); + }, + getRequestFormData() { + return { + custom_email: this.customEmail, + smtp_address: this.smtpAddress, + smtp_port: this.smtpPort, + smtp_username: this.smtpUsername, + smtp_password: this.smtpPassword, + }; + }, + onCustomEmailChange() { + this.validateCustomEmail(); + + if (this.validationState.customEmail && isEmptyValue(this.smtpUsername)) { + this.smtpUsername = this.customEmail; + this.validateSmtpUsername(); + } + }, + validateCustomEmail() { + this.validationState.customEmail = isEmail(this.customEmail); + }, + validateSmtpAddress() { + this.validationState.smtpAddress = !isEmptyValue(this.smtpAddress); + }, + validateSmtpPort() { + this.validationState.smtpPort = isIntegerGreaterThan(this.smtpPort, 0); + }, + validateSmtpUsername() { + this.validationState.smtpUsername = !isEmptyValue(this.smtpUsername); + }, + validateSmtpPassword() { + this.validationState.smtpPassword = hasMinimumLength(this.smtpPassword, 8); + }, + triggerVerification() { + this.validateCustomEmail(); + this.validateSmtpAddress(); + this.validateSmtpPort(); + this.validateSmtpUsername(); + this.validateSmtpPassword(); + }, + }, +}; +</script> + +<template> + <div> + <p>{{ $options.I18N_FORM_INTRODUCTION_PARAGRAPH }}</p> + <gl-form class="js-quick-submit" @submit.prevent="onSubmit"> + <gl-form-group + :label="$options.I18N_FORM_FORWARDING_LABEL" + label-for="custom-email-form-forwarding" + class="gl-mt-3" + > + <gl-form-input-group> + <gl-form-input + 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" + /> + <template #append> + <clipboard-button + :title="$options.I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE" + :text="incomingEmail" + css-class="input-group-text" + /> + </template> + </gl-form-input-group> + </gl-form-group> + + <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" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-custom-email" + v-model.trim="customEmail" + data-testid="form-custom-email" + :aria-label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL" + placeholder="contact@example.com" + type="email" + :state="validationState.customEmail" + :required="true" + :disabled="isSubmitting" + @change="onCustomEmailChange" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <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" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-smtp-address" + v-model.trim="smtpAddress" + data-testid="form-smtp-address" + :aria-label="$options.I18N_FORM_SMTP_ADDRESS_LABEL" + placeholder="smtp.example.com" + type="email" + :state="validationState.smtpAddress" + :required="true" + :disabled="isSubmitting" + @change="validateSmtpAddress" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_PORT_LABEL" + label-for="custom-email-form-smtp-port" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PORT" + class="gl-mt-3" + :description="$options.I18N_FORM_SMTP_PORT_DESCRIPTION" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-smtp-port" + v-model.trim="smtpPort" + data-testid="form-smtp-port" + :aria-label="$options.I18N_FORM_SMTP_PORT_LABEL" + placeholder="587" + type="number" + :state="validationState.smtpPort" + :required="true" + :disabled="isSubmitting" + @change="validateSmtpPort" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_USERNAME_LABEL" + label-for="custom-email-form-smtp-username" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME" + class="gl-mt-3" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-smtp-username" + v-model.trim="smtpUsername" + data-testid="form-smtp-username" + :aria-label="$options.I18N_FORM_SMTP_USERNAME_LABEL" + placeholder="contact@example.com" + :state="validationState.smtpUsername" + :required="true" + :disabled="isSubmitting" + @change="validateSmtpUsername" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_PASSWORD_LABEL" + label-for="custom-email-form-smtp-password" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD" + class="gl-mt-3" + :description="$options.I18N_FORM_SMTP_PASSWORD_DESCRIPTION" + > + <gl-form-input + id="custom-email-form-smtp-password" + v-model.trim="smtpPassword" + data-testid="form-smtp-password" + :aria-label="$options.I18N_FORM_SMTP_PASSWORD_LABEL" + type="password" + :state="validationState.smtpPassword" + :required="true" + :disabled="isSubmitting" + @change="validateSmtpPassword" + /> + </gl-form-group> + + <gl-button + type="submit" + variant="confirm" + class="gl-mt-5" + data-testid="form-submit" + :disabled="!isFormValid" + :loading="isSubmitting" + @click="onSubmit" + > + {{ $options.I18N_FORM_SUBMIT_LABEL }} + </gl-button> + </gl-form> + </div> +</template> 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 new file mode 100644 index 00000000000..7e040e6001a --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue @@ -0,0 +1,245 @@ +<script> +import { GlAlert, GlLoadingIcon, GlSprintf, GlLink, GlCard } from '@gitlab/ui'; +import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; +import axios from '~/lib/utils/axios_utils'; +import { + FEEDBACK_ISSUE_URL, + I18N_LOADING_LABEL, + I18N_CARD_TITLE, + I18N_GENERIC_ERROR, + I18N_FEEDBACK_PARAGRAPH, + I18N_TOAST_SAVED, + I18N_TOAST_DELETED, + I18N_TOAST_ENABLED, + I18N_TOAST_DISABLED, +} from '../custom_email_constants'; +import CustomEmailConfirmModal from './custom_email_confirm_modal.vue'; +import CustomEmailForm from './custom_email_form.vue'; +import CustomEmail from './custom_email.vue'; + +export default { + components: { + BetaBadge, + GlAlert, + GlLoadingIcon, + GlSprintf, + GlLink, + GlCard, + CustomEmailConfirmModal, + CustomEmailForm, + CustomEmail, + }, + FEEDBACK_ISSUE_URL, + I18N_LOADING_LABEL, + I18N_CARD_TITLE, + I18N_FEEDBACK_PARAGRAPH, + I18N_TOAST_SAVED, + I18N_TOAST_DELETED, + props: { + incomingEmail: { + type: String, + required: true, + }, + customEmailEndpoint: { + type: String, + required: true, + }, + }, + data() { + return { + isLoading: true, + isSubmitting: false, + confirmModalVisible: false, + customEmail: null, + isEnabled: false, + verificationState: null, + verificationError: null, + smtpAddress: null, + alertMessage: null, + }; + }, + computed: { + customEmailNotSetUp() { + return !this.isEnabled && this.verificationState === null && this.customEmail === null; + }, + toastToggleText() { + return this.isEnabled ? I18N_TOAST_ENABLED : I18N_TOAST_DISABLED; + }, + }, + mounted() { + this.getCustomEmailDetails(); + }, + methods: { + dismissAlert() { + this.alertMessage = null; + }, + getCustomEmailDetails() { + axios + .get(this.customEmailEndpoint) + .then(({ data }) => { + this.updateData(data); + }) + .catch(this.handleRequestError) + .finally(() => { + this.isLoading = false; + this.enqueueReFetchVerification(); + }); + }, + enqueueReFetchVerification() { + setTimeout(this.reFetchVerification, 8000); + }, + reFetchVerification() { + if (this.verificationState !== 'started') { + return; + } + this.getCustomEmailDetails(); + }, + handleRequestError() { + this.alertMessage = I18N_GENERIC_ERROR; + }, + updateData(data) { + this.customEmail = data.custom_email; + this.isEnabled = data.custom_email_enabled; + this.verificationState = data.custom_email_verification_state; + this.verificationError = data.custom_email_verification_error; + this.smtpAddress = data.custom_email_smtp_address; + }, + onSaveCustomEmail(requestData) { + this.alertMessage = null; + this.isSubmitting = true; + + axios + .post(this.customEmailEndpoint, requestData) + .then(({ data }) => { + this.updateData(data); + this.$toast.show(this.$options.I18N_TOAST_SAVED); + this.enqueueReFetchVerification(); + }) + .catch(this.handleRequestError) + .finally(() => { + this.isSubmitting = false; + }); + }, + onResetCustomEmail() { + this.confirmModalVisible = true; + }, + onConfirmModalCanceled() { + this.confirmModalVisible = false; + }, + onConfirmModalProceed() { + this.isSubmitting = true; + this.confirmModalVisible = false; + + this.deleteCustomEmail(); + }, + deleteCustomEmail() { + axios + .delete(this.customEmailEndpoint) + .then(({ data }) => { + this.updateData(data); + this.$toast.show(I18N_TOAST_DELETED); + }) + .catch(this.handleRequestError) + .finally(() => { + this.isSubmitting = false; + }); + }, + onToggleCustomEmail(isChecked) { + this.isEnabled = isChecked; + this.isSubmitting = true; + + const body = { + custom_email_enabled: this.isEnabled, + }; + + axios + .put(this.customEmailEndpoint, body) + .then(({ data }) => { + this.updateData(data); + this.$toast.show(this.toastToggleText); + }) + .catch(this.handleRequestError) + .finally(() => { + this.isSubmitting = false; + }); + }, + }, +}; +</script> + +<template> + <div class="row gl-mt-7"> + <div class="col-md-9"> + <gl-card> + <template #header> + <div class="gl-display-flex align-items-center justify-content-between"> + <h5 class="gl-my-0">{{ $options.I18N_CARD_TITLE }}</h5> + <beta-badge /> + </div> + </template> + + <template #default> + <div v-if="isLoading" class="gl-p-3 gl-text-center"> + <gl-loading-icon + :label="$options.I18N_LOADING_LABEL" + size="md" + color="dark" + variant="spinner" + /> + {{ $options.I18N_LOADING_LABEL }} + </div> + + <custom-email-confirm-modal + :visible="confirmModalVisible" + :custom-email="customEmail" + @remove="onConfirmModalProceed" + @cancel="onConfirmModalCanceled" + /> + + <gl-alert + v-if="alertMessage" + variant="warning" + class="gl-mt-n5 gl-mb-4 gl-mx-n5" + @dismiss="dismissAlert" + > + {{ alertMessage }} + </gl-alert> + + <!-- Use v-show to preserve form data after verification failure + without the need to maintain a state in this component. --> + <custom-email-form + v-show="customEmailNotSetUp && !isLoading" + :incoming-email="incomingEmail" + :is-submitting="isSubmitting" + @submit="onSaveCustomEmail" + /> + + <custom-email + v-if="customEmail" + :custom-email="customEmail" + :smtp-address="smtpAddress" + :verification-state="verificationState" + :verification-error="verificationError" + :is-enabled="isEnabled" + :is-submitting="isSubmitting" + @toggle="onToggleCustomEmail" + @reset="onResetCustomEmail" + /> + </template> + + <template #footer> + <gl-sprintf :message="$options.I18N_FEEDBACK_PARAGRAPH"> + <template #link="{ content }"> + <gl-link + :href="$options.FEEDBACK_ISSUE_URL" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-card> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index ae28694f5d2..2b2722ab329 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -4,8 +4,11 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ServiceDeskSetting from './service_desk_setting.vue'; +const CustomEmailWrapper = () => import('./custom_email_wrapper.vue'); + export default { serviceDeskEmailHelpPath: helpPagePath('/user/project/service_desk.html', { anchor: 'use-an-additional-service-desk-alias-email', @@ -15,10 +18,12 @@ export default { GlSprintf, GlLink, ServiceDeskSetting, + CustomEmailWrapper, }, directives: { SafeHtml, }, + mixins: [glFeatureFlagsMixin()], inject: { initialIsEnabled: { default: false, @@ -56,6 +61,9 @@ export default { publicProject: { default: false, }, + customEmailEndpoint: { + default: '', + }, }, data() { return { @@ -68,6 +76,11 @@ export default { updatedServiceDeskEmail: this.serviceDeskEmail, }; }, + computed: { + showCustomEmailWrapper() { + return this.glFeatures.serviceDeskCustomEmail && this.isEnabled && this.isIssueTrackerEnabled; + }, + }, methods: { onEnableToggled(isChecked) { this.isEnabled = isChecked; @@ -179,5 +192,10 @@ export default { @save="onSaveTemplate" @toggle="onEnableToggled" /> + <custom-email-wrapper + v-if="showCustomEmailWrapper" + :incoming-email="incomingEmail" + :custom-email-endpoint="customEmailEndpoint" + /> </div> </template> 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 new file mode 100644 index 00000000000..cdf2e53982e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js @@ -0,0 +1,146 @@ +import { s__, __ } from '~/locale'; + +export const FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/416637'; + +export const I18N_LOADING_LABEL = __('Loading'); +export const I18N_CARD_TITLE = s__('ServiceDesk|Configure a custom email address'); +export const I18N_FEEDBACK_PARAGRAPH = s__( + 'ServiceDesk|Please share your feedback on this feature in the %{linkStart}feedback issue%{linkEnd}', +); +export const I18N_GENERIC_ERROR = __('An error occurred. Please try again.'); + +export const I18N_TOAST_SAVED = s__( + 'ServiceDesk|Saved custom email address and started verification.', +); +export const I18N_TOAST_DELETED = s__('ServiceDesk|Reset custom email address.'); +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.', +); +export const I18N_FORM_FORWARDING_LABEL = s__( + 'ServiceDesk|Service Desk email address to forward emails to', +); +export const I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE = s__( + 'ServiceDesk|Copy Service Desk email address', +); +export const I18N_FORM_CUSTOM_EMAIL_LABEL = s__('ServiceDesk|Custom email address'); +export const I18N_FORM_CUSTOM_EMAIL_DESCRIPTION = s__( + 'ServiceDesk|Email address your customers can use to send support requests. It must support sub-addressing.', +); +export const I18N_FORM_SMTP_ADDRESS_LABEL = s__('ServiceDesk|SMTP host'); +export const I18N_FORM_SMTP_PORT_LABEL = s__('ServiceDesk|SMTP port'); +export const I18N_FORM_SMTP_PORT_DESCRIPTION = s__( + 'ServiceDesk|Common ports are 587 when using TLS, and 25 when not.', +); +export const I18N_FORM_SMTP_USERNAME_LABEL = s__('ServiceDesk|SMTP username'); +export const I18N_FORM_SMTP_PASSWORD_LABEL = s__('ServiceDesk|SMTP password'); +export const I18N_FORM_SMTP_PASSWORD_DESCRIPTION = s__('ServiceDesk|Minimum 8 characters long.'); +export const I18N_FORM_SUBMIT_LABEL = s__('ServiceDesk|Save and test connection'); + +export const I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL = s__( + 'ServiceDesk|Custom email is required and must be a valid email address.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS = s__( + 'ServiceDesk|SMTP address is required and must be resolvable.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_PORT = s__( + 'ServiceDesk|SMTP port is required and must be a port number larger than 0.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME = s__( + 'ServiceDesk|SMTP username is required.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD = s__( + 'ServiceDesk|SMTP password is required and must be at least 8 characters long.', +); + +export const I18N_MODAL_TITLE = s__( + 'ServiceDesk|Reset custom email address and delete credentials', +); +export const I18N_MODAL_CANCEL_BUTTON_LABEL = s__('ServiceDesk|Keep custom email'); +export const I18N_MODAL_DISABLE_CUSTOM_EMAIL_PARAGRAPH = s__( + 'ServiceDesk|You are about to %{strongStart}disable the custom email address%{strongEnd} %{customEmail} %{strongStart}and delete its credentials%{strongEnd}.', +); +export const I18N_MODAL_SET_UP_AGAIN_PARAGRAPH = s__( + "ServiceDesk|To use a custom email address for this Service Desk, you'll need to configure and verify an email address again.", +); + +export const I18N_STATE_INTRO_PARAGRAPH = s__( + 'ServiceDesk|Verify %{customEmail} with SMTP host %{smtpAddress}:', +); +export const I18N_STATE_VERIFICATION_STARTED = s__('ServiceDesk|Verification started'); +export const I18N_STATE_VERIFICATION_STARTED_RESET_PARAGRAPH = s__( + 'ServiceDesk|A verification email has been sent to a sub-address of your custom email address. This can take up to 30 minutes. The screen refreshes automatically.', +); +export const I18N_RESET_BUTTON_LABEL = s__('ServiceDesk|Reset custom email'); + +export const I18N_STATE_VERIFICATION_FINISHED_INTRO_PARAGRAPH = s__( + 'ServiceDesk|%{customEmail} with SMTP host %{smtpAddress} is %{badgeStart}verified%{badgeEnd}', +); +export const I18N_STATE_VERIFICATION_FINISHED_TOGGLE_LABEL = s__( + 'ServiceDesk|Enable custom email address', +); +export const I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP = s__( + 'ServiceDesk|When enabled, Service Desk emails will be sent using the provided credentials.', +); +export const I18N_STATE_VERIFICATION_FINISHED_RESET_PARAGRAPH = s__( + 'ServiceDesk|Or reset and connect a new custom email address to this Service Desk.', +); + +export const I18N_STATE_VERIFICATION_FAILED = s__('ServiceDesk|Verification failed'); +export const I18N_STATE_VERIFICATION_FAILED_RESET_PARAGRAPH = s__( + 'ServiceDesk|Please try again. Check email forwarding settings and credentials, and then restart verification.', +); + +export const I18N_STATE_RESET_PARAGRAPH = { + started: I18N_STATE_VERIFICATION_STARTED_RESET_PARAGRAPH, + failed: I18N_STATE_VERIFICATION_FAILED_RESET_PARAGRAPH, + finished: I18N_STATE_VERIFICATION_FINISHED_RESET_PARAGRAPH, +}; + +export const I18N_ERROR_SMTP_HOST_ISSUE_LABEL = s__('ServiceDesk|SMTP host issue'); +export const I18N_ERROR_SMTP_HOST_ISSUE_DESC = s__( + 'ServiceDesk|A connection to the specified host could not be made or an SSL issue occurred.', +); +export const I18N_ERROR_INVALID_CREDENTIALS_LABEL = s__('ServiceDesk|Invalid credentials'); +export const I18N_ERROR_INVALID_CREDENTIALS_DESC = s__( + 'ServiceDesk|The given credentials (username and password) were rejected by the SMTP server.', +); +export const I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_LABEL = s__( + 'ServiceDesk|Verification email not received within timeframe', +); +export const I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_DESC = s__( + "ServiceDesk|The verification email wasn't received in time. There is a 30 minutes timeframe for verification emails to appear in your instance's Service Desk. Make sure that you have set up email forwarding correctly.", +); +export const I18N_ERROR_INCORRECT_FROM_LABEL = s__('ServiceDesk|Incorrect From header'); +export const I18N_ERROR_INCORRECT_FROM_DESC = s__( + 'ServiceDesk|Check your forwarding settings and make sure the original email sender remains in the From header.', +); +export const I18N_ERROR_INCORRECT_TOKEN_LABEL = s__('ServiceDesk|Incorrect verification token'); +export const I18N_ERROR_INCORRECT_TOKEN_DESC = s__( + "ServiceDesk|The received email didn't contain the verification token that was sent to your email address.", +); + +export const I18N_VERIFICATION_ERRORS = { + smtp_host_issue: { + label: I18N_ERROR_SMTP_HOST_ISSUE_LABEL, + description: I18N_ERROR_SMTP_HOST_ISSUE_DESC, + }, + invalid_credentials: { + label: I18N_ERROR_INVALID_CREDENTIALS_LABEL, + description: I18N_ERROR_INVALID_CREDENTIALS_DESC, + }, + mail_not_received_within_timeframe: { + label: I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_LABEL, + description: I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_DESC, + }, + incorrect_from: { + label: I18N_ERROR_INCORRECT_FROM_LABEL, + description: I18N_ERROR_INCORRECT_FROM_DESC, + }, + incorrect_token: { + label: I18N_ERROR_INCORRECT_TOKEN_LABEL, + description: I18N_ERROR_INCORRECT_TOKEN_DESC, + }, +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index 0f4c747a7b6..c4d4f42576f 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -1,7 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import ServiceDeskRoot from './components/service_desk_root.vue'; +Vue.use(GlToast); + export default () => { const el = document.querySelector('.js-service-desk-setting-root'); @@ -22,6 +25,7 @@ export default () => { selectedFileTemplateProjectId, templates, publicProject, + customEmailEndpoint, } = el.dataset; return new Vue({ @@ -39,6 +43,7 @@ export default () => { selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null, templates: JSON.parse(templates), publicProject: parseBoolean(publicProject), + customEmailEndpoint, }, render: (createElement) => createElement(ServiceDeskRoot), }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js deleted file mode 100644 index 4094c300a50..00000000000 --- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js +++ /dev/null @@ -1,29 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { __ } from '~/locale'; - -export default class ProtectedTagAccessDropdown { - constructor(options) { - this.options = options; - this.initDropdown(); - } - - initDropdown() { - const { onSelect } = this.options; - initDeprecatedJQueryDropdown(this.options.$dropdown, { - data: this.options.data, - selectable: true, - inputId: this.options.$dropdown.data('inputId'), - fieldName: this.options.$dropdown.data('fieldName'), - toggleLabel(item, $el) { - if ($el.is('.is-active')) { - return item.text; - } - return __('Select'); - }, - clicked(options) { - options.e.preventDefault(); - onSelect(); - }, - }); - } -} diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 7f58b394547..e5f5800c99c 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce, isArray } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { sprintf } from '~/locale'; import { diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js index fb2196fa1d0..d97d7578b00 100644 --- a/app/assets/javascripts/ref/stores/index.js +++ b/app/assets/javascripts/ref/stores/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 516162b57b5..c68fbceb4f6 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -8,6 +8,7 @@ import { GlLink, GlSprintf, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index dc465851721..81986456ca4 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -9,6 +9,7 @@ import { GlFormInput, GlFormSelect, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants'; diff --git a/app/assets/javascripts/releases/components/confirm_delete_modal.vue b/app/assets/javascripts/releases/components/confirm_delete_modal.vue index aa948fbbaf6..d42cf267064 100644 --- a/app/assets/javascripts/releases/components/confirm_delete_modal.vue +++ b/app/assets/javascripts/releases/components/confirm_delete_modal.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { __, s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue index 44269bccec9..4fea93f5b81 100644 --- a/app/assets/javascripts/releases/components/tag_create.vue +++ b/app/assets/javascripts/releases/components/tag_create.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { uniqueId } from 'lodash'; import { __, s__ } from '~/locale'; diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue index b4fea9bee35..17bcdb22350 100644 --- a/app/assets/javascripts/releases/components/tag_field.vue +++ b/app/assets/javascripts/releases/components/tag_field.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import TagFieldExisting from './tag_field_existing.vue'; import TagFieldNew from './tag_field_new.vue'; diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue index 11945fbaf3d..ca899420c02 100644 --- a/app/assets/javascripts/releases/components/tag_field_existing.vue +++ b/app/assets/javascripts/releases/components/tag_field_existing.vue @@ -1,6 +1,7 @@ <script> import { GlFormGroup, GlFormInput } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import FormFieldContainer from './form_field_container.vue'; diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index ec058cc3603..fe996a2a734 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -1,5 +1,6 @@ <script> import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__ } from '~/locale'; @@ -95,7 +96,6 @@ export default { :state="!showTagNameValidationError" :invalid-feedback="tagFeedback" optional - data-testid="tag-name-field" > <gl-dropdown :id="id" diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue index 33b44c90e1f..791b5e0e2a0 100644 --- a/app/assets/javascripts/releases/components/tag_search.vue +++ b/app/assets/javascripts/releases/components/tag_search.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { debounce } from 'lodash'; import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants'; diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js index c3130a0b778..ae67d5eba35 100644 --- a/app/assets/javascripts/releases/mount_edit.js +++ b/app/assets/javascripts/releases/mount_edit.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index efd82edcdf0..ff8da047061 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { createRefModule } from '../ref/stores'; import ReleaseEditNewApp from './components/app_edit_new.vue'; diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js index b2e93d789d7..825bbd30852 100644 --- a/app/assets/javascripts/releases/stores/index.js +++ b/app/assets/javascripts/releases/stores/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; export default ({ modules, featureFlags }) => diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index e26036b5620..1e0de045d39 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -24,7 +24,7 @@ const addRequestedOffset = (offset) => { const removeLeadingSlash = (path) => path.replace(/^\//, ''); -const fetchData = (projectPath, path, ref, offset) => { +const fetchData = (projectPath, path, ref, offset, refType) => { if (fetchedBatches.includes(offset) || offset < 0) { return []; } @@ -41,12 +41,12 @@ const fetchData = (projectPath, path, ref, offset) => { ); return axios - .get(url, { params: { format: 'json', offset } }) + .get(url, { params: { format: 'json', offset, ref_type: refType } }) .then(({ data }) => normalizeData(data, path)) .catch(() => createAlert({ message: I18N_COMMIT_DATA_FETCH_ERROR })); }; -export const loadCommits = async (projectPath, path, ref, offset) => { +export const loadCommits = async (projectPath, path, ref, offset, refType) => { if (isRequested(offset)) { return []; } @@ -54,7 +54,7 @@ export const loadCommits = async (projectPath, path, ref, offset) => { // We fetch in batches of 25, so this ensures we don't refetch Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => addRequestedOffset(offset + i)); - const commits = await fetchData(projectPath, path, ref, offset); + const commits = await fetchData(projectPath, path, ref, offset, refType); return commits; }; diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index d79ccde61a8..99b861ca104 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -76,6 +76,11 @@ export default { type: Boolean, required: true, }, + isUsingLfs: { + type: Boolean, + required: false, + default: false, + }, }, computed: { replaceModalTitle() { @@ -148,6 +153,7 @@ export default { :can-push-code="canPushCode" :can-push-to-branch="canPushToBranch" :empty-repo="emptyRepo" + :is-using-lfs="isUsingLfs" /> </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 969036f84b7..6f9f0a81dfd 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -21,7 +21,13 @@ 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 } from '../constants'; +import { + DEFAULT_BLOB_INFO, + TEXT_FILE_TYPE, + LFS_STORAGE, + LEGACY_FILE_TYPES, + CODEOWNERS_FILE_NAME, +} from '../constants'; import BlobButtonGroup from './blob_button_group.vue'; import ForkSuggestion from './fork_suggestion.vue'; import { loadViewer } from './blob_viewers'; @@ -32,6 +38,7 @@ export default { BlobButtonGroup, BlobContent, GlLoadingIcon, + CodeownersValidation: () => import('ee_component/blob/components/codeowners_validation.vue'), GlButton, ForkSuggestion, WebIdeLink, @@ -76,12 +83,15 @@ export default { project: { query: blobInfoQuery, variables() { - return { + const queryVariables = { projectPath: this.projectPath, filePath: this.path, - ref: this.originalBranch || this.ref, - shouldFetchRawText: Boolean(this.glFeatures.highlightJs), + ref: this.currentRef, + refType: this.refType?.toUpperCase() || null, + shouldFetchRawText: true, }; + + return queryVariables; }, result({ data }) { const blob = data.project?.repository?.blobs?.nodes[0] || {}; @@ -130,6 +140,11 @@ export default { type: String, required: true, }, + refType: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -163,6 +178,12 @@ export default { 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; @@ -185,8 +206,7 @@ export default { ); }, shouldLoadLegacyViewer() { - const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs; - return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback; + return LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback; }, legacyViewerLoaded() { return ( @@ -213,7 +233,9 @@ export default { const { createMergeRequestIn, forkProject } = this.userPermissions; const { canModifyBlob } = this.blobInfo; - return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject; + return ( + this.isLoggedIn && !this.isUsingLfs && !canModifyBlob && createMergeRequestIn && forkProject + ); }, forkPath() { const forkPaths = { @@ -259,8 +281,12 @@ export default { const type = this.activeViewerType; this.isLoadingLegacyViewer = true; + + const newUrl = new URL(this.blobInfo.webPath, window.location.origin); + newUrl.searchParams.set('format', 'json'); + newUrl.searchParams.set('viewer', type); axios - .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`) + .get(newUrl.pathname + newUrl.search) .then(async ({ data: { html, binary } }) => { this.isRenderingLegacyTextViewer = true; @@ -343,7 +369,7 @@ export default { :active-viewer-type="viewer.type" :has-render-error="hasRenderError" :show-path="false" - :override-copy="glFeatures.highlightJs" + :override-copy="true" @viewer-changed="handleViewerChanged" @copy="onCopy" > @@ -382,6 +408,7 @@ export default { :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" :show-fork-suggestion="showForkSuggestion" + :is-using-lfs="isUsingLfs" @fork="setForkTarget('view')" /> </template> @@ -391,6 +418,12 @@ 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" @@ -416,12 +449,12 @@ export default { :code-navigation-path="blobInfo.codeNavigationPath" :blob-path="blobInfo.path" :path-prefix="blobInfo.projectBlobPathRoot" - :wrap-text-nodes="glFeatures.highlightJs" + :wrap-text-nodes="true" /> </div> <ai-genie v-if="explainCodeAvailable" - container-id="fileHolder" + container-selector=".file-content" :file-path="path" class="gl-ml-7" /> diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue index 014f1abc121..9a8bb8e4aa6 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue @@ -8,7 +8,7 @@ export default { }, data() { return { - url: this.blob.rawPath, + url: this.blob.externalStorageUrl || this.blob.rawPath, alt: this.blob.name, }; }, diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index 368f42e0064..d434700b29f 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -1,4 +1,6 @@ -const viewers = { +import { TEXT_FILE_TYPE, JSON_LANGUAGE } from '../../constants'; + +export const viewers = { csv: () => import('./csv_viewer.vue'), download: () => import('./download_viewer.vue'), image: () => import('./image_viewer.vue'), @@ -18,7 +20,7 @@ const viewers = { export const loadViewer = (type, isUsingLfs, hljsWorkerEnabled, language) => { let viewer = viewers[type]; - if (hljsWorkerEnabled && language === 'json') { + if (hljsWorkerEnabled && language === JSON_LANGUAGE && type === TEXT_FILE_TYPE) { // The New Source Viewer currently only supports JSON files. // More language support will be added in: https://gitlab.com/gitlab-org/gitlab/-/issues/415753 viewer = () => import('~/vue_shared/components/source_viewer/source_viewer_new.vue'); diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index d498be0b2bb..b347f97a5ae 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -1,7 +1,8 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; -import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; +import { joinPaths, escapeFileUrl, buildURLwithRefType } from '~/lib/utils/url_utility'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { __ } from '~/locale'; import getRefMixin from '../mixins/get_ref'; @@ -49,6 +50,11 @@ export default { required: false, default: '', }, + refType: { + type: String, + required: false, + default: null, + }, canCollaborate: { type: Boolean, required: false, @@ -141,14 +147,17 @@ export default { return acc.concat({ name, path, - to, + to: buildURLwithRefType({ path: to, refType: this.refType }), }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${this.escapedRef}/`, + to: buildURLwithRefType({ + path: joinPaths('/-/tree', this.escapedRef), + refType: this.refType, + }), }, ], ); diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index cbdf6ef9ccd..97171a3282b 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -1,8 +1,18 @@ <script> -import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle, GlForm } from '@gitlab/ui'; +import { + GlModal, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlToggle, + GlForm, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import validation from '~/vue_shared/directives/validation'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { SECONDARY_OPTIONS_TEXT, COMMIT_LABEL, @@ -28,8 +38,19 @@ export default { GlFormTextarea, GlToggle, GlForm, + GlSprintf, + GlLink, }, i18n: { + LFS_WARNING_TITLE: __("The file you're about to delete is tracked by LFS"), + LFS_WARNING_PRIMARY_CONTENT: s__( + 'BlobViewer|If you delete the file, it will be removed from the branch %{branch}.', + ), + LFS_WARNING_SECONDARY_CONTENT: s__( + 'BlobViewer|This file will still take up space in your LFS storage. %{linkStart}How do I remove tracked objects from Git LFS?%{linkEnd}', + ), + LFS_CONTINUE_TEXT: __('Continue…'), + LFS_CANCEL_TEXT: __('Cancel'), PRIMARY_OPTIONS_TEXT: __('Delete file'), SECONDARY_OPTIONS_TEXT, COMMIT_LABEL, @@ -79,6 +100,11 @@ export default { type: Boolean, required: true, }, + isUsingLfs: { + type: Boolean, + required: false, + default: false, + }, }, data() { const form = { @@ -91,6 +117,7 @@ export default { }, }; return { + lfsWarningDismissed: false, loading: false, createNewMr: true, error: '', @@ -99,7 +126,7 @@ export default { }, computed: { primaryOptions() { - return { + const defaultOptions = { text: this.$options.i18n.PRIMARY_OPTIONS_TEXT, attributes: { variant: 'danger', @@ -107,6 +134,13 @@ export default { disabled: this.loading || !this.form.state, }, }; + + const lfsWarningOptions = { + text: this.$options.i18n.LFS_CONTINUE_TEXT, + attributes: { variant: 'confirm' }, + }; + + return this.showLfsWarning ? lfsWarningOptions : defaultOptions; }, cancelOptions() { return { @@ -139,14 +173,39 @@ export default { (hasFirstLineExceedMaxLength || hasOtherLineExceedMaxLength) ); }, - /* eslint-enable dot-notation */ + showLfsWarning() { + return this.isUsingLfs && !this.lfsWarningDismissed; + }, + title() { + return this.showLfsWarning ? this.$options.i18n.LFS_WARNING_TITLE : this.modalTitle; + }, + showDeleteForm() { + return !this.isUsingLfs || (this.isUsingLfs && this.lfsWarningDismissed); + }, }, methods: { show() { this.$refs[this.modalId].show(); + this.lfsWarningDismissed = false; + }, + cancel() { + this.$refs[this.modalId].hide(); }, - submitForm(e) { + async handleContinueLfsWarning() { + this.lfsWarningDismissed = true; + await this.$nextTick(); + this.$refs.message?.$el.focus(); + }, + async handlePrimaryAction(e) { e.preventDefault(); // Prevent modal from closing + + if (this.showLfsWarning) { + this.lfsWarningDismissed = true; + await this.$nextTick(); + this.$refs.message?.$el.focus(); + return; + } + this.form.showValidation = true; if (!this.form.state) { @@ -158,6 +217,7 @@ export default { this.$refs.form.$el.submit(); }, }, + deleteLfsHelpPath: helpPagePath('topics/git/lfs/index', { anchor: 'removing-objects-from-lfs' }), }; </script> @@ -165,67 +225,86 @@ export default { <gl-modal :ref="modalId" v-bind="$attrs" - data-testid="modal-delete" :modal-id="modalId" - :title="modalTitle" + :title="title" :action-primary="primaryOptions" :action-cancel="cancelOptions" - @primary="submitForm" + @primary="handlePrimaryAction" > - <gl-form ref="form" novalidate :action="deletePath" method="post"> - <input type="hidden" name="_method" value="delete" /> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <template v-if="emptyRepo"> - <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" /> - </template> - <template v-else> - <input type="hidden" name="original_branch" :value="originalBranch" /> - <input - v-if="createNewMr || !canPushToBranch" - type="hidden" - name="create_merge_request" - value="1" - /> - <gl-form-group - :label="$options.i18n.COMMIT_LABEL" - label-for="commit_message" - :invalid-feedback="form.fields['commit_message'].feedback" - > - <gl-form-textarea - v-model="form.fields['commit_message'].value" - v-validation:[form.showValidation] - name="commit_message" - data-qa-selector="commit_message_field" - :state="form.fields['commit_message'].state" - :disabled="loading" - required + <div v-if="showLfsWarning"> + <p> + <gl-sprintf :message="$options.i18n.LFS_WARNING_PRIMARY_CONTENT"> + <template #branch> + <code>{{ targetBranch }}</code> + </template> + </gl-sprintf> + </p> + + <p> + <gl-sprintf :message="$options.i18n.LFS_WARNING_SECONDARY_CONTENT"> + <template #link="{ content }"> + <gl-link :href="$options.deleteLfsHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + <div v-if="showDeleteForm"> + <gl-form ref="form" novalidate :action="deletePath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <template v-if="emptyRepo"> + <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" /> + </template> + <template v-else> + <input type="hidden" name="original_branch" :value="originalBranch" /> + <input + v-if="createNewMr || !canPushToBranch" + type="hidden" + name="create_merge_request" + value="1" /> - <p v-if="showHint" class="form-text gl-text-gray-600" data-testid="hint"> - {{ $options.i18n.COMMIT_MESSAGE_HINT }} - </p> - </gl-form-group> - <gl-form-group - v-if="canPushCode" - :label="$options.i18n.TARGET_BRANCH_LABEL" - label-for="branch_name" - :invalid-feedback="form.fields['branch_name'].feedback" - > - <gl-form-input - v-model="form.fields['branch_name'].value" - v-validation:[form.showValidation] - :state="form.fields['branch_name'].state" + <gl-form-group + :label="$options.i18n.COMMIT_LABEL" + label-for="commit_message" + :invalid-feedback="form.fields['commit_message'].feedback" + > + <gl-form-textarea + ref="message" + v-model="form.fields['commit_message'].value" + v-validation:[form.showValidation] + name="commit_message" + data-qa-selector="commit_message_field" + :state="form.fields['commit_message'].state" + :disabled="loading" + required + /> + <p v-if="showHint" class="form-text gl-text-gray-600" data-testid="hint"> + {{ $options.i18n.COMMIT_MESSAGE_HINT }} + </p> + </gl-form-group> + <gl-form-group + v-if="canPushCode" + :label="$options.i18n.TARGET_BRANCH_LABEL" + label-for="branch_name" + :invalid-feedback="form.fields['branch_name'].feedback" + > + <gl-form-input + v-model="form.fields['branch_name'].value" + v-validation:[form.showValidation] + :state="form.fields['branch_name'].state" + :disabled="loading" + name="branch_name" + required + /> + </gl-form-group> + <gl-toggle + v-if="showCreateNewMrToggle" + v-model="createNewMr" :disabled="loading" - name="branch_name" - required + :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" /> - </gl-form-group> - <gl-toggle - v-if="showCreateNewMrToggle" - v-model="createNewMr" - :disabled="loading" - :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" - /> - </template> - </gl-form> + </template> + </gl-form> + </div> </gl-modal> </template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index bdc9ed210ed..fa51ef30546 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -43,6 +43,7 @@ export default { return { projectPath: this.projectPath, ref: this.ref, + refType: this.refType?.toUpperCase(), path: this.currentPath.replace(/^\//, ''), }; }, @@ -69,6 +70,11 @@ export default { required: false, default: '', }, + refType: { + type: String, + required: false, + default: null, + }, }, data() { return { diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 90949536cc1..bdcacd80b30 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/repository/components/table/header.vue b/app/assets/javascripts/repository/components/table/header.vue index 9d30aa88155..f99cfea2e6e 100644 --- a/app/assets/javascripts/repository/components/table/header.vue +++ b/app/assets/javascripts/repository/components/table/header.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <template> <thead> <tr> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 46d546c2ee4..557e9cd168f 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index 8a081944600..0bc22253bd2 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { joinPaths, buildURLwithRefType } from '~/lib/utils/url_utility'; export default { components: { @@ -8,6 +9,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['refType'], props: { commitRef: { type: String, @@ -31,7 +33,9 @@ export default { return splitArray.map((p) => encodeURIComponent(p)).join('/'); }, parentRoute() { - return { path: `/-/tree/${this.commitRef}/${this.parentPath}` }; + const path = joinPaths('/-/tree', this.commitRef, this.parentPath); + + return buildURLwithRefType({ path, refType: this.refType }); }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 6dd059a349f..c839d7a53cd 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlBadge, @@ -12,11 +13,10 @@ import { import { escapeRegExp } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; -import { escapeFileUrl } from '~/lib/utils/url_utility'; +import { buildURLwithRefType, joinPaths } from '~/lib/utils/url_utility'; import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import getRefMixin from '../../mixins/get_ref'; @@ -36,7 +36,8 @@ export default { GlHoverLoad: GlHoverLoadDirective, SafeHtml, }, - mixins: [getRefMixin, glFeatureFlagMixin()], + mixins: [getRefMixin], + inject: ['refType'], props: { commitInfo: { type: Object, @@ -117,14 +118,18 @@ export default { return this.commitInfo; }, routerLinkTo() { - const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` }; - const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` }; - if (this.isBlob) { - return blobRouteConfig; + return buildURLwithRefType({ + path: joinPaths('/-/blob', this.escapedRef, this.path), + refType: this.refType, + }); + } else if (this.isFolder) { + return buildURLwithRefType({ + path: joinPaths('/-/tree', this.escapedRef, this.path), + refType: this.refType, + }); } - - return this.isFolder ? treeRouteConfig : null; + return null; }, isBlob() { return this.type === 'blob'; @@ -159,6 +164,7 @@ export default { this.apolloQuery(paginatedTreeQuery, { projectPath: this.projectPath, ref: this.ref, + refType: this.refType?.toUpperCase() || null, path: this.path, nextPageCursor: '', pageSize: TREE_PAGE_SIZE, @@ -169,7 +175,8 @@ export default { projectPath: this.projectPath, filePath: this.path, ref: this.ref, - shouldFetchRawText: Boolean(this.glFeatures.highlightJs), + refType: this.refType?.toUpperCase() || null, + shouldFetchRawText: true, }); }, apolloQuery(query, variables) { diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 0c9b46344c5..dd2cfddc94e 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -27,6 +27,7 @@ export default { query: projectPathQuery, }, }, + inject: ['refType'], props: { path: { type: String, @@ -99,6 +100,7 @@ export default { variables: { projectPath: this.projectPath, ref: this.ref, + refType: this.refType?.toUpperCase(), path: originalPath, nextPageCursor: this.nextPageCursor, pageSize: TREE_PAGE_SIZE, @@ -171,7 +173,7 @@ export default { } }, loadCommitData(rowNumber) { - loadCommits(this.projectPath, this.path, this.ref, rowNumber) + loadCommits(this.projectPath, this.path, this.ref, rowNumber, this.refType) .then(this.setCommitData) .catch(() => {}); }, diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index b711f671850..3079ef0bfbb 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -83,6 +83,8 @@ export const DEFAULT_BLOB_INFO = { }, }; +export const JSON_LANGUAGE = 'json'; +export const OPENAPI_FILE_TYPE = 'openapi'; export const TEXT_FILE_TYPE = 'text'; export const LFS_STORAGE = 'lfs'; @@ -114,3 +116,5 @@ 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/repository/index.js b/app/assets/javascripts/repository/index.js index c1e0104c6ac..9753173ac30 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,5 +1,6 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import { joinPaths, escapeFileUrl, visitUrl } from '~/lib/utils/url_utility'; @@ -121,6 +122,7 @@ export default function setupVueRepositoryList() { return h(LastCommit, { props: { currentPath: this.$route.params.path, + refType: this.$route.query.ref_type, }, }); }, @@ -207,6 +209,7 @@ export default function setupVueRepositoryList() { return h(Breadcrumbs, { props: { currentPath: this.$route.params.path, + refType: this.$route.query.ref_type, canCollaborate: parseBoolean(canCollaborate), canEditTree: parseBoolean(canEditTree), canPushCode: parseBoolean(canPushCode), @@ -228,20 +231,12 @@ export default function setupVueRepositoryList() { const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); const { historyLink } = treeHistoryLinkEl.dataset; - let { isProjectOverview } = treeHistoryLinkEl.dataset; - - const isProjectOverviewAfterEach = router.afterEach(() => { - isProjectOverview = false; - isProjectOverviewAfterEach(); - }); // eslint-disable-next-line no-new new Vue({ el: treeHistoryLinkEl, router, render(h) { - if (parseBoolean(isProjectOverview) && !this.$route.params.path) return null; - return h( GlButton, { diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js index 822a8b4ee38..5b6f68681bb 100644 --- a/app/assets/javascripts/repository/mixins/highlight_mixin.js +++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js @@ -8,7 +8,6 @@ import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/h import LineHighlighter from '~/blob/line_highlighter'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import Tracking from '~/tracking'; -import { TEXT_FILE_TYPE } from '../constants'; /* * This mixin is intended to be used as an interface between our highlight worker and Vue components @@ -37,8 +36,8 @@ export default { this.trackEvent(EVENT_LABEL_FALLBACK, language); this?.onError(); }, - initHighlightWorker({ rawTextBlob, language, simpleViewer, fileType }) { - if (simpleViewer?.fileType !== TEXT_FILE_TYPE || !this.glFeatures.highlightJsWorker) return; + initHighlightWorker({ rawTextBlob, language, fileType }) { + if (language !== 'json' || !this.glFeatures.highlightJsWorker) return; if (this.isUnsupportedLanguage(language)) { this.handleUnsupportedLanguage(language); diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index 30c36dee48f..473317ecf5d 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -18,13 +18,13 @@ export default { methods: { preload(path = '/', next) { this.loadingPath = path.replace(/^\//, ''); - return this.$apollo .query({ query: paginatedTreeQuery, variables: { projectPath: this.projectPath, ref: this.ref, + refType: this.refType?.toUpperCase(), path: this.loadingPath, nextPageCursor: '', pageSize: 100, diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue index c09e2133936..89bfba79a37 100644 --- a/app/assets/javascripts/repository/pages/blob.vue +++ b/app/assets/javascripts/repository/pages/blob.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> // This file is in progress and behind a feature flag, please see the following issue for more: // https://gitlab.com/gitlab-org/gitlab/-/issues/323200 @@ -31,11 +32,15 @@ export default { type: String, required: true, }, + refType: { + type: String, + required: false, + default: null, + }, }, limitedContainerElements: document.querySelectorAll(`.${LIMITED_CONTAINER_WIDTH_CLASS}`), }; </script> - <template> - <blob-content-viewer :path="path" :project-path="projectPath" /> + <blob-content-viewer :path="path" :project-path="projectPath" :ref-type="refType" /> </template> diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue index 0e53235779c..0e46871cee1 100644 --- a/app/assets/javascripts/repository/pages/index.vue +++ b/app/assets/javascripts/repository/pages/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { updateElementsVisibility } from '../utils/dom'; import TreePage from './tree.vue'; @@ -6,6 +7,13 @@ export default { components: { TreePage, }, + props: { + refType: { + type: String, + required: false, + default: null, + }, + }, mounted() { this.updateProjectElements(true); }, @@ -21,5 +29,5 @@ export default { </script> <template> - <tree-page path="/" /> + <tree-page path="/" :ref-type="refType" /> </template> diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index 6bf674eb3f1..0d35bfa679f 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import TreeContent from 'jh_else_ce/repository/components/tree_content.vue'; import preloadMixin from '../mixins/preload'; @@ -8,12 +9,22 @@ export default { TreeContent, }, mixins: [preloadMixin], + provide() { + return { + refType: this.refType, + }; + }, props: { path: { type: String, required: false, default: '/', }, + refType: { + type: String, + required: false, + default: '', + }, }, computed: { isRoot() { diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 0a675e14eb5..5f73912ed2b 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -13,15 +13,19 @@ export default function createRouter(base, baseRef) { component: TreePage, props: (route) => ({ path: route.params.path?.replace(/^\//, '') || '/', + refType: route.query.ref_type || null, }), }; const blobPathRoute = { component: BlobPage, - props: (route) => ({ - path: route.params.path, - projectPath: base, - }), + props: (route) => { + return { + path: route.params.path, + projectPath: base, + refType: route.query.ref_type || null, + }; + }, }; const router = new VueRouter({ @@ -56,6 +60,9 @@ export default function createRouter(base, baseRef) { path: '/', name: 'projectRoot', component: IndexPage, + props: { + refType: 'HEADS', + }, }, ], }); diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js index bcad4a2c822..f3d21971771 100644 --- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -28,7 +28,7 @@ export function generateRefDestinationPath(projectRootPath, ref, selectedRef) { [, refType, actualRef] = matches; } if (refType) { - url.searchParams.set('ref_type', refType); + url.searchParams.set('ref_type', refType.toLowerCase()); } else { url.searchParams.delete('ref_type'); } diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index cd289be4c05..9962f711892 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -1,11 +1,15 @@ <script> +// eslint-disable-next-line no-restricted-imports 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 SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; -import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB, SCOPE_PROJECTS } from '../constants'; import IssuesFilters from './issues_filters.vue'; -import LanguageFilter from './language_filter/index.vue'; +import MergeRequestsFilters from './merge_requests_filters.vue'; +import BlobsFilters from './blobs_filters.vue'; +import ProjectsFilters from './projects_filters.vue'; export default { name: 'GlobalSearchSidebar', @@ -13,25 +17,33 @@ export default { IssuesFilters, ScopeLegacyNavigation, ScopeSidebarNavigation, - LanguageFilter, SidebarPortal, + MergeRequestsFilters, + BlobsFilters, + ProjectsFilters, }, + mixins: [glFeatureFlagsMixin()], computed: { // useSidebarNavigation refers to whether the new left sidebar navigation is enabled ...mapState(['useSidebarNavigation']), ...mapGetters(['currentScope']), - showIssueAndMergeFilters() { - return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS; + showIssuesFilters() { + return this.currentScope === SCOPE_ISSUES; + }, + showMergeRequestFilters() { + return this.currentScope === SCOPE_MERGE_REQUESTS; }, - showBlobFilter() { + showBlobFilters() { return this.currentScope === SCOPE_BLOB; }, - showLabelFilter() { - return this.currentScope === SCOPE_ISSUES; + 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; }, showScopeNavigation() { // showScopeNavigation refers to whether the scope navigation should be shown - // while the legacy navigation is being used and there are no search results the scope navigation has to be hidden + // while the legacy navigation is being used and there are no search results + // the scope navigation has to be hidden return Boolean(this.currentScope); }, }, @@ -42,8 +54,10 @@ export default { <section v-if="useSidebarNavigation"> <sidebar-portal> <scope-sidebar-navigation /> - <issues-filters v-if="showIssueAndMergeFilters" /> - <language-filter v-if="showBlobFilter" /> + <issues-filters v-if="showIssuesFilters" /> + <merge-requests-filters v-if="showMergeRequestFilters" /> + <blobs-filters v-if="showBlobFilters" /> + <projects-filters v-if="showProjectsFilters" /> </sidebar-portal> </section> <section @@ -51,7 +65,9 @@ export default { class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5" > <scope-legacy-navigation /> - <issues-filters v-if="showIssueAndMergeFilters" /> - <language-filter v-if="showBlobFilter" /> + <issues-filters v-if="showIssuesFilters" /> + <merge-requests-filters v-if="showMergeRequestFilters" /> + <blobs-filters v-if="showBlobFilters" /> + <projects-filters v-if="showProjectsFilters" /> </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 new file mode 100644 index 00000000000..77efbdd9e60 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js @@ -0,0 +1,19 @@ +import { s__ } from '~/locale'; + +const headerLabel = s__('GlobalSearch|Archived'); +const checkboxLabel = s__('GlobalSearch|Include archived'); +export const TRACKING_NAMESPACE = 'search:archived:select'; +export const TRACKING_LABEL_CHECKBOX = 'checkbox'; + +const scopes = { + PROJECTS: 'projects', +}; + +const filterParam = 'include_archived'; + +export const archivedFilterData = { + headerLabel, + checkboxLabel, + scopes, + filterParam, +}; diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue new file mode 100644 index 00000000000..1984e3a36c4 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue @@ -0,0 +1,55 @@ +<script> +import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapActions } from 'vuex'; +import Tracking from '~/tracking'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +import { archivedFilterData, TRACKING_NAMESPACE, TRACKING_LABEL_CHECKBOX } from './data'; + +export default { + name: 'ArchivedFilter', + components: { + GlFormCheckboxGroup, + GlFormCheckbox, + }, + computed: { + ...mapState(['urlQuery']), + selectedFilter: { + get() { + return [parseBoolean(this.urlQuery?.include_archived)]; + }, + set(value) { + const newValue = value?.pop() ?? false; + this.setQuery({ key: archivedFilterData.filterParam, value: newValue?.toString() }); + this.trackSelectCheckbox(newValue); + }, + }, + }, + methods: { + ...mapActions(['setQuery']), + trackSelectCheckbox(value) { + Tracking.event(TRACKING_NAMESPACE, TRACKING_LABEL_CHECKBOX, { + label: archivedFilterData.checkboxLabel, + property: value, + }); + }, + }, + archivedFilterData, +}; +</script> + +<template> + <gl-form-checkbox-group v-model="selectedFilter"> + <h5>{{ $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" + :value="true" + > + <span data-testid="label"> + {{ $options.archivedFilterData.checkboxLabel }} + </span> + </gl-form-checkbox> + </gl-form-checkbox-group> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js new file mode 100644 index 00000000000..d9d139bf572 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/tracking.js @@ -0,0 +1,38 @@ +import Tracking from '~/tracking'; + +export const TRACKING_CATEGORY = 'Language filters'; +export const TRACKING_LABEL_FILTERS = 'Filters'; + +export const TRACKING_LABEL_MAX = 'Max Shown'; +export const TRACKING_LABEL_SHOW_MORE = 'Show More'; +export const TRACKING_LABEL_APPLY = 'Apply Filters'; +export const TRACKING_LABEL_RESET = 'Reset Filters'; +export const TRACKING_LABEL_ALL = 'All Filters'; +export const TRACKING_PROPERTY_MAX = `More than filters to show`; + +export const TRACKING_ACTION_CLICK = 'search:agreggations:language:click'; +export const TRACKING_ACTION_SHOW = 'search:agreggations:language:show'; + +// select is imported and used in checkbox_filter.vue +export const TRACKING_ACTION_SELECT = 'search:agreggations:language:select'; + +export const trackShowMore = () => + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, { + label: TRACKING_LABEL_ALL, + }); + +export const trackShowHasOverMax = () => + Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, { + label: TRACKING_LABEL_MAX, + property: TRACKING_PROPERTY_MAX, + }); + +export const trackSubmitQuery = () => + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { + label: TRACKING_CATEGORY, + }); + +export const trackResetQuery = () => + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, { + label: TRACKING_CATEGORY, + }); diff --git a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue new file mode 100644 index 00000000000..5f4d6fbd56c --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue @@ -0,0 +1,18 @@ +<script> +import LanguageFilter from './language_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'BlobsFilters', + components: { + LanguageFilter, + FiltersTemplate, + }, +}; +</script> + +<template> + <filters-template> + <language-filter class="gl-mb-5" /> + </filters-template> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue deleted file mode 100644 index feff3f77dd2..00000000000 --- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue +++ /dev/null @@ -1,94 +0,0 @@ -<script> -import Vue from 'vue'; -import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; -import { mapState, mapActions, mapGetters } from 'vuex'; -import { intersection } from 'lodash'; -import Tracking from '~/tracking'; -import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants'; -import { formatSearchResultCount } from '../../store/utils'; - -export const TRACKING_LABEL_SET = 'set'; -export const TRACKING_LABEL_CHECKBOX = 'checkbox'; - -export default { - name: 'CheckboxFilter', - components: { - GlFormCheckboxGroup, - GlFormCheckbox, - }, - props: { - filtersData: { - type: Object, - required: true, - }, - trackingNamespace: { - type: String, - required: true, - }, - }, - computed: { - ...mapState(['query', 'useNewNavigation']), - ...mapGetters(['queryLanguageFilters']), - dataFilters() { - return Object.values(this.filtersData?.filters || []); - }, - flatDataFilterValues() { - return this.dataFilters.map(({ value }) => value); - }, - selectedFilter: { - get() { - return intersection(this.flatDataFilterValues, this.queryLanguageFilters); - }, - async set(value) { - this.setQuery({ key: this.filtersData?.filterParam, value }); - - await Vue.nextTick(); - this.trackSelectCheckbox(); - }, - }, - labelCountClasses() { - return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500']; - }, - }, - methods: { - ...mapActions(['setQuery']), - getFormattedCount(count) { - return formatSearchResultCount(count); - }, - trackSelectCheckbox() { - Tracking.event(this.trackingNamespace, TRACKING_LABEL_CHECKBOX, { - label: TRACKING_LABEL_SET, - property: this.selectedFilter, - }); - }, - }, - NAV_LINK_COUNT_DEFAULT_CLASSES, - LABEL_DEFAULT_CLASSES, -}; -</script> - -<template> - <div class="gl-mx-5"> - <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filtersData.header }}</h5> - <gl-form-checkbox-group v-model="selectedFilter"> - <gl-form-checkbox - v-for="f in dataFilters" - :key="f.label" - :value="f.label" - class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" - :class="$options.LABEL_DEFAULT_CLASSES" - > - <span - class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" - > - <span data-testid="label"> - {{ f.label }} - </span> - <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount"> - {{ getFormattedCount(f.count) }} - </span> - </span> - </gl-form-checkbox> - </gl-form-checkbox-group> - </div> -</template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue deleted file mode 100644 index 2a7988cd4c6..00000000000 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { confidentialFilterData } from '../constants/confidential_filter_data'; -import { HR_DEFAULT_CLASSES } from '../constants'; -import RadioFilter from './radio_filter.vue'; - -export default { - name: 'ConfidentialityFilter', - components: { - RadioFilter, - }, - computed: { - ...mapState(['useNewNavigation']), - }, - confidentialFilterData, - HR_DEFAULT_CLASSES, -}; -</script> - -<template> - <div> - <radio-filter :filter-data="$options.confidentialFilterData" /> - <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> - </div> -</template> diff --git a/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/data.js index ecb63ed9eea..ecb63ed9eea 100644 --- a/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/data.js diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue new file mode 100644 index 00000000000..176614be6da --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue @@ -0,0 +1,23 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapState } from 'vuex'; +import RadioFilter from '../radio_filter.vue'; +import { confidentialFilterData } from './data'; + +export default { + name: 'ConfidentialityFilter', + components: { + RadioFilter, + }, + computed: { + ...mapState(['useSidebarNavigation']), + }, + confidentialFilterData, +}; +</script> + +<template> + <div> + <radio-filter :filter-data="$options.confidentialFilterData" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/filters_template.vue b/app/assets/javascripts/search/sidebar/components/filters_template.vue new file mode 100644 index 00000000000..3dae05ccc69 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/filters_template.vue @@ -0,0 +1,60 @@ +<script> +import { GlButton, GlLink, GlForm } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapState, mapGetters } from 'vuex'; +import Tracking from '~/tracking'; + +import { + HR_DEFAULT_CLASSES, + TRACKING_ACTION_CLICK, + TRACKING_LABEL_APPLY, + TRACKING_LABEL_RESET, +} from '../constants/index'; + +export default { + name: 'FiltersTemplate', + components: { + GlButton, + GlLink, + GlForm, + }, + computed: { + ...mapState(['sidebarDirty', 'useSidebarNavigation']), + ...mapGetters(['currentScope']), + hrClasses() { + return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; + }, + }, + methods: { + ...mapActions(['applyQuery', 'resetQuery']), + applyQueryWithTracking() { + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { + label: this.currentScope, + }); + this.applyQuery(); + }, + resetQueryWithTracking() { + Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, { + label: this.currentScope, + }); + this.resetQuery(); + }, + }, +}; +</script> + +<template> + <gl-form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking"> + <hr v-if="!useSidebarNavigation" :class="hrClasses" /> + <slot></slot> + <hr v-if="!useSidebarNavigation" :class="hrClasses" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4"> + <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> + {{ __('Apply') }} + </gl-button> + <gl-link v-if="sidebarDirty" class="gl-ml-auto" @click="resetQueryWithTracking">{{ + __('Reset filters') + }}</gl-link> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 8928f80d83a..919bd2b2e49 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -1,43 +1,34 @@ <script> -import { GlButton, GlLink } from '@gitlab/ui'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import Tracking from '~/tracking'; +// 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, - TRACKING_ACTION_CLICK, - TRACKING_LABEL_APPLY, - TRACKING_CATEGORY, - TRACKING_LABEL_RESET, -} from '../constants/index'; -import { confidentialFilterData } from '../constants/confidential_filter_data'; -import { stateFilterData } from '../constants/state_filter_data'; -import ConfidentialityFilter from './confidentiality_filter.vue'; +import { HR_DEFAULT_CLASSES } from '../constants/index'; +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 LabelFilter from './label_filter/index.vue'; -import StatusFilter from './status_filter.vue'; +import StatusFilter from './status_filter/index.vue'; + +import FiltersTemplate from './filters_template.vue'; export default { name: 'IssuesFilters', components: { - GlButton, - GlLink, StatusFilter, ConfidentialityFilter, LabelFilter, + FiltersTemplate, }, mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), ...mapGetters(['currentScope']), - showReset() { - return this.urlQuery.state || this.urlQuery.confidential || this.urlQuery.labels; - }, + ...mapState(['useSidebarNavigation']), showConfidentialityFilter() { return Object.values(confidentialFilterData.scopes).includes(this.currentScope); }, showStatusFilter() { - return Object.values(stateFilterData.scopes).includes(this.currentScope); + return Object.values(statusFilterData.scopes).includes(this.currentScope); }, showLabelFilter() { return ( @@ -45,41 +36,22 @@ export default { this.glFeatures.searchIssueLabelAggregation ); }, + showDivider() { + return !this.useSidebarNavigation; + }, hrClasses() { return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; }, }, - methods: { - ...mapActions(['applyQuery', 'resetQuery']), - applyQueryWithTracking() { - Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { - label: TRACKING_CATEGORY, - }); - this.applyQuery(); - }, - resetQueryWithTracking() { - Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, { - label: TRACKING_CATEGORY, - }); - this.resetQuery(); - }, - }, }; </script> <template> - <form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking"> - <hr v-if="!useNewNavigation" :class="hrClasses" /> + <filters-template> <status-filter v-if="showStatusFilter" class="gl-mb-5" /> + <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" /> - <div class="gl-display-flex gl-align-items-center gl-mt-4"> - <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> - {{ __('Apply') }} - </gl-button> - <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQueryWithTracking">{{ - __('Reset filters') - }}</gl-link> - </div> - </form> + </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue index eb3556ac2cf..a6af789baad 100644 --- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -10,6 +10,7 @@ import { GlAlert, GlOutsideDirective as Outside, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { uniq } from 'lodash'; import { rgbFromHex } from '@gitlab/ui/dist/utils/utils'; @@ -19,8 +20,6 @@ import { sprintf } from '~/locale'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; import { I18N } from '~/vue_shared/global_search/constants'; - -import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; import LabelDropdownItems from './label_dropdown_items.vue'; import { @@ -62,7 +61,6 @@ export default { 'filteredUnselectedLabels', 'filteredAppliedSelectedLabels', 'appliedSelectedLabels', - 'filteredUnappliedSelectedLabels', ]), searchInputDescribeBy() { if (this.isLoggedIn) { @@ -107,9 +105,6 @@ export default { hasUnselectedLabels() { return this.filteredUnselectedLabels.length > 0; }, - dividerClasses() { - return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; - }, labelSearchBox() { return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]'); }, @@ -253,15 +248,10 @@ export default { <gl-form-checkbox-group v-model="selectedFilters"> <label-dropdown-items v-if="hasSelectedLabels" - data-testid="selected-lavel-items" :labels="filteredAppliedSelectedLabels" /> <gl-dropdown-divider v-if="hasSelectedLabels && hasUnselectedLabels" /> - <label-dropdown-items - v-if="hasUnselectedLabels" - data-testid="unselected-lavel-items" - :labels="filteredUnselectedLabels" - /> + <label-dropdown-items v-if="hasUnselectedLabels" :labels="filteredUnselectedLabels" /> </gl-form-checkbox-group> </gl-dropdown-form> </div> @@ -277,6 +267,5 @@ export default { <gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" /> </div> </div> - <hr v-if="!useSidebarNavigation" :class="dividerClasses" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue index b820ca837bc..7a1bfa200bb 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue @@ -1,7 +1,8 @@ <script> import Vue from 'vue'; import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; -import { mapState, mapActions, mapGetters } from 'vuex'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapGetters } from 'vuex'; import { intersection } from 'lodash'; import Tracking from '~/tracking'; import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../../constants'; @@ -27,7 +28,6 @@ export default { }, }, computed: { - ...mapState(['query', 'useNewNavigation']), ...mapGetters(['queryLanguageFilters']), dataFilters() { return Object.values(this.filtersData?.filters || []); @@ -62,7 +62,6 @@ export default { }); }, }, - NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES, }; </script> 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 c10b14bd116..ca1503d7c64 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -1,17 +1,11 @@ <script> -import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; +import { GlButton, GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; -import { __, s__, sprintf } from '~/locale'; -import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; +import { s__, sprintf } from '~/locale'; import { convertFiltersData } from '../../utils'; import CheckboxFilter from './checkbox_filter.vue'; -import { - trackShowMore, - trackShowHasOverMax, - trackSubmitQuery, - trackResetQuery, - TRACKING_ACTION_SELECT, -} from './tracking'; +import { trackShowMore, trackShowHasOverMax, TRACKING_ACTION_SELECT } from './tracking'; import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH, languageFilterData } from './data'; @@ -21,7 +15,6 @@ export default { CheckboxFilter, GlButton, GlAlert, - GlForm, }, data() { return { @@ -30,18 +23,12 @@ export default { }, i18n: { showMore: s__('GlobalSearch|Show more'), - apply: __('Apply'), showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }), loadError: s__('GlobalSearch|Aggregations load error.'), - reset: s__('GlobalSearch|Reset filters'), }, computed: { - ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']), - ...mapGetters([ - 'languageAggregationBuckets', - 'currentUrlQueryHasLanguageFilters', - 'queryLanguageFilters', - ]), + ...mapState(['aggregations', 'useSidebarNavigation']), + ...mapGetters(['languageAggregationBuckets']), hasBuckets() { return this.languageAggregationBuckets.length > 0; }, @@ -63,26 +50,12 @@ export default { hasOverMax() { return this.languageAggregationBuckets.length > MAX_ITEM_LENGTH; }, - dividerClassesTop() { - return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; - }, - dividerClassesBottom() { - return [...HR_DEFAULT_CLASSES, 'gl-mt-5']; - }, - hasQueryFilters() { - return this.queryLanguageFilters.length > 0; - }, }, async created() { await this.fetchAllAggregation(); }, methods: { - ...mapActions([ - 'applyQuery', - 'resetLanguageQuery', - 'resetLanguageQueryWithRedirect', - 'fetchAllAggregation', - ]), + ...mapActions(['fetchAllAggregation']), onShowMore() { this.showAll = true; trackShowMore(); @@ -91,91 +64,47 @@ export default { trackShowHasOverMax(); } }, - submitQuery() { - trackSubmitQuery(); - this.applyQuery(); - }, trimBuckets(length) { return this.languageAggregationBuckets.slice(0, length); }, - cleanResetFilters() { - trackResetQuery(); - if (this.currentUrlQueryHasLanguageFilters) { - return this.resetLanguageQueryWithRedirect(); - } - this.showAll = false; - return this.resetLanguageQuery(); - }, }, - HR_DEFAULT_CLASSES, TRACKING_ACTION_SELECT, languageFilterData, }; </script> <template> - <div> - <gl-form - v-if="hasBuckets" - class="gl-m-5 gl-my-0 language-filter-checkbox" - @submit.prevent="submitQuery" + <div v-if="hasBuckets" class="gl-my-0 language-filter-checkbox"> + <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> + {{ $options.languageFilterData.header }} + </h5> + <div + v-if="!aggregations.error" + class="gl-overflow-x-hidden gl-overflow-y-auto" + :class="{ 'language-filter-max-height': showAll }" > - <hr v-if="!useNewNavigation" :class="dividerClassesTop" /> - <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }"> - {{ $options.languageFilterData.header }} - </h5> - <div - v-if="!aggregations.error" - class="gl-overflow-x-hidden gl-overflow-y-auto" - :class="{ 'language-filter-max-height': showAll }" + <checkbox-filter + :filters-data="filtersData" + :tracking-namespace="$options.TRACKING_ACTION_SELECT" + /> + <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{ + $options.i18n.showingMax + }}</span> + </div> + <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{ + $options.i18n.loadError + }}</gl-alert> + <div v-if="hasShowMore && !showAll" class="language-filter-show-all"> + <gl-button + data-testid="show-more-button" + category="tertiary" + variant="link" + size="small" + button-text-classes="gl-font-sm" + @click="onShowMore" > - <checkbox-filter - :filters-data="filtersData" - :tracking-namespace="$options.TRACKING_ACTION_SELECT" - /> - <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{ - $options.i18n.showingMax - }}</span> - </div> - <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{ - $options.i18n.loadError - }}</gl-alert> - <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all"> - <gl-button - data-testid="show-more-button" - category="tertiary" - variant="link" - size="small" - button-text-classes="gl-font-sm" - @click="onShowMore" - > - {{ $options.i18n.showMore }} - </gl-button> - </div> - <div v-if="!aggregations.error"> - <hr v-if="!useNewNavigation" :class="dividerClassesBottom" /> - <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4"> - <gl-button - category="primary" - variant="confirm" - type="submit" - :disabled="!sidebarDirty" - data-testid="apply-button" - > - {{ $options.i18n.apply }} - </gl-button> - <gl-button - v-if="hasQueryFilters && sidebarDirty" - category="tertiary" - variant="link" - size="small" - data-testid="reset-button" - @click="cleanResetFilters" - > - {{ $options.i18n.reset }} - </gl-button> - </div> - </div> - </gl-form> + {{ $options.i18n.showMore }} + </gl-button> + </div> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js index db107830329..5f085c7df7e 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js +++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js @@ -27,13 +27,3 @@ export const trackShowHasOverMax = () => label: TRACKING_LABEL_MAX, property: TRACKING_PROPERTY_MAX, }); - -export const trackSubmitQuery = () => - Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { - label: TRACKING_CATEGORY, - }); - -export const trackResetQuery = () => - Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, { - label: TRACKING_CATEGORY, - }); diff --git a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue new file mode 100644 index 00000000000..bc5b797dd56 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue @@ -0,0 +1,18 @@ +<script> +import StatusFilter from './status_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'MergeRequestsFilters', + components: { + StatusFilter, + FiltersTemplate, + }, +}; +</script> + +<template> + <filters-template> + <status-filter class="gl-mb-5" /> + </filters-template> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/projects_filters.vue b/app/assets/javascripts/search/sidebar/components/projects_filters.vue new file mode 100644 index 00000000000..093bfd2297f --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/projects_filters.vue @@ -0,0 +1,18 @@ +<script> +import ArchivedFilter from './archived_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'ProjectsFilters', + 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/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue index 10ece1b82eb..a1eb5ccecd8 100644 --- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -1,5 +1,6 @@ <script> import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { sprintf, __ } from '~/locale'; @@ -16,7 +17,7 @@ export default { }, }, computed: { - ...mapState(['query', 'useNewNavigation']), + ...mapState(['query', 'useSidebarNavigation']), ...mapGetters(['currentScope']), ANY() { return this.filterData.filters.ANY; @@ -56,7 +57,7 @@ export default { <template> <div> - <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }"> + <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> {{ filterData.header }} </h5> <gl-form-radio-group v-model="selectedFilter"> diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue deleted file mode 100644 index 24804baef44..00000000000 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script> -import { GlButton, GlLink } from '@gitlab/ui'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { HR_DEFAULT_CLASSES } from '../constants/index'; -import { confidentialFilterData } from '../constants/confidential_filter_data'; -import { stateFilterData } from '../constants/state_filter_data'; -import ConfidentialityFilter from './confidentiality_filter.vue'; -import StatusFilter from './status_filter.vue'; - -export default { - name: 'ResultsFilters', - components: { - GlButton, - GlLink, - StatusFilter, - ConfidentialityFilter, - }, - computed: { - ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), - ...mapGetters(['currentScope']), - showReset() { - return this.urlQuery.state || this.urlQuery.confidential; - }, - showConfidentialityFilter() { - return Object.values(confidentialFilterData.scopes).includes(this.currentScope); - }, - showStatusFilter() { - return Object.values(stateFilterData.scopes).includes(this.currentScope); - }, - hrClasses() { - return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; - }, - }, - methods: { - ...mapActions(['applyQuery', 'resetQuery']), - }, -}; -</script> - -<template> - <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> - <hr v-if="!useNewNavigation" :class="hrClasses" /> - <status-filter v-if="showStatusFilter" /> - <confidentiality-filter v-if="showConfidentialityFilter" /> - <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5"> - <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> - {{ __('Apply') }} - </gl-button> - <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ - __('Reset filters') - }}</gl-link> - </div> - </form> -</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 e682369d60b..e8d5de4d769 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue @@ -1,5 +1,6 @@ <script> import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; diff --git a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue index 3707e152e47..f30618ad9b7 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue @@ -1,7 +1,7 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; -import Tracking from '~/tracking'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; @@ -13,7 +13,6 @@ export default { components: { NavItem, }, - mixins: [Tracking.mixin()], computed: { ...mapState(['navigation', 'urlQuery']), ...mapGetters(['navigationItems']), diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue deleted file mode 100644 index 2a3d9ede982..00000000000 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { stateFilterData } from '../constants/state_filter_data'; -import { HR_DEFAULT_CLASSES } from '../constants'; -import RadioFilter from './radio_filter.vue'; - -export default { - name: 'StatusFilter', - components: { - RadioFilter, - }, - computed: { - ...mapState(['useNewNavigation']), - }, - stateFilterData, - HR_DEFAULT_CLASSES, -}; -</script> - -<template> - <div> - <radio-filter :filter-data="$options.stateFilterData" /> - <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> - </div> -</template> diff --git a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/components/status_filter/data.js index 2f9f8a7cb46..1e3cd59214b 100644 --- a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js +++ b/app/assets/javascripts/search/sidebar/components/status_filter/data.js @@ -33,7 +33,7 @@ const filterByScope = { const filterParam = 'state'; -export const stateFilterData = { +export const statusFilterData = { header, filters, scopes, diff --git a/app/assets/javascripts/search/sidebar/components/status_filter/index.vue b/app/assets/javascripts/search/sidebar/components/status_filter/index.vue new file mode 100644 index 00000000000..a5f717dcf06 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/status_filter/index.vue @@ -0,0 +1,18 @@ +<script> +import { HR_DEFAULT_CLASSES } from '../../constants'; +import RadioFilter from '../radio_filter.vue'; +import { statusFilterData } from './data'; + +export default { + name: 'StatusFilter', + components: { + RadioFilter, + }, + statusFilterData, + HR_DEFAULT_CLASSES, +}; +</script> + +<template> + <radio-filter :filter-data="$options.statusFilterData" /> +</template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 99d8821db61..01d0aad206c 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -1,6 +1,7 @@ export const SCOPE_ISSUES = 'issues'; export const SCOPE_MERGE_REQUESTS = 'merge_requests'; export const SCOPE_BLOB = 'blobs'; +export const SCOPE_PROJECTS = 'projects'; export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', @@ -18,4 +19,3 @@ 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 TRACKING_CATEGORY = 'Issue filters'; diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue index 9f28d2bfc99..79717802dc6 100644 --- a/app/assets/javascripts/search/sort/components/app.vue +++ b/app/assets/javascripts/search/sort/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlCollapsibleListbox, GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { SORT_DIRECTION_UI } from '../constants'; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 077c46bbe22..a68a0f75a2f 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -4,7 +4,6 @@ import axios from '~/lib/utils/axios_utils'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; -import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; @@ -108,9 +107,18 @@ export const applyQuery = ({ state }) => { }; export const resetQuery = ({ state }) => { + const resetParams = SIDEBAR_PARAMS.reduce((acc, param) => { + acc[param] = null; + return acc; + }, {}); + visitUrl( setUrlParams( - { ...state.query, page: null, state: null, confidential: null, labels: null }, + { + ...state.query, + page: null, + ...resetParams, + }, undefined, true, ), @@ -127,14 +135,6 @@ export const setLabelFilterSearch = ({ commit }, { value }) => { commit(types.SET_LABEL_SEARCH_STRING, value); }; -export const resetLanguageQueryWithRedirect = ({ state }) => { - visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true)); -}; - -export const resetLanguageQuery = ({ commit }) => { - commit(types.SET_QUERY, { key: languageFilterData?.filterParam, value: [] }); -}; - export const fetchSidebarCount = ({ commit, state }) => { const promises = Object.values(state.navigation).map((navItem) => { // active nav item has count already so we skip it diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index bb112c122ae..f3b4a09b45b 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,7 +1,8 @@ -import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; -import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; +import { statusFilterData } from '~/search/sidebar/components/status_filter/data'; +import { confidentialFilterData } from '~/search/sidebar/components/confidentiality_filter/data'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; +import { archivedFilterData } from '~/search/sidebar/components/archived_filter/data'; export const MAX_FREQUENT_ITEMS = 5; @@ -12,10 +13,11 @@ export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; export const SIDEBAR_PARAMS = [ - stateFilterData.filterParam, + statusFilterData.filterParam, confidentialFilterData.filterParam, languageFilterData.filterParam, labelFilterData.filterParam, + archivedFilterData.filterParam, ]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index c7cb595f42f..d01fd884bad 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,4 +1,4 @@ -import { findKey, has } from 'lodash'; +import { findKey } from 'lodash'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; @@ -47,9 +47,6 @@ export const appliedSelectedLabels = (state) => { ); }; -export const filteredUnappliedSelectedLabels = (state) => - filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key)); - export const filteredUnselectedLabels = (state) => { if (!state?.urlQuery?.labels) { return filteredLabels(state); @@ -62,10 +59,6 @@ export const currentScope = (state) => findKey(state.navigation, { active: true export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || []; -export const currentUrlQueryHasLanguageFilters = (state) => - has(state.urlQuery, languageFilterData.filterParam) && - state.urlQuery[languageFilterData.filterParam]?.length > 0; - export const navigationItems = (state) => Object.values(state.navigation).map((item) => ({ title: item.label, diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index 2478518c157..e77438d9d6b 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 16ff8c94885..ee66bdb2632 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index 4798f1127eb..a177eb28991 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -1,5 +1,6 @@ <script> import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index 1cce3e3db8b..c8190b4002d 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index e7d97989195..c7d89113895 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -182,7 +182,7 @@ export default { <section-layout class="gl-border-b-0" :heading="$options.i18n.securityTesting"> <template #description> <p> - <span data-testid="latest-pipeline-info-security"> + <span> <gl-sprintf v-if="latestPipelinePath" :message="$options.i18n.latestPipelineDescription" diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 1c2be99b393..b427820144d 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -57,7 +57,7 @@ export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { anchor: 'enable-automatic-dast-run', }); -export const DAST_BADGE_TEXT = __('Available on-demand'); +export const DAST_BADGE_TEXT = __('Available on demand'); export const DAST_BADGE_TOOLTIP = __( 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', ); diff --git a/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue new file mode 100644 index 00000000000..a15c8ee2e9f --- /dev/null +++ b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue @@ -0,0 +1,58 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, +} from '../constants'; + +export default { + i18n: { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, + }, + components: { + GlEmptyState, + }, + inject: ['emptyStateSvgPath'], + props: { + hasSearch: { + type: Boolean, + required: true, + }, + isOpenTab: { + type: Boolean, + required: true, + }, + }, + computed: { + content() { + if (this.hasSearch) { + return { + title: noSearchResultsTitle, + description: noSearchResultsDescription, + svgHeight: 150, + }; + } else if (this.isOpenTab) { + return { title: noOpenIssuesTitle, description: infoBannerUserNote }; + } + + return { title: noClosedIssuesTitle, svgHeight: 150 }; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :description="content.description" + :title="content.title" + :svg-path="emptyStateSvgPath" + :svg-height="content.svgHeight" + /> +</template> diff --git a/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue new file mode 100644 index 00000000000..9dbed2c2579 --- /dev/null +++ b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue @@ -0,0 +1,74 @@ +<script> +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { + noIssuesSignedOutButtonText, + infoBannerTitle, + infoBannerUserNote, + infoBannerAdminNote, + learnMore, +} from '../constants'; + +export default { + i18n: { + noIssuesSignedOutButtonText, + infoBannerTitle, + infoBannerUserNote, + infoBannerAdminNote, + learnMore, + }, + components: { + GlEmptyState, + GlLink, + }, + inject: [ + 'emptyStateSvgPath', + 'isSignedIn', + 'signInPath', + 'canAdminIssues', + 'isServiceDeskEnabled', + 'serviceDeskEmailAddress', + 'serviceDeskHelpPath', + ], + computed: { + canSeeEmailAddress() { + return this.canAdminIssues && this.isServiceDeskEnabled; + }, + }, +}; +</script> + +<template> + <div v-if="isSignedIn"> + <gl-empty-state + :title="$options.i18n.infoBannerTitle" + :svg-path="emptyStateSvgPath" + content-class="gl-max-w-80!" + > + <template #description> + <p v-if="canSeeEmailAddress"> + {{ $options.i18n.infoBannerAdminNote }} <br /><code>{{ serviceDeskEmailAddress }}</code> + </p> + <p>{{ $options.i18n.infoBannerUserNote }}</p> + <gl-link :href="serviceDeskHelpPath"> + {{ $options.i18n.learnMore }} + </gl-link> + </template> + </gl-empty-state> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.infoBannerTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + content-class="gl-max-w-80!" + > + <template #description> + <p>{{ $options.i18n.infoBannerUserNote }}</p> + <gl-link :href="serviceDeskHelpPath"> + {{ $options.i18n.learnMore }} + </gl-link> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/service_desk/components/info_banner.vue b/app/assets/javascripts/service_desk/components/info_banner.vue index 8aaced839a5..5667ee2f31d 100644 --- a/app/assets/javascripts/service_desk/components/info_banner.vue +++ b/app/assets/javascripts/service_desk/components/info_banner.vue @@ -51,7 +51,7 @@ export default { </p> <p> {{ $options.i18n.infoBannerUserNote }} - <gl-link :href="serviceDeskHelpPath" target="_blank">{{ $options.i18n.learnMore }}</gl-link + <gl-link :href="serviceDeskHelpPath">{{ $options.i18n.learnMore }}</gl-link >. </p> <p v-if="canEnableServiceDesk" class="gl-mt-3"> diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue index e8b05642e7d..56cd21d7ea9 100644 --- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -1,49 +1,116 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; -import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; -import { STATUS_OPEN, STATUS_CLOSED, STATUS_ALL } from '~/issues/constants'; -import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql'; -import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql'; +import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import { + convertToSearchQuery, + convertToApiParams, + getInitialPageParams, + getFilterTokens, + isSortKey, +} from '~/issues/list/utils'; +import { + OPERATORS_IS_NOT, + OPERATORS_IS_NOT_OR, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { + MAX_LIST_SIZE, + ISSUE_REFERENCE, + PARAM_STATE, + PARAM_FIRST_PAGE_SIZE, + PARAM_LAST_PAGE_SIZE, + PARAM_PAGE_AFTER, + PARAM_PAGE_BEFORE, + PARAM_SORT, + CREATED_DESC, + UPDATED_DESC, + urlSortParams, +} from '~/issues/list/constants'; +import { convertToGraphQLId } 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 searchProjectLabelsQuery from '../queries/search_project_labels.query.graphql'; +import searchProjectMilestonesQuery from '../queries/search_project_milestones.query.graphql'; import { errorFetchingCounts, errorFetchingIssues, - noSearchNoFilterTitle, searchPlaceholder, SERVICE_DESK_BOT_USERNAME, + STATUS_OPEN, + STATUS_CLOSED, + STATUS_ALL, + WORKSPACE_PROJECT, } from '../constants'; +import { convertToUrlParams } from '../utils'; +import { + searchWithinTokenBase, + assigneeTokenBase, + milestoneTokenBase, + labelTokenBase, + releaseTokenBase, + reactionTokenBase, + confidentialityTokenBase, +} from '../search_tokens'; import InfoBanner from './info_banner.vue'; +import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; export default { i18n: { errorFetchingCounts, errorFetchingIssues, - noSearchNoFilterTitle, searchPlaceholder, }, issuableListTabs, components: { - GlEmptyState, IssuableList, InfoBanner, + EmptyStateWithAnyIssues, + EmptyStateWithoutAnyIssues, }, + mixins: [glFeatureFlagMixin()], inject: [ + 'releasesPath', + 'autocompleteAwardEmojisPath', + 'hasIterationsFeature', + 'hasIssueWeightsFeature', + 'hasIssuableHealthStatusFeature', + 'groupPath', 'emptyStateSvgPath', 'isProject', 'isSignedIn', 'fullPath', 'isServiceDeskSupported', 'hasAnyIssues', + 'initialSort', ], + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, data() { return { serviceDeskIssues: [], serviceDeskIssuesCounts: {}, - searchTokens: [], sortOptions: [], + filterTokens: [], + pageInfo: {}, + pageParams: {}, + sortKey: CREATED_DESC, state: STATUS_OPEN, + pageSize: DEFAULT_PAGE_SIZE, issuesError: null, }; }, @@ -71,7 +138,7 @@ export default { Sentry.captureException(error); }, skip() { - return !this.hasAnyIssues; + return this.shouldSkipQuery; }, }, serviceDeskIssuesCounts: { @@ -86,6 +153,9 @@ export default { this.issuesError = this.$options.i18n.errorFetchingCounts; Sentry.captureException(error); }, + skip() { + return this.shouldSkipQuery; + }, context: { isSingleRequest: true, }, @@ -93,14 +163,23 @@ export default { }, computed: { queryVariables() { + const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery); return { fullPath: this.fullPath, + iid: isIidSearch ? this.searchQuery.slice(1) : undefined, isProject: this.isProject, isSignedIn: this.isSignedIn, authorUsername: SERVICE_DESK_BOT_USERNAME, + sort: this.sortKey, state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + search: isIidSearch ? undefined : this.searchQuery, }; }, + shouldSkipQuery() { + return !this.hasAnyIssues || isEmpty(this.pageParams); + }, tabCounts() { const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts; return { @@ -109,16 +188,222 @@ export default { [STATUS_ALL]: allIssues?.count, }; }, + isLoading() { + return this.$apollo.queries.serviceDeskIssues.loading; + }, + isOpenTab() { + return this.state === STATUS_OPEN; + }, + urlParams() { + return { + sort: urlSortParams[this.sortKey], + state: this.state, + ...this.urlFilterParams, + first_page_size: this.pageParams.firstPageSize, + last_page_size: this.pageParams.lastPageSize, + page_after: this.pageParams.afterCursor ?? undefined, + page_before: this.pageParams.beforeCursor ?? undefined, + }; + }, isInfoBannerVisible() { - return this.isServiceDeskSupported && this.hasAnyIssues; + return this.isServiceDeskSupported && this.hasAnyServiceDeskIssues; }, + hasAnyServiceDeskIssues() { + return this.hasSearch || Boolean(this.tabCounts.all); + }, + hasOrFeature() { + return this.glFeatures.orIssuableQueries; + }, + hasSearch() { + return Boolean( + this.searchQuery || + Object.keys(this.urlFilterParams).length || + this.pageParams.afterCursor || + this.pageParams.beforeCursor, + ); + }, + apiFilterParams() { + return convertToApiParams(this.filterTokens); + }, + urlFilterParams() { + return convertToUrlParams(this.filterTokens); + }, + searchQuery() { + return convertToSearchQuery(this.filterTokens); + }, + searchTokens() { + const preloadedUsers = []; + + if (gon.current_user_id) { + preloadedUsers.push({ + id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); + } + + const tokens = [ + { + ...searchWithinTokenBase, + }, + { + ...assigneeTokenBase, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, + preloadedUsers, + }, + { + ...milestoneTokenBase, + fetchMilestones: this.fetchMilestones, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, + }, + { + ...labelTokenBase, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchLabels: this.fetchLabels, + fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, + }, + ]; + + if (this.isProject) { + tokens.push({ + ...releaseTokenBase, + fetchReleases: this.fetchReleases, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`, + }); + } + + if (this.isSignedIn) { + tokens.push({ + ...reactionTokenBase, + fetchEmojis: this.fetchEmojis, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`, + }); + + tokens.push({ + ...confidentialityTokenBase, + }); + } + + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); + } + + tokens.sort((a, b) => a.title.localeCompare(b.title)); + + return tokens; + }, + }, + watch: { + $route(newValue, oldValue) { + if (newValue.fullPath !== oldValue.fullPath) { + this.updateData(getParameterByName(PARAM_SORT)); + } + }, + }, + created() { + this.updateData(this.initialSort); + this.cache = {}; }, methods: { + fetchWithCache(path, cacheName, searchKey, search) { + if (this.cache[cacheName]) { + const data = search + ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) + : this.cache[cacheName].slice(0, MAX_LIST_SIZE); + return Promise.resolve(data); + } + + return axios.get(path).then(({ data }) => { + this.cache[cacheName] = data; + return data.slice(0, MAX_LIST_SIZE); + }); + }, + fetchUsers(search) { + return this.$apollo + .query({ + query: searchProjectMembers, + variables: { fullPath: this.fullPath, search }, + }) + .then(({ data }) => + data[WORKSPACE_PROJECT]?.[`${WORKSPACE_PROJECT}Members`].nodes.map( + (member) => member.user, + ), + ); + }, + fetchMilestones(search) { + return this.$apollo + .query({ + query: searchProjectMilestonesQuery, + variables: { fullPath: this.fullPath, search }, + }) + .then(({ data }) => data[WORKSPACE_PROJECT]?.milestones.nodes); + }, + fetchEmojis(search) { + return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); + }, + fetchReleases(search) { + return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search); + }, + fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) { + return this.$apollo + .query({ + query: searchProjectLabelsQuery, + variables: { fullPath: this.fullPath, search }, + fetchPolicy, + }) + .then(({ data }) => data[WORKSPACE_PROJECT]?.labels.nodes) + .then((labels) => + // TODO remove once we can search by title-only on the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/346353 + labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), + ); + }, + fetchLabels(search) { + return this.fetchLabelsWithFetchPolicy(search); + }, + fetchLatestLabels(search) { + return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); + }, handleClickTab(state) { if (this.state === state) { return; } this.state = state; + this.pageParams = getInitialPageParams(this.pageSize); + + this.$router.push({ query: this.urlParams }); + }, + handleFilter(tokens) { + this.filterTokens = tokens; + this.pageParams = getInitialPageParams(this.pageSize); + + this.$router.push({ query: this.urlParams }); + }, + 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 graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); + + const sortKey = graphQLSortKey || defaultSortKey; + + this.filterTokens = getFilterTokens(window.location.search); + + this.pageParams = getInitialPageParams( + this.pageSize, + isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, + isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined, + getParameterByName(PARAM_PAGE_AFTER), + getParameterByName(PARAM_PAGE_BEFORE), + ); + this.sortKey = sortKey; + this.state = state || STATUS_OPEN; }, }, }; @@ -128,24 +413,31 @@ export default { <section> <info-banner v-if="isInfoBannerVisible" /> <issuable-list + v-if="isLoading || hasAnyServiceDeskIssues" namespace="service-desk" - recent-searches-storage-key="issues" + 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" :sort-options="sortOptions" + :initial-sort-by="sortKey" :issuables="serviceDeskIssues" :tabs="$options.issuableListTabs" :tab-counts="tabCounts" :current-tab="state" + :default-page-size="pageSize" + sync-filter-and-sort @click-tab="handleClickTab" + @filter="handleFilter" > <template #empty-state> - <gl-empty-state - :svg-path="emptyStateSvgPath" - :title="$options.i18n.noSearchNoFilterTitle" - /> + <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> </template> </issuable-list> + + <empty-state-without-any-issues v-else /> </section> </template> diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js index 685ad738792..a83c0d9ca57 100644 --- a/app/assets/javascripts/service_desk/constants.js +++ b/app/assets/javascripts/service_desk/constants.js @@ -1,10 +1,240 @@ import { __, s__ } from '~/locale'; +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS, + OPERATOR_NOT, + OPERATOR_OR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_EPIC, + TOKEN_TYPE_HEALTH, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, + TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_SEARCH_WITHIN, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { + ALTERNATIVE_FILTER, + API_PARAM, + NORMAL_FILTER, + SPECIAL_FILTER, + URL_PARAM, +} from '~/issues/list/constants'; export const SERVICE_DESK_BOT_USERNAME = 'support-bot'; +export const ISSUE_REFERENCE = /^#\d+$/; + +export const STATUS_ALL = 'all'; +export const STATUS_CLOSED = 'closed'; +export const STATUS_OPEN = 'opened'; + +export const WORKSPACE_PROJECT = 'project'; + +export const filtersMap = { + [FILTERED_SEARCH_TERM]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'search', + }, + [URL_PARAM]: { + [undefined]: { + [NORMAL_FILTER]: 'search', + }, + }, + }, + [TOKEN_TYPE_SEARCH_WITHIN]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'in', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'in', + }, + }, + }, + [TOKEN_TYPE_ASSIGNEE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'assigneeUsernames', + [SPECIAL_FILTER]: 'assigneeId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'assignee_username[]', + [SPECIAL_FILTER]: 'assignee_id', + [ALTERNATIVE_FILTER]: 'assignee_username', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[assignee_username][]', + }, + [OPERATOR_OR]: { + [NORMAL_FILTER]: 'or[assignee_username][]', + }, + }, + }, + [TOKEN_TYPE_MILESTONE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'milestoneTitle', + [SPECIAL_FILTER]: 'milestoneWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'milestone_title', + [SPECIAL_FILTER]: 'milestone_title', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[milestone_title]', + [SPECIAL_FILTER]: 'not[milestone_title]', + }, + }, + }, + [TOKEN_TYPE_LABEL]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'labelName', + [SPECIAL_FILTER]: 'labelName', + [ALTERNATIVE_FILTER]: 'labelNames', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'label_name[]', + [SPECIAL_FILTER]: 'label_name[]', + [ALTERNATIVE_FILTER]: 'label_name', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[label_name][]', + }, + [OPERATOR_OR]: { + [ALTERNATIVE_FILTER]: 'or[label_name][]', + }, + }, + }, + [TOKEN_TYPE_TYPE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'types', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'type[]', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[type][]', + }, + }, + }, + [TOKEN_TYPE_RELEASE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'releaseTag', + [SPECIAL_FILTER]: 'releaseTagWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'release_tag', + [SPECIAL_FILTER]: 'release_tag', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[release_tag]', + }, + }, + }, + [TOKEN_TYPE_MY_REACTION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'myReactionEmoji', + [SPECIAL_FILTER]: 'myReactionEmoji', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'my_reaction_emoji', + [SPECIAL_FILTER]: 'my_reaction_emoji', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[my_reaction_emoji]', + }, + }, + }, + [TOKEN_TYPE_CONFIDENTIAL]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'confidential', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'confidential', + }, + }, + }, + [TOKEN_TYPE_ITERATION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'iterationId', + [SPECIAL_FILTER]: 'iterationWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'iteration_id', + [SPECIAL_FILTER]: 'iteration_id', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[iteration_id]', + [SPECIAL_FILTER]: 'not[iteration_id]', + }, + }, + }, + [TOKEN_TYPE_EPIC]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'epicId', + [SPECIAL_FILTER]: 'epicId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, + }, + }, + [TOKEN_TYPE_WEIGHT]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[weight]', + }, + }, + }, + [TOKEN_TYPE_HEALTH]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'healthStatusFilter', + [SPECIAL_FILTER]: 'healthStatusFilter', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'health_status', + [SPECIAL_FILTER]: 'health_status', + }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[health_status]', + }, + }, + }, +}; export const errorFetchingCounts = __('An error occurred while getting issue counts'); export const errorFetchingIssues = __('An error occurred while loading issues'); -export const noSearchNoFilterTitle = __('Please select at least one filter to see results'); +export const noOpenIssuesTitle = __('There are no open issues'); +export const noClosedIssuesTitle = __('There are no closed issues'); +export const noIssuesSignedOutButtonText = __('Register / Sign In'); +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 infoBannerTitle = s__( 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab', @@ -14,4 +244,8 @@ export const infoBannerUserNote = s__( 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.', ); export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk'); -export const learnMore = __('Learn more'); +export const learnMore = __('Learn more about Service Desk'); +export const titles = __('Titles'); +export const descriptions = __('Descriptions'); +export const no = __('No'); +export const yes = __('Yes'); diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js index a9172f96540..afb2d0e8de3 100644 --- a/app/assets/javascripts/service_desk/index.js +++ b/app/assets/javascripts/service_desk/index.js @@ -1,8 +1,9 @@ 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 { gqlClient } from './graphql'; -import ServiceDeskListApp from './components/service_desk_list_app.vue'; export async function mountServiceDeskListApp() { const el = document.querySelector('.js-service-desk-list'); @@ -12,11 +13,19 @@ export async function mountServiceDeskListApp() { } const { + projectDataReleasesPath, + projectDataAutocompleteAwardEmojisPath, + projectDataHasIterationsFeature, + projectDataHasIssueWeightsFeature, + projectDataHasIssuableHealthStatusFeature, + projectDataGroupPath, projectDataEmptyStateSvgPath, projectDataFullPath, projectDataIsProject, projectDataIsSignedIn, + projectDataSignInPath, projectDataHasAnyIssues, + projectDataInitialSort, serviceDeskEmailAddress, canAdminIssues, canEditProjectSettings, @@ -28,6 +37,7 @@ export async function mountServiceDeskListApp() { } = el.dataset; Vue.use(VueApollo); + Vue.use(VueRouter); return new Vue({ el, @@ -35,7 +45,18 @@ export async function mountServiceDeskListApp() { apolloProvider: new VueApollo({ defaultClient: await gqlClient(), }), + router: new VueRouter({ + base: window.location.pathname, + mode: 'history', + routes: [{ path: '/' }], + }), provide: { + releasesPath: projectDataReleasesPath, + autocompleteAwardEmojisPath: projectDataAutocompleteAwardEmojisPath, + hasIterationsFeature: parseBoolean(projectDataHasIterationsFeature), + hasIssueWeightsFeature: parseBoolean(projectDataHasIssueWeightsFeature), + hasIssuableHealthStatusFeature: parseBoolean(projectDataHasIssuableHealthStatusFeature), + groupPath: projectDataGroupPath, emptyStateSvgPath: projectDataEmptyStateSvgPath, fullPath: projectDataFullPath, isProject: parseBoolean(projectDataIsProject), @@ -48,7 +69,9 @@ export async function mountServiceDeskListApp() { serviceDeskHelpPath, isServiceDeskSupported: parseBoolean(isServiceDeskSupported), isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled), + signInPath: projectDataSignInPath, hasAnyIssues: parseBoolean(projectDataHasAnyIssues), + initialSort: projectDataInitialSort, }, render: (createComponent) => createComponent(ServiceDeskListApp), }); diff --git a/app/assets/javascripts/service_desk/queries/label.fragment.graphql b/app/assets/javascripts/service_desk/queries/label.fragment.graphql new file mode 100644 index 00000000000..bb1d8f1ac9b --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/label.fragment.graphql @@ -0,0 +1,6 @@ +fragment Label on Label { + id + color + textColor + title +} diff --git a/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql b/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql new file mode 100644 index 00000000000..3cdf69bf585 --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/milestone.fragment.graphql @@ -0,0 +1,4 @@ +fragment Milestone on Milestone { + id + title +} diff --git a/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql b/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql new file mode 100644 index 00000000000..89ce14134b4 --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/search_project_labels.query.graphql @@ -0,0 +1,14 @@ +#import "./label.fragment.graphql" + +query searchProjectLabels($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) @persist { + id + labels(searchTerm: $search, includeAncestorGroups: true) { + __persist + nodes { + __persist + ...Label + } + } + } +} diff --git a/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql b/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql new file mode 100644 index 00000000000..f34166be87d --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/search_project_milestones.query.graphql @@ -0,0 +1,17 @@ +#import "./milestone.fragment.graphql" + +query searchProjectMilestones($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) { + id + milestones( + searchTitle: $search + includeAncestors: true + sort: EXPIRED_LAST_DUE_DATE_ASC + state: active + ) { + nodes { + ...Milestone + } + } + } +} diff --git a/app/assets/javascripts/service_desk/search_tokens.js b/app/assets/javascripts/service_desk/search_tokens.js new file mode 100644 index 00000000000..72750f518e4 --- /dev/null +++ b/app/assets/javascripts/service_desk/search_tokens.js @@ -0,0 +1,97 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { + OPERATORS_IS, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_CONFIDENTIAL, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_RELEASE, + TOKEN_TITLE_SEARCH_WITHIN, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_SEARCH_WITHIN, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { titles, descriptions, yes, no } from './constants'; + +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); +const EmojiToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); +const LabelToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const MilestoneToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); +const ReleaseToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); + +export const searchWithinTokenBase = { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: descriptions, + }, + ], +}; + +export const assigneeTokenBase = { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: UserToken, + dataType: 'user', +}; + +export const milestoneTokenBase = { + type: TOKEN_TYPE_MILESTONE, + title: TOKEN_TITLE_MILESTONE, + icon: 'clock', + token: MilestoneToken, + shouldSkipSort: true, +}; + +export const labelTokenBase = { + type: TOKEN_TYPE_LABEL, + title: TOKEN_TITLE_LABEL, + icon: 'labels', + token: LabelToken, +}; + +export const releaseTokenBase = { + type: TOKEN_TYPE_RELEASE, + title: TOKEN_TITLE_RELEASE, + icon: 'rocket', + token: ReleaseToken, +}; + +export const reactionTokenBase = { + type: TOKEN_TYPE_MY_REACTION, + title: TOKEN_TITLE_MY_REACTION, + icon: 'thumb-up', + token: EmojiToken, + unique: true, +}; + +export const confidentialityTokenBase = { + type: TOKEN_TYPE_CONFIDENTIAL, + title: TOKEN_TITLE_CONFIDENTIAL, + icon: 'eye-slash', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'eye-slash', value: 'yes', title: yes }, + { icon: 'eye', value: 'no', title: no }, + ], +}; diff --git a/app/assets/javascripts/service_desk/utils.js b/app/assets/javascripts/service_desk/utils.js new file mode 100644 index 00000000000..86f76da3880 --- /dev/null +++ b/app/assets/javascripts/service_desk/utils.js @@ -0,0 +1,37 @@ +import { + OPERATOR_OR, + TOKEN_TYPE_LABEL, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { isSpecialFilter, isNotEmptySearchToken } from '~/issues/list/utils'; +import { + ALTERNATIVE_FILTER, + NORMAL_FILTER, + SPECIAL_FILTER, + URL_PARAM, +} from '~/issues/list/constants'; +import { filtersMap } from './constants'; + +const getFilterType = ({ type, value: { data, operator } }) => { + const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR; + + if (isUnionedLabel) { + return ALTERNATIVE_FILTER; + } + if (isSpecialFilter(type, data)) { + return SPECIAL_FILTER; + } + return NORMAL_FILTER; +}; + +export const convertToUrlParams = (filterTokens) => { + const urlParamsMap = filterTokens.filter(isNotEmptySearchToken).reduce((acc, token) => { + const filterType = getFilterType(token); + const urlParam = filtersMap[token.type][URL_PARAM][token.value.operator]?.[filterType]; + return acc.set( + urlParam, + acc.has(urlParam) ? [acc.get(urlParam), token.value.data].flat() : token.value.data, + ); + }, new Map()); + + return Object.fromEntries(urlParamsMap); +}; diff --git a/app/assets/javascripts/sessions/new/components/email_verification.vue b/app/assets/javascripts/sessions/new/components/email_verification.vue new file mode 100644 index 00000000000..6a67c25b58f --- /dev/null +++ b/app/assets/javascripts/sessions/new/components/email_verification.vue @@ -0,0 +1,211 @@ +<script> +import { GlSprintf, GlForm, GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { + I18N_EXPLANATION, + I18N_INPUT_LABEL, + I18N_EMAIL_EMPTY_CODE, + I18N_EMAIL_INVALID_CODE, + I18N_SUBMIT_BUTTON, + I18N_RESEND_LINK, + I18N_EMAIL_RESEND_SUCCESS, + I18N_GENERIC_ERROR, + I18N_UPDATE_EMAIL, + VERIFICATION_CODE_REGEX, + SUCCESS_RESPONSE, + FAILURE_RESPONSE, +} from '../constants'; +import UpdateEmail from './update_email.vue'; + +export default { + name: 'EmailVerification', + components: { + GlSprintf, + GlForm, + GlFormGroup, + GlFormInput, + GlButton, + UpdateEmail, + }, + props: { + obfuscatedEmail: { + type: String, + required: true, + }, + verifyPath: { + type: String, + required: true, + }, + resendPath: { + type: String, + required: true, + }, + isOfferEmailReset: { + type: Boolean, + required: true, + }, + updateEmailPath: { + type: String, + required: true, + }, + }, + data() { + return { + email: this.obfuscatedEmail, + verificationCode: '', + submitted: false, + verifyError: '', + showUpdateEmail: false, + }; + }, + computed: { + inputValidation() { + return { + state: !(this.submitted && this.invalidFeedback), + message: this.invalidFeedback, + }; + }, + invalidFeedback() { + if (!this.submitted) { + return ''; + } + + if (!this.verificationCode) { + return I18N_EMAIL_EMPTY_CODE; + } + + if (!VERIFICATION_CODE_REGEX.test(this.verificationCode)) { + return I18N_EMAIL_INVALID_CODE; + } + + return this.verifyError; + }, + }, + watch: { + verificationCode() { + this.verifyError = ''; + }, + }, + methods: { + verify() { + this.submitted = true; + + if (!this.inputValidation.state) return; + + axios + .post(this.verifyPath, { user: { verification_token: this.verificationCode } }) + .then(this.handleVerificationResponse) + .catch(this.handleError); + }, + handleVerificationResponse(response) { + if (response.data.status === undefined) { + this.handleError(); + } else if (response.data.status === SUCCESS_RESPONSE) { + visitUrl(response.data.redirect_path); + } else if (response.data.status === FAILURE_RESPONSE) { + this.verifyError = response.data.message; + } + }, + resend() { + axios + .post(this.resendPath) + .then(this.handleResendResponse) + .catch(this.handleError) + .finally(this.resetForm); + }, + handleResendResponse(response) { + if (response.data.status === undefined) { + this.handleError(); + } else if (response.data.status === SUCCESS_RESPONSE) { + createAlert({ + message: I18N_EMAIL_RESEND_SUCCESS, + variant: VARIANT_SUCCESS, + }); + } else if (response.data.status === FAILURE_RESPONSE) { + createAlert({ message: response.data.message }); + } + }, + handleError(error) { + createAlert({ + message: I18N_GENERIC_ERROR, + captureError: true, + error, + }); + }, + resetForm() { + this.verificationCode = ''; + this.submitted = false; + this.$refs.input.$el.focus(); + }, + updateEmail() { + this.showUpdateEmail = true; + }, + verifyToken(email = '') { + this.showUpdateEmail = false; + if (email.length) this.email = email; + this.$nextTick(this.resetForm); + }, + }, + i18n: { + explanation: I18N_EXPLANATION, + inputLabel: I18N_INPUT_LABEL, + submitButton: I18N_SUBMIT_BUTTON, + resendLink: I18N_RESEND_LINK, + updateEmail: I18N_UPDATE_EMAIL, + }, +}; +</script> + +<template> + <div> + <update-email + v-if="showUpdateEmail" + :update-email-path="updateEmailPath" + @verifyToken="verifyToken" + /> + <gl-form v-else @submit.prevent="verify"> + <section class="gl-mb-5"> + <gl-sprintf :message="$options.i18n.explanation"> + <template #email> + <strong>{{ email }}</strong> + </template> + </gl-sprintf> + </section> + <gl-form-group + :label="$options.i18n.inputLabel" + label-for="verification-code" + :state="inputValidation.state" + :invalid-feedback="inputValidation.message" + > + <gl-form-input + id="verification-code" + ref="input" + v-model="verificationCode" + autofocus + autocomplete="one-time-code" + inputmode="numeric" + maxlength="6" + :state="inputValidation.state" + /> + </gl-form-group> + <section class="gl-mt-5"> + <gl-button block variant="confirm" type="submit" :disabled="!inputValidation.state">{{ + $options.i18n.submitButton + }}</gl-button> + <gl-button block variant="link" class="gl-mt-3 gl-h-7" @click="resend">{{ + $options.i18n.resendLink + }}</gl-button> + <gl-button + v-if="isOfferEmailReset" + block + variant="link" + class="gl-mt-3 gl-h-7" + @click="updateEmail" + >{{ $options.i18n.updateEmail }}</gl-button + > + </section> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/sessions/new/components/update_email.vue b/app/assets/javascripts/sessions/new/components/update_email.vue new file mode 100644 index 00000000000..124cd671169 --- /dev/null +++ b/app/assets/javascripts/sessions/new/components/update_email.vue @@ -0,0 +1,133 @@ +<script> +import { GlForm, GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { + I18N_EMAIL, + I18N_UPDATE_EMAIL, + I18N_UPDATE_EMAIL_GUIDANCE, + I18N_CANCEL, + I18N_EMAIL_INVALID, + I18N_UPDATE_EMAIL_SUCCESS, + I18N_GENERIC_ERROR, + EMAIL_REGEXP, + SUCCESS_RESPONSE, + FAILURE_RESPONSE, +} from '../constants'; + +export default { + name: 'UpdateEmail', + components: { + GlForm, + GlFormGroup, + GlFormInput, + GlButton, + }, + props: { + updateEmailPath: { + type: String, + required: true, + }, + }, + data() { + return { + email: '', + submitted: false, + verifyError: '', + }; + }, + computed: { + inputValidation() { + return { + state: !(this.submitted && this.invalidFeedback), + message: this.invalidFeedback, + }; + }, + invalidFeedback() { + if (!this.submitted) { + return ''; + } + + if (!EMAIL_REGEXP.test(this.email)) { + return I18N_EMAIL_INVALID; + } + + return this.verifyError; + }, + }, + watch: { + email() { + this.verifyError = ''; + }, + }, + methods: { + updateEmail() { + this.submitted = true; + + if (!this.inputValidation.state) return; + + axios + .patch(this.updateEmailPath, { user: { email: this.email } }) + .then(this.handleResponse) + .catch(this.handleError); + }, + handleResponse(response) { + if (response.data.status === undefined) { + this.handleError(); + } else if (response.data.status === SUCCESS_RESPONSE) { + this.handleSuccess(); + } else if (response.data.status === FAILURE_RESPONSE) { + this.verifyError = response.data.message; + } + }, + handleSuccess() { + createAlert({ + message: I18N_UPDATE_EMAIL_SUCCESS, + variant: VARIANT_SUCCESS, + }); + this.$emit('verifyToken', this.email); + }, + handleError(error) { + createAlert({ + message: I18N_GENERIC_ERROR, + captureError: true, + error, + }); + }, + }, + i18n: { + email: I18N_EMAIL, + updateEmail: I18N_UPDATE_EMAIL, + cancel: I18N_CANCEL, + guidance: I18N_UPDATE_EMAIL_GUIDANCE, + }, +}; +</script> + +<template> + <gl-form novalidate @submit.prevent="updateEmail"> + <gl-form-group + :label="$options.i18n.email" + label-for="update-email" + :state="inputValidation.state" + :invalid-feedback="inputValidation.message" + > + <gl-form-input + id="update-email" + v-model="email" + type="email" + autofocus + :state="inputValidation.state" + /> + <p class="gl-mt-3 gl-text-secondary">{{ $options.i18n.guidance }}</p> + </gl-form-group> + <section class="gl-mt-5"> + <gl-button block variant="confirm" type="submit" :disabled="!inputValidation.state">{{ + $options.i18n.updateEmail + }}</gl-button> + <gl-button block variant="link" class="gl-mt-3 gl-h-7" @click="$emit('verifyToken')">{{ + $options.i18n.cancel + }}</gl-button> + </section> + </gl-form> +</template> diff --git a/app/assets/javascripts/sessions/new/constants.js b/app/assets/javascripts/sessions/new/constants.js new file mode 100644 index 00000000000..e9bd26099aa --- /dev/null +++ b/app/assets/javascripts/sessions/new/constants.js @@ -0,0 +1,30 @@ +import { s__, __ } from '~/locale'; + +export const I18N_EXPLANATION = s__( + "IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}", +); +export const I18N_INPUT_LABEL = s__('IdentityVerification|Verification code'); +export const I18N_EMAIL_EMPTY_CODE = s__('IdentityVerification|Enter a code.'); +export const I18N_EMAIL_INVALID_CODE = s__('IdentityVerification|Please enter a valid code'); +export const I18N_SUBMIT_BUTTON = s__('IdentityVerification|Verify code'); +export const I18N_RESEND_LINK = s__('IdentityVerification|Resend code'); +export const I18N_EMAIL_RESEND_SUCCESS = s__('IdentityVerification|A new code has been sent.'); +export const I18N_GENERIC_ERROR = s__( + 'IdentityVerification|Something went wrong. Please try again.', +); + +export const I18N_EMAIL = __('Email'); +export const I18N_UPDATE_EMAIL = s__('IdentityVerification|Update email'); +export const I18N_UPDATE_EMAIL_GUIDANCE = s__( + "EmailVerification|Update your email to a valid permanent address. If you use a temporary email, you won't be able to sign in later.", +); +export const I18N_CANCEL = __('Cancel'); +export const I18N_EMAIL_INVALID = s__('IdentityVerification|Please enter a valid email address.'); +export const I18N_UPDATE_EMAIL_SUCCESS = s__( + 'IdentityVerification|A new code has been sent to your updated email address.', +); + +export const VERIFICATION_CODE_REGEX = /^\d{6}$/; +export const EMAIL_REGEXP = /^[^@\s]+@[^@\s]+$/; // Taken from DeviseEmailValidator +export const SUCCESS_RESPONSE = 'success'; +export const FAILURE_RESPONSE = 'failure'; diff --git a/app/assets/javascripts/sessions/new/index.js b/app/assets/javascripts/sessions/new/index.js new file mode 100644 index 00000000000..bf126b0e202 --- /dev/null +++ b/app/assets/javascripts/sessions/new/index.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import EmailVerification from './components/email_verification.vue'; + +export default () => { + const el = document.querySelector('.js-email-verification'); + + if (!el) { + return null; + } + + const { obfuscatedEmail, verifyPath, resendPath, offerEmailReset, updateEmailPath } = el.dataset; + + return new Vue({ + el, + name: 'EmailVerificationRoot', + render(createElement) { + return createElement(EmailVerification, { + props: { + obfuscatedEmail, + verifyPath, + resendPath, + isOfferEmailReset: parseBoolean(offerEmailReset), + updateEmailPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue index c96189c7cae..60ed0d073fe 100644 --- a/app/assets/javascripts/set_status_modal/set_status_form.vue +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -6,8 +6,7 @@ import { GlFormCheckbox, GlFormInput, GlFormInputGroup, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlFormGroup, } from '@gitlab/ui'; import $ from 'jquery'; @@ -25,8 +24,7 @@ export default { GlFormCheckbox, GlFormInput, GlFormInputGroup, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlFormGroup, EmojiPicker: () => import('~/emoji/components/picker.vue'), }, @@ -79,6 +77,9 @@ export default { noEmoji() { return this.emojiTag === ''; }, + clearStatusAfterValue() { + return this.clearStatusAfter?.name; + }, clearStatusAfterDropdownText() { if (this.clearStatusAfter === null && this.currentClearStatusAfter.length) { return this.formatClearStatusAfterDate(new Date(this.currentClearStatusAfter)); @@ -94,11 +95,18 @@ export default { return NEVER_TIME_RANGE.label; }, + clearStatusAfterDropdownItems() { + return TIME_RANGES_WITH_NEVER.map((item) => ({ text: item.label, value: item.name })); + }, }, mounted() { this.setupEmojiListAndAutocomplete(); }, methods: { + onClearStatusAfterValueChange(value) { + const selectedValue = TIME_RANGES_WITH_NEVER.find((i) => i.name === value); + this.$emit('clear-status-after-click', selectedValue); + }, async setupEmojiListAndAutocomplete() { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true }); @@ -221,20 +229,13 @@ export default { </gl-form-checkbox> <gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0"> - <gl-dropdown - block - :text="clearStatusAfterDropdownText" + <gl-collapsible-listbox + :selected="clearStatusAfterValue" + :toggle-text="clearStatusAfterDropdownText" + :items="clearStatusAfterDropdownItems" data-testid="clear-status-at-dropdown" - toggle-class="gl-mb-0 gl-form-input-md" - > - <gl-dropdown-item - v-for="after in $options.TIME_RANGES_WITH_NEVER" - :key="after.name" - :data-testid="after.name" - @click="$emit('clear-status-after-click', after)" - >{{ after.label }}</gl-dropdown-item - > - </gl-dropdown> + @select="onClearStatusAfterValueChange" + /> </gl-form-group> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 5cdebee04ad..9d6a8bf47e0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { TYPE_ISSUE } from '~/issues/constants'; import CollapsedAssigneeList from './collapsed_assignee_list.vue'; @@ -48,7 +49,7 @@ export default { <div> <collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" /> - <div data-testid="expanded-assignee" class="value hide-collapsed"> + <div class="value hide-collapsed"> <span v-if="hasNoUsers" class="no-value" data-testid="no-value"> {{ __('None') }} <template v-if="editable"> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue b/app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue new file mode 100644 index 00000000000..d1463bb813a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/confidential/confidentiality_dropdown.vue @@ -0,0 +1,55 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlCollapsibleListbox, + }, + data() { + return { + value: null, + }; + }, + i18n: { + defaultDropdownText: __('Select confidentiality'), + headerText: __('Change confidentiality'), + resetText: __('Reset'), + }, + computed: { + toggleText() { + return this.value ? null : this.$options.i18n.defaultDropdownText; + }, + }, + methods: { + handleReset() { + this.value = null; + }, + }, + dropdownOptions: [ + { + text: __('Confidential'), + value: 'true', + }, + { + text: __('Not confidential'), + value: 'false', + }, + ], +}; +</script> + +<template> + <div> + <input type="hidden" name="update[confidentiality]" :value="value" /> + <gl-collapsible-listbox + v-model="value" + block + :header-text="$options.i18n.headerText" + :reset-button-label="$options.i18n.resetText" + :toggle-text="toggleText" + :items="$options.dropdownOptions" + @reset="handleReset" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue index 72a572087c7..8203dce67cd 100644 --- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { INCIDENTS_I18N as i18n, STATUS_ACKNOWLEDGED, @@ -14,8 +14,7 @@ export default { i18n, STATUS_LIST, components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, props: { value: { @@ -26,52 +25,64 @@ export default { return [...STATUS_LIST, null].includes(value); }, }, - preventDropdownClose: { - type: Boolean, + headerText: { + type: String, required: false, - default: false, + default: null, + }, + statusSubtexts: { + type: Object, + required: false, + default() { + return {}; + }, }, }, + data() { + return { + selected: this.value, + }; + }, computed: { + statusDropdownOptions() { + return this.$options.STATUS_LIST.map((status) => ({ + text: this.getStatusLabel(status), + subtext: this.statusSubtexts[status], + value: status, + })); + }, currentStatusLabel() { return this.getStatusLabel(this.value); }, }, + methods: { show() { - this.$refs.dropdown.show(); + this.$refs.dropdown.open(); }, hide() { - this.$refs.dropdown.hide(); + this.$refs.dropdown.close(); }, getStatusLabel, - hideDropdown(event) { - if (this.preventDropdownClose) { - event.preventDefault(); - } - }, }, }; </script> <template> - <gl-dropdown + <gl-collapsible-listbox ref="dropdown" + v-model="selected" + :header-text="headerText" block - :text="currentStatusLabel" + :toggle-text="currentStatusLabel" + :items="statusDropdownOptions" toggle-class="dropdown-menu-toggle gl-mb-2" - @hide="hideDropdown" + data-testid="escalation-status-dropdown" + @select="$emit('input', selected)" > - <slot name="header"> </slot> - <gl-dropdown-item - v-for="status in $options.STATUS_LIST" - :key="status" - data-testid="status-dropdown-item" - is-check-item - :is-checked="status === value" - @click="$emit('input', status)" - > - {{ getStatusLabel(status) }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item="{ item }"> + <span class="gl-display-block">{{ item.text }}</span> + <span v-if="item.subtext" class="gl-font-sm gl-text-gray-500">{{ item.subtext }}</span> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue index 864d9b308e7..33299ab56e0 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue index 1c27df2418d..86c544ec52a 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue index 8535398decf..1d4a1601a27 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue index 3db962c7fe8..3e4297887f0 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue @@ -7,6 +7,7 @@ import { GlLink, } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue index 1e9edd222c5..50fcd3c9350 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue index 583f060be8a..5ca18969f0b 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue @@ -1,6 +1,7 @@ <script> import { GlLabel } from '@gitlab/ui'; import { sortBy } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import { isScopedLabel } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue index 74e47b333ef..af4215b663c 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue @@ -1,5 +1,6 @@ <script> import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; 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 1d8b21700c3..19fe78aca87 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 @@ -1,5 +1,5 @@ <script> -import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; +import { GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -9,7 +9,6 @@ import LabelItem from './label_item.vue'; export default { components: { - GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver, @@ -142,7 +141,7 @@ export default { <template> <gl-intersection-observer @appear="onDropdownAppear"> - <gl-dropdown-form class="labels-select-contents-list js-labels-list"> + <div class="js-labels-list"> <div ref="labelsListContainer" data-testid="dropdown-content"> <gl-loading-icon v-if="labelsFetchInProgress" @@ -171,6 +170,6 @@ export default { </gl-dropdown-item> </template> </div> - </gl-dropdown-form> + </div> </gl-intersection-observer> </template> 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 72567b7d4a4..74c3f08a47b 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 @@ -412,6 +412,7 @@ export default { :workspace-type="workspaceType" :attr-workspace-path="attrWorkspacePath" :label-create-type="labelCreateType" + class="gl-mt-3" @setLabels="handleDropdownClose" @closeDropdown="collapseEditableItem" /> @@ -421,8 +422,8 @@ export default { <template v-else> <dropdown-contents ref="dropdownContents" - :allow-multiselect="allowMultiselect" :dropdown-button-text="dropdownButtonText" + :allow-multiselect="allowMultiselect" :labels-list-title="labelsListTitle" :footer-create-label-title="footerCreateLabelTitle" :footer-manage-label-title="footerManageLabelTitle" diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index 606d374158b..fa6ae8f6a1b 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,6 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import $ from 'jquery'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { createAlert } from '~/alert'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 1ea8ab19012..165499696de 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -6,6 +6,7 @@ import { GlTooltipDirective, GlOutsideDirective as Outside, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions } from 'vuex'; import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index bad73273409..7b288e15a3e 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index bd1d9fbff0c..a3282932f84 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue index 776dab98f01..bf1a67d86a1 100644 --- a/app/assets/javascripts/sidebar/components/severity/severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/severity.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index c0424dc2873..b13f594603b 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js index 56e986e3b27..ddfbf5ab2a6 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/constants.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js @@ -1 +1,2 @@ export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal'; +export const SET_TIME_ESTIMATE_MODAL_ID = 'set-time-estimate-modal'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 109e1af85ec..70d8024f46a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue new file mode 100644 index 00000000000..44c5896d658 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue @@ -0,0 +1,215 @@ +<script> +import { GlFormGroup, GlFormInput, GlModal, GlAlert, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; +import { s__, __, sprintf } from '~/locale'; +import issueSetTimeEstimateMutation from '../../queries/issue_set_time_estimate.mutation.graphql'; +import mergeRequestSetTimeEstimateMutation from '../../queries/merge_request_set_time_estimate.mutation.graphql'; +import { SET_TIME_ESTIMATE_MODAL_ID } from './constants'; + +const MUTATIONS = { + [TYPE_ISSUE]: issueSetTimeEstimateMutation, + [TYPE_MERGE_REQUEST]: mergeRequestSetTimeEstimateMutation, +}; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlModal, + GlAlert, + GlLink, + }, + inject: ['issuableType'], + props: { + fullPath: { + type: String, + required: true, + }, + issuableIid: { + type: String, + required: true, + }, + /** + * This object must contain the following keys, used to show + * the initial time estimate in the form: + * - timeEstimate: the time estimate numeric value + * - humanTimeEstimate: the time estimate in human readable format + */ + timeTracking: { + type: Object, + required: true, + }, + }, + data() { + return { + currentEstimate: this.timeTracking.timeEstimate ?? 0, + timeEstimate: this.timeTracking.humanTimeEstimate ?? '0h', + isSaving: false, + isResetting: false, + saveError: '', + }; + }, + computed: { + submitDisabled() { + return this.isSaving || this.isResetting || this.timeEstimate === ''; + }, + resetDisabled() { + return this.isSaving || this.isResetting || this.currentEstimate === 0; + }, + primaryProps() { + return { + text: __('Save'), + attributes: { + variant: 'confirm', + disabled: this.submitDisabled, + loading: this.isSaving, + }, + }; + }, + secondaryProps() { + return this.currentEstimate === 0 + ? null + : { + text: __('Remove'), + attributes: { + disabled: this.resetDisabled, + loading: this.isResetting, + }, + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + timeTrackingDocsPath() { + return helpPagePath('user/project/time_tracking.md'); + }, + modalTitle() { + return this.currentEstimate === 0 + ? s__('TimeTracking|Set time estimate') + : s__('TimeTracking|Edit time estimate'); + }, + isIssue() { + return this.issuableType === TYPE_ISSUE; + }, + modalText() { + return sprintf(s__('TimeTracking|Set estimated time to complete this %{issuableTypeName}.'), { + issuableTypeName: this.isIssue ? __('issue') : __('merge request'), + }); + }, + }, + watch: { + timeTracking() { + this.currentEstimate = this.timeTracking.timeEstimate ?? 0; + this.timeEstimate = this.timeTracking.humanTimeEstimate ?? '0h'; + }, + }, + methods: { + resetModal() { + this.isSaving = false; + this.isResetting = false; + this.saveError = ''; + }, + close() { + this.$refs.modal.close(); + }, + saveTimeEstimate(event) { + event?.preventDefault(); + + if (this.timeEstimate === '') { + return; + } + + this.isSaving = true; + this.updateEstimatedTime(this.timeEstimate); + }, + resetTimeEstimate() { + this.isResetting = true; + this.updateEstimatedTime('0'); + }, + updateEstimatedTime(timeEstimate) { + this.saveError = ''; + + this.$apollo + .mutate({ + mutation: MUTATIONS[this.issuableType], + variables: { + input: { + projectPath: this.fullPath, + iid: this.issuableIid, + timeEstimate, + }, + }, + }) + .then(({ data }) => { + if (data.issuableSetTimeEstimate?.errors.length) { + this.saveError = + data.issuableSetTimeEstimate.errors[0].message || + data.issuableSetTimeEstimate.errors[0]; + } else { + this.close(); + } + }) + .catch((error) => { + this.saveError = + error?.message || s__('TimeTracking|An error occurred while saving the time estimate.'); + }) + .finally(() => { + this.isSaving = false; + this.isResetting = false; + }); + }, + }, + SET_TIME_ESTIMATE_MODAL_ID, +}; +</script> + +<template> + <gl-modal + ref="modal" + :title="modalTitle" + :modal-id="$options.SET_TIME_ESTIMATE_MODAL_ID" + size="sm" + data-testid="set-time-estimate-modal" + :action-primary="primaryProps" + :action-secondary="secondaryProps" + :action-cancel="cancelProps" + @hidden="resetModal" + @primary.prevent="saveTimeEstimate" + @secondary.prevent="resetTimeEstimate" + @cancel="close" + > + <p data-testid="timetracking-docs-link"> + {{ modalText }} + + <gl-link :href="timeTrackingDocsPath">{{ + s__('TimeTracking|How do I estimate and track time?') + }}</gl-link> + </p> + <form class="js-quick-submit" @submit.prevent="saveTimeEstimate"> + <gl-form-group + label-for="time-estimate" + :label="s__('TimeTracking|Estimate')" + :description=" + s__( + `TimeTracking|Enter time as a total duration (for example, 1mo 2w 3d 5h 10m), or specify hours and minutes (for example, 75:30).`, + ) + " + > + <gl-form-input + id="time-estimate" + v-model="timeEstimate" + data-testid="time-estimate" + autocomplete="off" + /> + </gl-form-group> + <gl-alert v-if="saveError" variant="danger" class="gl-mt-5" :dismissible="false"> + {{ saveError }} + </gl-alert> + <!-- This is needed to have the quick-submit behaviour (with Ctrl + Enter or Cmd + Enter) --> + <input type="submit" hidden /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 06adc048942..54f10cac075 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -35,6 +35,11 @@ export default { required: false, default: true, }, + canSetTimeEstimate: { + type: Boolean, + required: false, + default: false, + }, }, mounted() { this.listenForQuickActions(); @@ -73,6 +78,7 @@ export default { :issuable-iid="issuableIid" :limit-to-hours="limitToHours" :can-add-time-entries="canAddTimeEntries" + :can-set-time-estimate="canSetTimeEstimate" /> </div> </template> 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 f6968558122..1d427a871e1 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -18,8 +18,9 @@ import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; -import { CREATE_TIMELOG_MODAL_ID } from './constants'; +import { CREATE_TIMELOG_MODAL_ID, SET_TIME_ESTIMATE_MODAL_ID } from './constants'; import CreateTimelogForm from './create_timelog_form.vue'; +import SetTimeEstimateForm from './set_time_estimate_form.vue'; export default { name: 'IssuableTimeTracker', @@ -38,6 +39,7 @@ export default { TimeTrackingComparisonPane, TimeTrackingReport, CreateTimelogForm, + SetTimeEstimateForm, }, directives: { GlModal: GlModalDirective, @@ -94,6 +96,11 @@ export default { required: false, default: true, }, + canSetTimeEstimate: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -181,6 +188,11 @@ export default { timeTrackingIconName() { return this.showHelpState ? 'close' : 'question-o'; }, + timeEstimateTooltip() { + return this.hasTimeEstimate + ? s__('TimeTracking|Edit estimate') + : s__('TimeTracking|Set estimate'); + }, }, watch: { /** @@ -203,6 +215,7 @@ export default { this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID); }, }, + setTimeEstimateModalId: SET_TIME_ESTIMATE_MODAL_ID, }; </script> @@ -223,18 +236,31 @@ export default { > {{ __('Time tracking') }} <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline /> - <gl-button - v-if="canAddTimeEntries" - v-gl-tooltip.left - category="tertiary" - size="small" - class="gl-ml-auto" - data-testid="add-time-entry-button" - :title="__('Add time entry')" - @click="openRegisterTimeSpentModal()" - > - <gl-icon name="plus" class="gl-text-gray-900!" /> - </gl-button> + <div v-if="canSetTimeEstimate || canAddTimeEntries" class="gl-ml-auto gl-display-flex"> + <gl-button + v-if="canSetTimeEstimate" + v-gl-modal="$options.setTimeEstimateModalId" + v-gl-tooltip.top + category="tertiary" + size="small" + data-testid="set-time-estimate-button" + :title="timeEstimateTooltip" + :aria-label="timeEstimateTooltip" + > + <gl-icon name="timer" class="gl-text-gray-900!" /> + </gl-button> + <gl-button + v-if="canAddTimeEntries" + v-gl-tooltip.top + category="tertiary" + size="small" + data-testid="add-time-entry-button" + :title="__('Add time entry')" + @click="openRegisterTimeSpentModal()" + > + <gl-icon name="plus" class="gl-text-gray-900!" /> + </gl-button> + </div> </div> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> <div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane"> @@ -255,10 +281,11 @@ export default { :time-estimate-human-readable="humanTimeEstimate" :limit-to-hours="limitToHours" /> - <template v-if="isTimeReportSupported"> + <div v-if="isTimeReportSupported"> <gl-link v-if="hasTotalTimeSpent" v-gl-modal="'time-tracking-report'" + class="gl-text-black-normal" data-testid="reportLink" href="#" > @@ -272,8 +299,13 @@ export default { > <time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" /> </gl-modal> - </template> + </div> <create-timelog-form :issuable-id="issuableId" /> + <set-time-estimate-form + :full-path="fullPath" + :issuable-iid="issuableIid" + :time-tracking="timeTracking" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index d5782e4b371..2c8c23c1152 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index b0060e4c28d..cb6d503d6ef 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -36,6 +36,7 @@ export default class SidebarMilestone { humanTotalTimeSpent: humanTimeSpent, }, canAddTimeEntries: false, + canSetTimeEstimate: false, }, }), }); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 8f6b855ecd6..1f3119e14db 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -42,6 +42,7 @@ import { IssuableAttributeType } from './constants'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; import trackShowInviteMemberLink from './track_invite_members'; import MoveIssueButton from './components/move/move_issue_button.vue'; +import ConfidentialityDropdown from './components/confidential/confidentiality_dropdown.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -545,6 +546,7 @@ function mountSidebarTimeTracking() { issuableType, timeTrackingLimitToHours, canCreateTimelogs, + editable, } = getSidebarOptions(); if (!el) { @@ -564,6 +566,7 @@ function mountSidebarTimeTracking() { issuableIid: iid.toString(), limitToHours: timeTrackingLimitToHours, canAddTimeEntries: canCreateTimelogs, + canSetTimeEstimate: parseBoolean(editable), }, }), }); @@ -694,6 +697,20 @@ export function mountSubscriptionsDropdown() { }); } +export function mountConfidentialityDropdown() { + const el = document.querySelector('.js-confidentiality-dropdown'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'ConfidentialityDropdownRoot', + render: (createElement) => createElement(ConfidentialityDropdown), + }); +} + export function mountMoveIssueButton() { const el = document.querySelector('.js-sidebar-move-issue-block'); diff --git a/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql b/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql new file mode 100644 index 00000000000..3e3ebb3869e --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql @@ -0,0 +1,10 @@ +mutation issueSetTimeEstimate($input: UpdateIssueInput!) { + issuableSetTimeEstimate: updateIssue(input: $input) { + errors + issuable: issue { + id + humanTimeEstimate + timeEstimate + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql b/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql new file mode 100644 index 00000000000..398b3b1c520 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql @@ -0,0 +1,10 @@ +mutation mergeRequestSetTimeEstimate($input: MergeRequestUpdateInput!) { + issuableSetTimeEstimate: mergeRequestUpdate(input: $input) { + errors + issuable: mergeRequest { + id + humanTimeEstimate + timeEstimate + } + } +} diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 5e2f194e133..9e80210de51 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui'; @@ -261,7 +262,6 @@ export default { type="submit" variant="confirm" data-qa-selector="submit_button" - data-testid="snippet-submit-btn" :disabled="isUpdating" >{{ saveButtonLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 074c5fda29b..549b1bdd209 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import eventHub from '~/blob/components/eventhub'; diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue index 66381e4da4d..1589f4978e1 100644 --- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue +++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue @@ -26,7 +26,7 @@ export default { <template> <a - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" + v-gl-tooltip:super-sidebar.hover.noninteractive.bottom.ds500="$options.i18n.homepage" class="brand-logo" :href="rootPath" :title="$options.i18n.homepage" diff --git a/app/assets/javascripts/super_sidebar/components/context_header.vue b/app/assets/javascripts/super_sidebar/components/context_header.vue new file mode 100644 index 00000000000..11b9840a409 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/context_header.vue @@ -0,0 +1,56 @@ +<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 index c5f3410a68f..d4aa11b6e04 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -64,11 +64,8 @@ export default { ProjectsList, GroupsList, }, + inject: ['contextSwitcherLinks'], props: { - persistentLinks: { - type: Array, - required: true, - }, username: { type: String, required: true, @@ -177,7 +174,7 @@ export default { <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-qa-selector="context_navigation"> + <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 @@ -185,7 +182,7 @@ export default { class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2" > <nav-item - v-for="item in persistentLinks" + v-for="item in contextSwitcherLinks" :key="item.link" :item="item" :link-classes="{ [item.link_classes]: item.link_classes }" diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue index 17227a2b123..faa7eba6470 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -1,11 +1,11 @@ <script> -import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; +import ContextHeader from './context_header.vue'; export default { components: { - GlTruncate, - GlAvatar, GlIcon, + ContextHeader, }, props: { /* @@ -24,39 +24,20 @@ export default { collapseIcon() { return this.expanded ? 'chevron-up' : 'chevron-down'; }, - avatarShape() { - return this.context.avatar_shape || 'rect'; - }, }, }; </script> <template> - <button + <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 border-top border-bottom gl-border-gray-a-08! gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8 gl-flex-shrink-0" - data-qa-selector="context_switcher" + 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" > - <span - v-if="context.icon" - class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4" - > - <gl-icon 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" - class="gl-mr-3 gl-ml-4" - /> - <div class="gl-overflow-auto gl-text-gray-900"> - <gl-truncate :text="context.title" /> - </div> - <span class="gl-flex-grow-1 gl-text-right gl-mr-4"> + <template #end> <gl-icon class="gl-text-gray-400" :name="collapseIcon" /> - </span> - </button> + </template> + </context-header> </template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index a6f19ff95f3..c0e1959fba4 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; import { highCountTrim } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index 82f4fd18e80..3645606515f 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -95,6 +95,7 @@ export default { :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar" + noninteractive > {{ $options.i18n.createNew }} </gl-tooltip> diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue new file mode 100644 index 00000000000..fa7960da2f4 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue @@ -0,0 +1,65 @@ +<script> +import { computePosition, autoUpdate, offset, flip, shift } from '@floating-ui/dom'; +import NavItem from './nav_item.vue'; + +export default { + name: 'FlyoutMenu', + components: { NavItem }, + props: { + targetId: { + type: String, + required: true, + }, + items: { + type: Array, + required: true, + }, + }, + cleanupFunction: undefined, + mounted() { + const target = document.querySelector(`#${this.targetId}`); + const flyout = document.querySelector(`#${this.targetId}-flyout`); + + function updatePosition() { + return computePosition(target, flyout, { + middleware: [offset({ alignmentAxis: -12 }), flip(), shift()], + placement: 'right-start', + strategy: 'fixed', + }).then(({ x, y }) => { + Object.assign(flyout.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + } + + this.$options.cleanupFunction = autoUpdate(target, flyout, updatePosition); + }, + beforeUnmount() { + this.$options.cleanupFunction(); + }, +}; +</script> + +<template> + <div + :id="`${targetId}-flyout`" + class="gl-fixed gl-p-4 gl-mx-n1 gl-z-index-9999 gl-max-h-full gl-overflow-y-auto" + @mouseover="$emit('mouseover')" + @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" + > + <nav-item + v-for="item of items" + :key="item.id" + :item="item" + :is-flyout="true" + @pin-add="(itemId) => $emit('pin-add', itemId)" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue index 342e1284e86..fe1a907bd91 100644 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue @@ -1,9 +1,11 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import AccessorUtilities from '~/lib/utils/accessor'; import { __ } from '~/locale'; -import { getTopFrequentItems, formatContextSwitcherItems } from '../utils'; +import { + getItemsFromLocalStorage, + removeItemFromLocalStorage, + formatContextSwitcherItems, +} from '../utils'; import ItemsList from './items_list.vue'; export default { @@ -43,35 +45,21 @@ export default { }, }, created() { - this.getItemsFromLocalStorage(); + this.cachedFrequentItems = formatContextSwitcherItems( + getItemsFromLocalStorage({ + storageKey: this.storageKey, + maxItems: this.maxItems, + }), + ); }, methods: { - getItemsFromLocalStorage() { - if (!AccessorUtilities.canUseLocalStorage()) { - return; - } - try { - const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey)); - const topFrequentItems = getTopFrequentItems(parsedCachedFrequentItems, this.maxItems); - this.cachedFrequentItems = formatContextSwitcherItems(topFrequentItems); - } catch (e) { - Sentry.captureException(e); - } - }, handleItemRemove(item) { - try { - // Remove item from local storage - const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey)); - localStorage.setItem( - this.storageKey, - JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)), - ); + removeItemFromLocalStorage({ + storageKey: this.storageKey, + item, + }); - // Update the list - this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); - } catch (e) { - Sentry.captureException(e); - } + this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); }, }, i18n: { @@ -103,6 +91,7 @@ export default { 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" 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 a1d0e400b5f..bd79962f1a1 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 @@ -133,6 +133,12 @@ export default { }, immediate: true, }, + handle: { + handler() { + this.debouncedSearch(); + }, + immediate: false, + }, }, updated() { this.$emit('updated'); @@ -180,7 +186,7 @@ export default { } }, async getScopedItems() { - if (this.searchQuery && this.searchQuery.length < 3) return; + if (this.searchQuery?.length < 3) return; this.loading = true; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue index efd93e88fa9..28e50dceb48 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue @@ -36,7 +36,7 @@ export default { <style scoped> .fake-input { - top: 12px; - left: 33px; + top: 18px; + left: 39px; } </style> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue new file mode 100644 index 00000000000..6f0a0a1fe79 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue @@ -0,0 +1,40 @@ +<script> +import { s__ } from '~/locale'; +import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants'; +import FrequentItems from './frequent_items.vue'; + +export default { + name: 'FrequentlyVisitedGroups', + components: { + FrequentItems, + }, + inject: ['groupsPath'], + data() { + const username = gon.current_username; + + return { + storageKey: username ? `${username}/frequent-groups` : null, + }; + }, + i18n: { + groupName: s__('Navigation|Frequently visited groups'), + viewAllText: s__('Navigation|View all my groups'), + emptyStateText: s__('Navigation|Groups you visit often will appear here.'), + }, + MAX_FREQUENT_GROUPS_COUNT, +}; +</script> + +<template> + <frequent-items + :empty-state-text="$options.i18n.emptyStateText" + :group-name="$options.i18n.groupName" + :max-items="$options.MAX_FREQUENT_GROUPS_COUNT" + :storage-key="storageKey" + view-all-items-icon="group" + :view-all-items-text="$options.i18n.viewAllText" + :view-all-items-path="groupsPath" + v-bind="$attrs" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue new file mode 100644 index 00000000000..5371887ee0f --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue @@ -0,0 +1,64 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import { __ } from '~/locale'; + +export default { + name: 'FrequentlyVisitedItem', + components: { + GlButton, + ProjectAvatar, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + item: { + type: Object, + required: true, + }, + }, + methods: { + onRemove() { + this.$emit('remove', this.item); + }, + }, + i18n: { + remove: __('Remove'), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-gap-3"> + <project-avatar + :project-id="item.id" + :project-name="item.title" + :project-avatar-url="item.avatar" + :size="24" + aria-hidden="true" + /> + + <div class="gl-flex-grow-1 gl-truncate-end"> + {{ item.title }} + <div + v-if="item.subtitle" + data-testid="subtitle" + class="gl-font-sm gl-text-gray-500 gl-truncate-end" + > + {{ item.subtitle }} + </div> + </div> + + <gl-button + v-gl-tooltip.left + icon="dash" + category="tertiary" + :aria-label="$options.i18n.remove" + :title="$options.i18n.remove" + class="show-on-focus-or-hover--target" + @click.stop.prevent="onRemove" + @keydown.enter.stop.prevent="onRemove" + /> + </div> +</template> 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 new file mode 100644 index 00000000000..382d844ceee --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue @@ -0,0 +1,133 @@ +<script> +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; +import FrequentItem from './frequent_item.vue'; + +export default { + name: 'FrequentlyVisitedItems', + components: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlIcon, + FrequentItem, + }, + props: { + emptyStateText: { + type: String, + required: true, + }, + groupName: { + type: String, + required: true, + }, + maxItems: { + type: Number, + required: true, + }, + storageKey: { + type: String, + required: false, + default: null, + }, + viewAllItemsText: { + type: String, + required: true, + }, + viewAllItemsIcon: { + type: String, + required: true, + }, + viewAllItemsPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + items: getItemsFromLocalStorage({ + storageKey: this.storageKey, + maxItems: this.maxItems, + }), + }; + }, + computed: { + formattedItems() { + // Each item needs two different representations. One is for the + // GlDisclosureDropdownItem, and the other is for the FrequentItem + // renderer component inside it. + return this.items.map((item) => ({ + forDropdown: { + id: item.id, + + // The text field satsifies GlDisclosureDropdownItem's prop + // validator, and the href field ensures it renders a link. + text: item.name, + href: item.webUrl, + }, + forRenderer: { + id: item.id, + title: item.name, + subtitle: truncateNamespace(item.namespace), + avatar: item.avatarUrl, + }, + })); + }, + showEmptyState() { + return this.items.length === 0; + }, + viewAllItem() { + return { + text: this.viewAllItemsText, + href: this.viewAllItemsPath, + }; + }, + }, + created() { + if (!this.storageKey) { + this.$emit('nothing-to-render'); + } + }, + methods: { + removeItem(item) { + removeItemFromLocalStorage({ + storageKey: this.storageKey, + item, + }); + + this.items = this.items.filter((i) => i.id !== item.id); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-group v-if="storageKey" v-bind="$attrs"> + <template #group-label>{{ groupName }}</template> + + <gl-disclosure-dropdown-item + v-for="item of formattedItems" + :key="item.forDropdown.id" + :item="item.forDropdown" + class="show-on-focus-or-hover--context" + > + <template #list-item + ><frequent-item :item="item.forRenderer" @remove="removeItem" + /></template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item v-if="showEmptyState" class="gl-cursor-text"> + <span class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3">{{ emptyStateText }}</span> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item key="all" :item="viewAllItem"> + <template #list-item> + <span> + <gl-icon :name="viewAllItemsIcon" class="gl-w-6!" /> + {{ viewAllItemsText }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue new file mode 100644 index 00000000000..35b254099c2 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue @@ -0,0 +1,40 @@ +<script> +import { s__ } from '~/locale'; +import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants'; +import FrequentItems from './frequent_items.vue'; + +export default { + name: 'FrequentlyVisitedProjects', + components: { + FrequentItems, + }, + inject: ['projectsPath'], + data() { + const username = gon.current_username; + + return { + storageKey: username ? `${username}/frequent-projects` : null, + }; + }, + i18n: { + groupName: s__('Navigation|Frequently visited projects'), + viewAllText: s__('Navigation|View all my projects'), + emptyStateText: s__('Navigation|Projects you visit often will appear here.'), + }, + MAX_FREQUENT_PROJECTS_COUNT, +}; +</script> + +<template> + <frequent-items + :empty-state-text="$options.i18n.emptyStateText" + :group-name="$options.i18n.groupName" + :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT" + :storage-key="storageKey" + view-all-items-icon="project" + :view-all-items-text="$options.i18n.viewAllText" + :view-all-items-path="projectsPath" + v-bind="$attrs" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index bec8c191b31..b64f3ac52b2 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 @@ -8,6 +8,7 @@ import { GlResizeObserverDirective, GlModal, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { debounce, clamp } from 'lodash'; import { truncate } from '~/lib/utils/text_utility'; @@ -200,17 +201,21 @@ export default { const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); if (code === HOME_KEY) { + if (isSearchInput) return; + this.focusItem(0, elements); } else if (code === END_KEY) { + if (isSearchInput) return; + this.focusItem(elements.length - 1, elements); } else if (code === ARROW_UP_KEY) { if (isSearchInput) return; if (elements.indexOf(target) === 0) { this.focusSearchInput(); - return; + } else { + this.focusNextItem(event, elements, -1); } - this.focusNextItem(event, elements, -1); } else if (code === ARROW_DOWN_KEY) { this.focusNextItem(event, elements, 1); } else if (code === ESC_KEY) { @@ -290,10 +295,9 @@ export default { <form role="search" :aria-label="searchPlaceholder" - class="gl-relative gl-rounded-base gl-w-full" - data-testid="global-search-form" + class="gl-relative gl-rounded-base gl-w-full gl-pb-0" > - <div class="gl-p-1 gl-relative"> + <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3"> <gl-search-box-by-type id="search" ref="searchInput" @@ -346,8 +350,7 @@ export default { </span> <div ref="resultsList" - data-testid="global-search-results" - class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-3" @keydown="onKeydown" > <command-palette-items @@ -357,12 +360,11 @@ export default { @updated="highlightFirstCommand" /> + <global-search-default-items v-else-if="showDefaultItems" /> + <template v-else> - <global-search-default-items v-if="showDefaultItems" /> - <template v-else> - <global-search-scoped-items v-if="showScopedSearchItems" /> - <global-search-autocomplete-items /> - </template> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> </template> </div> <template v-if="searchContext"> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue index cd623200b03..23ea0af12fc 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; @@ -23,9 +24,6 @@ export default { computed: { ...mapState(['search', 'loading', 'autocompleteError']), ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), - isPrecededByScopedOptions() { - return this.scopedSearchOptions.length > 1; - }, }, methods: { highlightedName(val) { @@ -40,9 +38,9 @@ export default { <div> <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> <gl-disclosure-dropdown-group - v-for="group in autocompleteGroupedSearchOptions" + v-for="(group, index) in autocompleteGroupedSearchOptions" :key="group.name" - :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :class="{ 'gl-mt-0!': index === 0 }" :group="group" bordered > diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue new file mode 100644 index 00000000000..1b7b8268ee3 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_issuables.vue @@ -0,0 +1,45 @@ +<script> +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapGetters } from 'vuex'; +import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; + +export default { + name: 'DefaultIssuables', + i18n: { + ALL_GITLAB, + }, + components: { + GlDisclosureDropdownGroup, + }, + computed: { + ...mapState(['searchContext']), + ...mapGetters(['defaultSearchOptions']), + currentContextName() { + return ( + this.searchContext?.project?.name || + this.searchContext?.group?.name || + this.$options.i18n.ALL_GITLAB + ); + }, + shouldRender() { + return this.group.items.length > 0; + }, + group() { + return { + name: this.currentContextName, + items: this.defaultSearchOptions, + }; + }, + }, + created() { + if (!this.shouldRender) { + this.$emit('nothing-to-render'); + } + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-group v-if="shouldRender" v-bind="$attrs" :group="group" /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue index 239c61fd750..27935d92a5c 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue @@ -1,38 +1,53 @@ <script> -import { GlDisclosureDropdownGroup } from '@gitlab/ui'; -import { mapState, mapGetters } from 'vuex'; -import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; +import DefaultPlaces from './global_search_default_places.vue'; +import DefaultIssuables from './global_search_default_issuables.vue'; +import FrequentGroups from './frequent_groups.vue'; +import FrequentProjects from './frequent_projects.vue'; + +const components = [DefaultPlaces, FrequentProjects, FrequentGroups, DefaultIssuables]; export default { name: 'GlobalSearchDefaultItems', - i18n: { - ALL_GITLAB, - }, - components: { - GlDisclosureDropdownGroup, + data() { + return { + // The components here are expected to: + // - be responsible for getting their own data, + // - render a GlDisclosureDropdownGroup as the root vnode, + // - transparently pass all attrs to it (e.g., `bordered`), + // - not render anything if they have no data, + // - emit a `nothing-to-render` event if they have nothing to render. + // - have a unique `name` + componentNames: components.map(({ name }) => name), + }; }, - computed: { - ...mapState(['searchContext']), - ...mapGetters(['defaultSearchOptions']), - sectionHeader() { - return ( - this.searchContext?.project?.name || - this.searchContext?.group?.name || - this.$options.i18n.ALL_GITLAB - ); + methods: { + componentFromName(name) { + return components.find((component) => component.name === name); + }, + remove(nameToRemove) { + const indexToRemove = this.componentNames.findIndex((name) => name === nameToRemove); + if (indexToRemove !== -1) this.componentNames.splice(indexToRemove, 1); }, - defaultItemsGroup() { - return { - name: this.sectionHeader, - items: this.defaultSearchOptions, - }; + attrs(index) { + return index === 0 + ? null + : { + bordered: true, + class: 'gl-mt-3', + }; }, }, }; </script> <template> - <ul class="gl-p-0 gl-m-0 gl-list-style-none"> - <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" /> + <ul class="gl-p-0 gl-m-0 gl-pt-2 gl-list-style-none"> + <component + :is="componentFromName(name)" + v-for="(name, index) in componentNames" + :key="name" + v-bind="attrs(index)" + @nothing-to-render="remove(name)" + /> </ul> </template> 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 new file mode 100644 index 00000000000..9a375837102 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue @@ -0,0 +1,35 @@ +<script> +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { PLACES } from '~/vue_shared/global_search/constants'; + +export default { + name: 'DefaultPlaces', + i18n: { + PLACES, + }, + components: { + GlDisclosureDropdownGroup, + }, + inject: ['contextSwitcherLinks'], + computed: { + shouldRender() { + return this.contextSwitcherLinks.length > 0; + }, + group() { + return { + name: this.$options.i18n.PLACES, + items: this.contextSwitcherLinks.map(({ title, link }) => ({ text: title, href: link })), + }; + }, + }, + created() { + if (!this.shouldRender) { + this.$emit('nothing-to-render'); + } + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-group v-if="shouldRender" v-bind="$attrs" :group="group" /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue index 76600f829f6..1f5e7e45cc1 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js index 5a860fcd1ab..dc8fc4d2452 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -21,6 +21,6 @@ export const INPUT_FIELD_PADDING = 84; export const FETCH_TYPES = ['generic', 'search']; export const SEARCH_MODAL_ID = 'super-sidebar-search-modal'; -export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless'; +export const SEARCH_INPUT_SELECTOR = 'input[role="searchbox"]'; export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js index 4a42f416206..6871dabc9a1 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js @@ -1,6 +1,8 @@ import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; import { + ISSUES_CATEGORY, + MERGE_REQUEST_CATEGORY, MSG_ISSUES_ASSIGNED_TO_ME, MSG_ISSUES_IVE_CREATED, MSG_MR_ASSIGNED_TO_ME, @@ -46,7 +48,7 @@ export const scopedIssuesPath = (state) => { return ( state.searchContext?.project_metadata?.issues_path || state.searchContext?.group_metadata?.issues_path || - state.issuesPath + (gon.current_username ? state.issuesPath : false) ); }; @@ -54,13 +56,33 @@ export const scopedMRPath = (state) => { return ( state.searchContext?.project_metadata?.mr_path || state.searchContext?.group_metadata?.mr_path || - state.mrPath + (gon.current_username ? state.mrPath : false) ); }; export const defaultSearchOptions = (state, getters) => { const userName = gon.current_username; + if (!userName) { + const options = []; + + if (getters.scopedIssuesPath) { + options.push({ + text: ISSUES_CATEGORY, + href: getters.scopedIssuesPath, + }); + } + + if (getters.scopedMRPath) { + options.push({ + text: MERGE_REQUEST_CATEGORY, + href: getters.scopedMRPath, + }); + } + + return options; + } + const issues = [ { text: MSG_ISSUES_ASSIGNED_TO_ME, diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js index b83433c5b49..ca5519f529c 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue index 764db490751..1bad13f91e8 100644 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -19,7 +19,13 @@ export default { <template> <ul class="gl-p-0 gl-list-style-none"> - <nav-item v-for="item in items" :key="item.id" :item="item" is-subitem> + <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" diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index 73a899eeb83..d2d45ca7b6e 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -2,6 +2,7 @@ import { kebabCase } from 'lodash'; import { GlCollapse, GlIcon } from '@gitlab/ui'; import NavItem from './nav_item.vue'; +import FlyoutMenu from './flyout_menu.vue'; export default { name: 'MenuSection', @@ -9,6 +10,7 @@ export default { GlCollapse, GlIcon, NavItem, + FlyoutMenu, }, props: { item: { @@ -30,10 +32,18 @@ export default { required: false, default: 'div', }, + hasFlyout: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { isExpanded: Boolean(this.expanded || this.item.is_active), + isMouseOverSection: false, + isMouseOverFlyout: false, + keepFlyoutClosed: false, }; }, computed: { @@ -45,6 +55,9 @@ export default { }; }, collapseIcon() { + if (this.hasFlyout) { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + } return this.isExpanded ? 'chevron-up' : 'chevron-down'; }, computedLinkClasses() { @@ -58,10 +71,23 @@ export default { itemId() { return kebabCase(this.item.title); }, + isMouseOver() { + return this.isMouseOverSection || this.isMouseOverFlyout; + }, }, watch: { isExpanded(newIsExpanded) { this.$emit('collapse-toggle', newIsExpanded); + this.keepFlyoutClosed = !this.newIsExpanded; + }, + }, + methods: { + handlePointerover(e) { + this.isMouseOverSection = e.pointerType === 'mouse'; + }, + handlePointerleave() { + this.isMouseOverSection = false; + this.keepFlyoutClosed = false; }, }, }; @@ -71,15 +97,18 @@ export default { <component :is="tag"> <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" /> <button + :id="`menu-section-button-${itemId}`" class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" :class="computedLinkClasses" data-qa-selector="menu_section_button" :data-qa-section-name="item.title" v-bind="buttonProps" @click="isExpanded = !isExpanded" + @pointerover="handlePointerover" + @pointerleave="handlePointerleave" > <span - :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']" + :class="[isActive ? 'active-indicator gl-bg-blue-500' : 'gl-bg-transparent']" class="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" @@ -99,6 +128,17 @@ export default { </span> </button> + <flyout-menu + v-if="hasFlyout" + v-show="isMouseOver && !isExpanded && !keepFlyoutClosed" + :target-id="`menu-section-button-${itemId}`" + :items="item.items" + @mouseover="isMouseOverFlyout = true" + @mouseleave="isMouseOverFlyout = false" + @pin-add="(itemId) => $emit('pin-add', itemId)" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + <gl-collapse :id="itemId" v-model="isExpanded" diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index c1e1f64dbc1..36803a885e7 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -56,6 +56,11 @@ export default { required: false, default: false, }, + isFlyout: { + type: Boolean, + required: false, + default: false, + }, }, computed: { pillData() { @@ -104,6 +109,8 @@ export default { return { 'gl-px-2 gl-mx-2 gl-line-height-normal': this.isSubitem, 'gl-px-3': !this.isSubitem, + 'gl-pl-5! gl-rounded-small': this.isFlyout, + 'gl-rounded-base': !this.isFlyout, [this.item.link_classes]: this.item.link_classes, ...this.linkClasses, }; @@ -121,25 +128,25 @@ export default { :is="navItemLinkComponent" #default="{ isActive }" v-bind="linkProps" - class="nav-item-link gl-rounded-base 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" + 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="computedLinkClasses" data-qa-selector="nav_item_link" data-testid="nav-item-link" > <div - :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']" - class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow" + :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" data-testid="active-indicator" ></div> - <div class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> + <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> <slot name="icon"> <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" /> <gl-icon v-else-if="isInPinnedSection" name="grip" - class="gl-m-auto gl-text-gray-400 draggable-icon" + class="gl-m-auto gl-text-gray-400 js-draggable-icon gl-cursor-grab show-on-focus-or-hover--target" /> </slot> </div> @@ -161,20 +168,22 @@ export default { </gl-badge> <gl-button v-if="isPinnable && !isPinned" - v-gl-tooltip.right.viewport="$options.i18n.pinItem" + 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.right.viewport="$options.i18n.unpinItem" + 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> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index ccd739c8bb1..1e2201fbdff 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -28,6 +28,11 @@ export default { required: false, default: false, }, + hasFlyout: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -40,7 +45,12 @@ export default { return this.items.some((item) => item.is_active); }, sectionItem() { - return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive }; + return { + title: this.$options.i18n.pinned, + icon: 'thumbtack', + is_active: this.isActive, + items: this.draggableItems, + }; }, itemIds() { return this.draggableItems.map((item) => item.id); @@ -75,14 +85,16 @@ export default { :item="sectionItem" :expanded="expanded" :separated="separated" + :has-flyout="hasFlyout" @collapse-toggle="expanded = !expanded" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" > <draggable v-if="items.length > 0" v-model="draggableItems" class="gl-p-0 gl-m-0" data-testid="pinned-nav-items" - handle=".draggable-icon" + handle=".js-draggable-icon" tag="ul" @end="handleDrag" > diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 287e4f57d01..821b9dbcb7b 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -1,7 +1,9 @@ <script> 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'; import PinnedSection from './pinned_section.vue'; @@ -14,6 +16,7 @@ export default { NavItem, PinnedSection, }, + mixins: [glFeatureFlagsMixin()], provide() { return { @@ -27,6 +30,10 @@ export default { type: Array, required: true, }, + isLoggedIn: { + type: Boolean, + required: true, + }, pinnedItemIds: { type: Array, required: false, @@ -39,7 +46,8 @@ export default { }, updatePinsUrl: { type: String, - required: true, + required: false, + default: '', }, }, @@ -49,6 +57,8 @@ export default { data() { return { + showFlyoutMenus: false, + // This is used as a provide and injected into the nav items. // Note: It has to be an object to be reactive. changedPinnedItemIds: { ids: this.pinnedItemIds }, @@ -92,12 +102,21 @@ export default { .filter(Boolean); }, supportsPins() { - return PANELS_WITH_PINS.includes(this.panelType); + return this.isLoggedIn && PANELS_WITH_PINS.includes(this.panelType); }, hasStaticItems() { return this.staticItems.length > 0; }, }, + mounted() { + if (this.glFeatures.superSidebarFlyoutMenus) { + this.decideFlyoutState(); + window.addEventListener('resize', this.decideFlyoutState); + } + }, + beforeDestroy() { + window.removeEventListener('resize', this.decideFlyoutState); + }, methods: { createPin(itemId) { this.changedPinnedItemIds.ids.push(itemId); @@ -137,6 +156,9 @@ export default { isSection(navItem) { return navItem.items?.length; }, + decideFlyoutState() { + this.showFlyoutMenus = GlBreakpointInstance.windowWidth() >= breakpoints.md; + }, }, }; </script> @@ -150,6 +172,7 @@ export default { v-if="supportsPins" separated :items="pinnedItems" + :has-flyout="showFlyoutMenus" @pin-remove="destroyPin" @pin-reorder="movePin" /> @@ -166,6 +189,7 @@ export default { :key="item.id" :item="item" :separated="item.separated" + :has-flyout="showFlyoutMenus" @pin-add="createPin" @pin-remove="destroyPin" /> 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 6058ed3a1cd..ec728b4af9e 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue @@ -11,6 +11,12 @@ export const STATE_WILL_CLOSE = 'will-close'; export default { name: 'SidebarPeek', mixins: [Tracking.mixin()], + props: { + isMouseOverSidebar: { + type: Boolean, + required: true, + }, + }, created() { // Nothing needs to observe these properties, so they are not reactive. this.state = null; @@ -57,6 +63,11 @@ export default { this.close(); } } else if (this.state === STATE_OPEN) { + // Do not close the sidebar if it or one of its child elements still + // has mouseover. This allows to move the mouse from the sidebar to + // one of its flyout menus. + if (this.isMouseOverSidebar) return; + if (clientX >= this.xAwayFromSidebar) { this.close(); } else if (clientX >= this.xSidebarEdge) { diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index c194401ce95..29a3147e949 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -8,6 +8,7 @@ import { sidebarState } from '../constants'; import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; 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'; @@ -17,6 +18,7 @@ export default { components: { GlButton, UserBar, + ContextHeader, ContextSwitcher, HelpCenter, SidebarMenu, @@ -42,6 +44,7 @@ export default { return { sidebarState, showPeekHint: false, + isMouseover: false, }; }, computed: { @@ -57,7 +60,7 @@ export default { }, watch: { 'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) { - if (newIsCollapsed) { + if (newIsCollapsed && this.$refs['context-switcher']) { this.$refs['context-switcher'].close(); } }, @@ -118,6 +121,8 @@ export default { data-testid="super-sidebar" data-qa-selector="navbar" :inert="sidebarState.isCollapsed" + @mouseenter="isMouseover = true" + @mouseleave="isMouseover = false" > <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" /> <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> @@ -126,15 +131,17 @@ export default { /> <trial-status-popover /> </div> - <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> + <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" - :persistent-links="sidebarData.context_switcher_links" :username="sidebarData.username" :projects-path="sidebarData.projects_path" :groups-path="sidebarData.groups_path" @@ -142,9 +149,11 @@ export default { :context-header="sidebarData.current_context_header" @toggle="onContextSwitcherToggled" /> + <context-header v-else :context="sidebarData.current_context_header" /> <sidebar-menu v-if="menuItems.length" :items="menuItems" + :is-logged-in="sidebarData.is_logged_in" :panel-type="sidebarData.panel_type" :pinned-item-ids="sidebarData.pinned_items" :update-pins-url="sidebarData.update_pins_url" @@ -170,6 +179,10 @@ export default { Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid setting up event listeners unnecessarily. --> - <sidebar-peek-behavior v-if="sidebarState.isPeekable" @change="onPeekChange" /> + <sidebar-peek-behavior + v-if="sidebarState.isPeekable" + :is-mouse-over-sidebar="isMouseover" + @change="onPeekChange" + /> </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 87762a62c0f..7d5e87805d5 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -74,7 +74,7 @@ export default { <template> <gl-button - v-gl-tooltip.hover="tooltip" + v-gl-tooltip.hover.noninteractive.ds500="tooltip" aria-controls="super-sidebar" :aria-expanded="ariaExpanded" :aria-label="$options.i18n.navigationSidebar" diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index a882df057fa..b76ef91b768 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -105,17 +105,20 @@ export default { <template> <div class="user-bar"> <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2"> - <brand-logo :logo-url="sidebarData.logo_url" /> - <gl-badge - v-if="sidebarData.gitlab_com_and_canary" - variant="success" - :href="sidebarData.canary_toggle_com_url" - size="sm" - class="gl-ml-2" - > - {{ $options.NEXT_LABEL }} - </gl-badge> - <div class="gl-flex-grow-1"></div> + <template v-if="sidebarData.is_logged_in"> + <brand-logo :logo-url="sidebarData.logo_url" /> + <gl-badge + v-if="sidebarData.gitlab_com_and_canary" + variant="success" + :href="sidebarData.canary_toggle_com_url" + size="sm" + class="gl-ml-2" + > + {{ $options.NEXT_LABEL }} + </gl-badge> + <div class="gl-flex-grow-1"></div> + </template> + <super-sidebar-toggle v-if="hasCollapseButton" :class="$options.JS_TOGGLE_COLLAPSE_CLASS" @@ -123,11 +126,11 @@ export default { tooltip-container="super-sidebar" data-testid="super-sidebar-collapse-button" /> - <create-menu :groups="sidebarData.create_new_menu_groups" /> + <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.html="searchTooltip" + v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip" v-gl-modal="$options.SEARCH_MODAL_ID" data-testid="super-sidebar-search-button" icon="search" @@ -136,24 +139,26 @@ export default { /> <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" /> - <user-menu :data="sidebarData" /> + <user-menu v-if="sidebarData.is_logged_in" :data="sidebarData" /> <gl-button v-if="isImpersonating" - v-gl-tooltip + v-gl-tooltip.noninteractive.ds500.bottom :href="sidebarData.stop_impersonation_path" :title="$options.i18n.stopImpersonating" :aria-label="$options.i18n.stopImpersonating" icon="incognito" - variant="confirm" category="tertiary" data-method="delete" data-testid="stop-impersonation-btn" /> </div> - <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> + <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" + > <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" + v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues" class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" :count="userCounts.assigned_issues" @@ -171,7 +176,9 @@ export default { @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" + v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom=" + mrMenuShown ? '' : $options.i18n.mergeRequests + " class="gl-w-full" icon="merge-request-open" :count="mergeRequestTotalCount" @@ -183,7 +190,7 @@ export default { /> </merge-request-menu> <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" + v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.todoList" class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" :count="userCounts.todos" 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 f3e8816cd37..13f19338610 100644 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -76,6 +76,7 @@ export default { <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span> <gl-tooltip + v-if="user.status.message_html" :target="() => $refs.statusTooltipTarget" boundary="viewport" placement="bottom" diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 322eca72016..2b62e7a6ede 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -40,6 +40,7 @@ const getTrialStatusWidgetData = (sidebarData) => { lastName, companyName, glmContent, + createHandRaiseLeadPath, } = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs); return { @@ -53,6 +54,7 @@ const getTrialStatusWidgetData = (sidebarData) => { plansHref, daysRemaining, targetId, + createHandRaiseLeadPath, trialEndDate: new Date(trialEndDate), user: { namespaceId, userName, firstName, lastName, companyName, glmContent }, }; @@ -79,11 +81,15 @@ export const initSuperSidebar = () => { const sidebarData = JSON.parse(sidebar); const searchData = convertObjectPropsToCamelCase(sidebarData.search); + const projectsPath = sidebarData.projects_path; + const groupsPath = sidebarData.groups_path; + const commandPaletteData = JSON.parse(commandPalette); const projectFilesPath = commandPaletteData.project_files_url; const projectBlobPath = commandPaletteData.project_blob_url; const commandPaletteCommands = sidebarData.create_new_menu_groups || []; const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []); + const contextSwitcherLinks = sidebarData.context_switcher_links; const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; const isImpersonating = parseBoolean(sidebarData.is_impersonating); @@ -99,10 +105,13 @@ export const initSuperSidebar = () => { ...getTrialStatusWidgetData(sidebarData), commandPaletteCommands, commandPaletteLinks, + contextSwitcherLinks, autocompletePath, searchContext, projectFilesPath, projectBlobPath, + projectsPath, + groupsPath, }, store: createStore({ searchPath, diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index 3b17a35c5bc..cbf93155fb6 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -1,3 +1,4 @@ +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'; @@ -35,17 +36,15 @@ export const getTopFrequentItems = (items, maxCount) => { return frequentItems.slice(0, maxCount); }; -const updateItemAccess = (item) => { +const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) => { const now = Date.now(); - const neverAccessed = !item.lastAccessedOn; - const shouldUpdate = - neverAccessed || Math.abs(now - item.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; - const currentFrequency = item.frequency ?? 0; + const neverAccessed = !lastAccessedOn; + const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; return { - ...item, - frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency, - lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn, + ...contextItem, + frequency: shouldUpdate ? frequency + 1 : frequency, + lastAccessedOn: shouldUpdate ? now : lastAccessedOn, }; }; @@ -62,7 +61,7 @@ export const trackContextAccess = (username, context) => { ); if (existingItemIndex > -1) { - storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]); + storedItems[existingItemIndex] = updateItemAccess(context.item, storedItems[existingItemIndex]); } else { const newItem = updateItemAccess(context.item); if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) { @@ -84,4 +83,31 @@ export const formatContextSwitcherItems = (items) => link, })); +export const getItemsFromLocalStorage = ({ storageKey, maxItems }) => { + if (!AccessorUtilities.canUseLocalStorage()) { + return []; + } + + try { + const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey)); + return getTopFrequentItems(parsedCachedFrequentItems, maxItems); + } catch (e) { + Sentry.captureException(e); + return []; + } +}; + +export const removeItemFromLocalStorage = ({ storageKey, item }) => { + try { + const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey)); + const filteredItems = parsedCachedFrequentItems.filter((i) => i.id !== item.id); + localStorage.setItem(storageKey, JSON.stringify(filteredItems)); + + return filteredItems; + } catch (e) { + Sentry.captureException(e); + return []; + } +}; + export const ariaCurrent = (isActive) => (isActive ? 'page' : null); diff --git a/app/assets/javascripts/tags/components/delete_tag_modal.vue b/app/assets/javascripts/tags/components/delete_tag_modal.vue index e3b666ec968..c4f9db70d2a 100644 --- a/app/assets/javascripts/tags/components/delete_tag_modal.vue +++ b/app/assets/javascripts/tags/components/delete_tag_modal.vue @@ -1,9 +1,10 @@ <script> -import { GlButton, GlFormInput, GlModal, GlSprintf, GlAlert } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; -import { sprintf, s__ } from '~/locale'; +import { sprintf } from '~/locale'; import eventHub from '../event_hub'; +import { I18N_DELETE_TAG_MODAL } from '../constants'; export default { csrf, @@ -12,7 +13,6 @@ export default { GlButton, GlFormInput, GlSprintf, - GlAlert, }, data() { return { @@ -94,57 +94,38 @@ export default { this.$refs.modal.hide(); }, }, - i18n: { - modalTitle: s__('TagsPage|Delete tag. Are you ABSOLUTELY SURE?'), - modalTitleProtectedTag: s__('TagsPage|Delete protected tag. Are you ABSOLUTELY SURE?'), - modalMessage: s__( - "TagsPage|You're about to permanently delete the tag %{strongStart}%{tagName}.%{strongEnd}", - ), - modalMessageProtectedTag: s__( - "TagsPage|You're about to permanently delete the protected tag %{strongStart}%{tagName}.%{strongEnd}", - ), - undoneWarning: s__( - 'TagsPage|After you confirm and select %{strongStart}%{buttonText},%{strongEnd} you cannot recover this tag.', - ), - cancelButtonText: s__('TagsPage|Cancel, keep tag'), - confirmationText: s__( - 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} tag cannot be undone. Are you sure?', - ), - confirmationTextProtectedTag: s__('TagsPage|Please type the following to confirm:'), - deleteButtonText: s__('TagsPage|Yes, delete tag'), - deleteButtonTextProtectedTag: s__('TagsPage|Yes, delete protected tag'), - }, + i18n: I18N_DELETE_TAG_MODAL, }; </script> <template> <gl-modal ref="modal" size="sm" :modal-id="modalId" :title="title"> - <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> - <div data-testid="modal-message"> - <gl-sprintf :message="message"> - <template #strong="{ content }"> - <strong> {{ content }} </strong> - </template> - </gl-sprintf> - </div> - </gl-alert> + <div data-testid="modal-message"> + <gl-sprintf :message="message"> + <template #strong="{ content }"> + <strong> {{ content }} </strong> + </template> + </gl-sprintf> + </div> + <p class="gl-mt-4"> + <gl-sprintf :message="confirmationText"> + <template #strong="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </p> <form ref="form" :action="path" method="post"> <div v-if="isProtected" class="gl-mt-4"> <p> - <gl-sprintf :message="undoneWarning"> - <template #strong="{ content }"> - <strong> {{ content }} </strong> - </template> - </gl-sprintf> - </p> - <p> <gl-sprintf :message="$options.i18n.confirmationTextProtectedTag"> <template #strong="{ content }"> {{ content }} </template> </gl-sprintf> - <code class="gl-white-space-pre-wrap"> {{ tagName }} </code> + <code> {{ tagName }} </code> <gl-form-input v-model="enteredTagName" name="delete_tag_input" @@ -155,17 +136,6 @@ export default { /> </p> </div> - <div v-else> - <p class="gl-mt-4"> - <gl-sprintf :message="confirmationText"> - <template #strong="{ content }"> - <strong> - {{ content }} - </strong> - </template> - </gl-sprintf> - </p> - </div> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue index bb4f3ac0571..87c8742076b 100644 --- a/app/assets/javascripts/tags/components/sort_dropdown.vue +++ b/app/assets/javascripts/tags/components/sort_dropdown.vue @@ -60,7 +60,6 @@ export default { v-model="searchTerm" :placeholder="$options.i18n.searchPlaceholder" class="gl-pr-3" - data-testid="tag-search" @submit="visitUrlFromOption(selectedKey)" /> <gl-collapsible-listbox diff --git a/app/assets/javascripts/tags/constants.js b/app/assets/javascripts/tags/constants.js new file mode 100644 index 00000000000..a8096a08a97 --- /dev/null +++ b/app/assets/javascripts/tags/constants.js @@ -0,0 +1,37 @@ +import { s__ } from '~/locale'; + +export const MODAL_TITLE = s__('TagsPage|Permanently delete tag?'); + +export const MODAL_TITLE_PROTECTED_TAG = s__('TagsPage|Permanently delete protected tag?'); + +export const MODAL_MESSAGE = s__( + 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} tag cannot be undone.', +); + +export const MODAL_MESSAGE_PROTECTED_TAG = s__( + 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} protected tag cannot be undone.', +); + +export const CANCEL_BUTTON_TEXT = s__('TagsPage|Cancel, keep tag'); + +export const CONFIRMATION_TEXT = s__('TagsPage|Are you sure you want to delete this tag?'); + +export const CONFIRMATION_TEXT_PROTECTED_TAG = s__( + 'TagsPage|Please type the following to confirm:', +); + +export const DELETE_BUTTON_TEXT = s__('TagsPage|Yes, delete tag'); + +export const DELETE_BUTTON_TEXT_PROTECTED_TAG = s__('TagsPage|Yes, delete protected tag'); + +export const I18N_DELETE_TAG_MODAL = { + modalTitle: MODAL_TITLE, + modalTitleProtectedTag: MODAL_TITLE_PROTECTED_TAG, + modalMessage: MODAL_MESSAGE, + modalMessageProtectedTag: MODAL_MESSAGE_PROTECTED_TAG, + cancelButtonText: CANCEL_BUTTON_TEXT, + confirmationText: CONFIRMATION_TEXT, + confirmationTextProtectedTag: CONFIRMATION_TEXT_PROTECTED_TAG, + deleteButtonText: DELETE_BUTTON_TEXT, + deleteButtonTextProtectedTag: DELETE_BUTTON_TEXT_PROTECTED_TAG, +}; diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue index a63c2025e8b..74c41700f43 100644 --- a/app/assets/javascripts/terraform/components/init_command_modal.vue +++ b/app/assets/javascripts/terraform/components/init_command_modal.vue @@ -83,7 +83,6 @@ terraform init \\ :title="$options.i18n.copyToClipboardText" :text="getModalInfoCopyStr()" :modal-id="$options.modalId" - data-testid="init-command-copy-clipboard" css-classes="gl-align-self-start gl-ml-2" /> </div> diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index eb1222d5130..234ac0505b2 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -5,6 +5,7 @@ import { GlCard, GlFormInput, GlLink, + GlIcon, GlLoadingIcon, GlSprintf, GlToggle, @@ -21,9 +22,9 @@ import TokenProjectsTable from './token_projects_table.vue'; export default { i18n: { - toggleLabelTitle: s__('CICD|Allow access to this project with a CI_JOB_TOKEN'), + toggleLabelTitle: s__('CICD|Limit access %{italicStart}to%{italicEnd} this project'), toggleHelpText: s__( - `CICD|Manage which projects can use their CI_JOB_TOKEN to access this project. 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 access to this project from other project CI/CD job tokens, 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|Allow CI job tokens from the following projects to access this project', @@ -64,6 +65,7 @@ export default { GlCard, GlFormInput, GlLink, + GlIcon, GlLoadingIcon, GlSprintf, GlToggle, @@ -109,6 +111,7 @@ export default { inboundJobTokenScopeEnabled: null, targetProjectPath: '', projects: [], + isAddFormVisible: false, }; }, computed: { @@ -193,10 +196,14 @@ export default { }, clearTargetProjectPath() { this.targetProjectPath = ''; + this.isAddFormVisible = false; }, getProjects() { this.$apollo.queries.projects.refetch(); }, + showAddForm() { + this.isAddFormVisible = true; + }, }, }; </script> @@ -209,6 +216,13 @@ export default { :label="$options.i18n.toggleLabelTitle" @change="updateCIJobTokenScope" > + <template #label> + <gl-sprintf :message="$options.i18n.toggleLabelTitle"> + <template #italic="{ content }"> + <i>{{ content }}</i> + </template> + </gl-sprintf> + </template> <template #help> <gl-sprintf :message="$options.i18n.toggleHelpText"> <template #link="{ content }"> @@ -221,22 +235,55 @@ export default { </gl-toggle> <div> - <gl-card class="gl-mt-5 gl-mb-3"> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + > <template #header> - <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> + <div class="gl-new-card-title-wrapper"> + <h5 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h5> + <span class="gl-new-card-count"> + <gl-icon name="project" class="gl-mr-2" /> + {{ projects.length }} + </span> + </div> + <div class="gl-new-card-actions"> + <gl-button + v-if="!isAddFormVisible" + size="small" + data-testid="toggle-form-btn" + @click="showAddForm" + >{{ $options.i18n.addProject }}</gl-button + > + </div> </template> - <template #default> + + <div v-if="isAddFormVisible" class="gl-new-card-add-form gl-m-3"> + <h4 class="gl-mt-0">{{ $options.i18n.addProject }}</h4> <gl-form-input v-model="targetProjectPath" :placeholder="$options.i18n.addProjectPlaceholder" /> - </template> - <template #footer> - <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject"> - {{ $options.i18n.addProject }} - </gl-button> - <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> - </template> + <div class="gl-display-flex gl-mt-5"> + <gl-button + variant="confirm" + :disabled="isProjectPathEmpty" + class="gl-mr-3" + data-testid="add-project-btn" + @click="addProject" + > + {{ $options.i18n.addProject }} + </gl-button> + <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> + </div> + </div> + + <token-projects-table + :projects="projects" + :table-fields="$options.fields" + @removeProject="removeProject" + /> </gl-card> <gl-alert v-if="!inboundJobTokenScopeEnabled" @@ -247,11 +294,6 @@ export default { > {{ $options.i18n.settingDisabledMessage }} </gl-alert> - <token-projects-table - :projects="projects" - :table-fields="$options.fields" - @removeProject="removeProject" - /> </div> </template> </div> 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 f70bb77b780..7e1e6cc445c 100644 --- a/app/assets/javascripts/token_access/components/outbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -3,8 +3,8 @@ import { GlAlert, GlButton, GlCard, - GlFormInput, GlLink, + GlIcon, GlLoadingIcon, GlSprintf, GlToggle, @@ -23,9 +23,11 @@ import TokenProjectsTable from './token_projects_table.vue'; // Note: This component will be removed in 17.0, as the outbound access token is getting deprecated export default { i18n: { - toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'), + toggleLabelTitle: s__( + 'CICD|Limit access %{italicStart}from%{italicEnd} this project (Deprecated)', + ), toggleHelpText: s__( - `CICD|Select the projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. 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__( @@ -69,8 +71,8 @@ export default { GlAlert, GlButton, GlCard, - GlFormInput, GlLink, + GlIcon, GlLoadingIcon, GlSprintf, GlToggle, @@ -219,7 +221,7 @@ export default { <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" /> <template v-else> <gl-alert - class="gl-mb-3" + class="gl-mt-5 gl-mb-3" variant="warning" :dismissible="false" :show-icon="false" @@ -246,6 +248,13 @@ export default { :disabled="disableTokenToggle" @change="updateCIJobTokenScope" > + <template #label> + <gl-sprintf :message="$options.i18n.toggleLabelTitle"> + <template #italic="{ content }"> + <i>{{ content }}</i> + </template> + </gl-sprintf> + </template> <template #help> <gl-sprintf :message="$options.i18n.toggleHelpText"> <template #link="{ content }"> @@ -259,30 +268,29 @@ export default { </gl-toggle> <div> - <gl-card class="gl-mt-5 gl-mb-3"> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-px-0" + > <template #header> - <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> - </template> - <template #default> - <gl-form-input - v-model="targetProjectPath" - :disabled="true" - :placeholder="$options.i18n.addProjectPlaceholder" - data-testid="project-path-input" - /> - </template> - <template #footer> - <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject"> - {{ $options.i18n.addProject }} - </gl-button> - <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> + <div class="gl-new-card-title-wrapper"> + <h5 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h5> + <span class="gl-new-card-count"> + <gl-icon name="project" class="gl-mr-2" /> + {{ projects.length }} + </span> + </div> + <div class="gl-new-card-actions"> + <gl-button size="small" disabled>{{ $options.i18n.addProject }}</gl-button> + </div> </template> + <token-projects-table + :projects="projects" + :table-fields="$options.fields" + @removeProject="removeProject" + /> </gl-card> - <token-projects-table - :projects="projects" - :table-fields="$options.fields" - @removeProject="removeProject" - /> </div> </template> </div> diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue index 167eebc8d9b..d2d5e6b2a5a 100644 --- a/app/assets/javascripts/token_access/components/token_access_app.vue +++ b/app/assets/javascripts/token_access/components/token_access_app.vue @@ -12,7 +12,7 @@ export default { </script> <template> <div> - <inbound-token-access class="gl-pb-5" /> - <outbound-token-access class="gl-py-5" /> + <inbound-token-access /> + <outbound-token-access /> </div> </template> diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue index a4dc783f1e4..26479aeffcf 100644 --- a/app/assets/javascripts/tooltips/components/tooltips.vue +++ b/app/assets/javascripts/tooltips/components/tooltips.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlTooltip } from '@gitlab/ui'; import { uniqueId } from 'lodash'; @@ -7,9 +8,14 @@ const getTooltipTitle = (element) => { return element.getAttribute('title') || element.dataset.title; }; +const getTooltipCustomClass = (element) => { + return element.dataset.tooltipCustomClass; +}; + const newTooltip = (element, config = {}) => { const { placement, container, boundary, html, triggers } = element.dataset; const title = getTooltipTitle(element); + const customClass = getTooltipCustomClass(element); return { id: uniqueId('gl-tooltip'), @@ -21,6 +27,7 @@ const newTooltip = (element, config = {}) => { boundary, triggers, disabled: !title, + customClass, ...config, }; }; @@ -115,6 +122,7 @@ export default { :boundary="tooltip.boundary" :disabled="tooltip.disabled" :show="tooltip.show" + :custom-class="tooltip.customClass" @hidden="$emit('hidden', tooltip)" > <span v-if="tooltip.html" v-safe-html:[$options.safeHtmlConfig]="tooltip.title"></span> diff --git a/app/assets/javascripts/tracing/components/tracing_details.vue b/app/assets/javascripts/tracing/components/tracing_details.vue new file mode 100644 index 00000000000..d8b2cbc9469 --- /dev/null +++ b/app/assets/javascripts/tracing/components/tracing_details.vue @@ -0,0 +1,90 @@ +<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 index 4cb3bd6d9f0..f17060db6bc 100644 --- a/app/assets/javascripts/tracing/components/tracing_empty_state.vue +++ b/app/assets/javascripts/tracing/components/tracing_empty_state.vue @@ -1,31 +1,20 @@ <script> import EMPTY_TRACING_SVG from '@gitlab/svgs/dist/illustrations/monitoring/tracing.svg?url'; import { GlEmptyState, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; export default { EMPTY_TRACING_SVG, name: 'TracingEmptyState', i18n: { - title: __('Get started with Tracing'), - description: __('Monitor your applications with GitLab Distributed Tracing.'), - enableButtonText: __('Enable'), + title: s__('Tracing|Get started with Tracing'), + description: s__('Tracing|Monitor your applications with GitLab Distributed Tracing.'), + enableButtonText: s__('Tracing|Enable'), }, components: { GlEmptyState, GlButton, }, - props: { - enableTracing: { - type: Function, - required: true, - }, - }, - methods: { - onEnabledClicked() { - this.enableTracing(); - }, - }, }; </script> @@ -38,7 +27,7 @@ export default { </template> <template #actions> - <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="onEnabledClicked"> + <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="$emit('enable-tracing')"> {{ $options.i18n.enableButtonText }} </gl-button> </template> diff --git a/app/assets/javascripts/tracing/components/tracing_list.vue b/app/assets/javascripts/tracing/components/tracing_list.vue index 294e520d7ac..21d1353a86d 100644 --- a/app/assets/javascripts/tracing/components/tracing_list.vue +++ b/app/assets/javascripts/tracing/components/tracing_list.vue @@ -1,15 +1,26 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; +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: { @@ -26,8 +37,17 @@ export default { */ tracingEnabled: null, traces: [], + filters: queryToFilterObj(window.location.search), }; }, + computed: { + query() { + return filterObjToQuery(this.filters); + }, + initialFilterValue() { + return filterObjToFilterToken(this.filters); + }, + }, async created() { this.checkEnabled(); }, @@ -41,7 +61,7 @@ export default { } } catch (e) { createAlert({ - message: __('Failed to load page.'), + message: s__('Tracing|Failed to load page.'), }); } finally { this.loading = false; @@ -55,7 +75,7 @@ export default { await this.fetchTraces(); } catch (e) { createAlert({ - message: __('Failed to enable tracing.'), + message: s__('Tracing|Failed to enable tracing.'), }); } finally { this.loading = false; @@ -64,16 +84,23 @@ export default { async fetchTraces() { this.loading = true; try { - const traces = await this.observabilityClient.fetchTraces(); + const traces = await this.observabilityClient.fetchTraces(this.filters); this.traces = traces; } catch (e) { createAlert({ - message: __('Failed to load traces.'), + 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> @@ -85,9 +112,14 @@ export default { </div> <template v-else-if="tracingEnabled !== null"> - <tracing-empty-state v-if="tracingEnabled === false" :enable-tracing="enableTracing" /> + <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 v-else :traces="traces" @reload="fetchTraces" /> + <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 new file mode 100644 index 00000000000..d086f2d03ff --- /dev/null +++ b/app/assets/javascripts/tracing/components/tracing_list_filtered_search.vue @@ -0,0 +1,87 @@ +<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 index 7e8c296a7d4..abb1f3ae88c 100644 --- a/app/assets/javascripts/tracing/components/tracing_table_list.vue +++ b/app/assets/javascripts/tracing/components/tracing_table_list.vue @@ -1,37 +1,37 @@ <script> import { GlTable, GlLink } from '@gitlab/ui'; -import { __ } from '~/locale'; +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: __('Traces'), - emptyText: __('No traces to display.'), - emptyLinkText: __('Check again'), + title: s__('Tracing|Traces'), + emptyText: s__('Tracing|No traces to display.'), + emptyLinkText: s__('Tracing|Check again'), }, fields: [ { - key: 'date', - label: __('Date'), + key: 'timestamp', + label: s__('Tracing|Date'), tdClass: tableDataClass, sortable: true, }, { - key: 'service', - label: __('Service'), + key: 'service_name', + label: s__('Tracing|Service'), tdClass: tableDataClass, sortable: true, }, { key: 'operation', - label: __('Operation'), + label: s__('Tracing|Operation'), tdClass: tableDataClass, sortable: true, }, { key: 'duration', - label: __('Duration'), + label: s__('Tracing|Duration'), thClass: 'gl-w-15p', tdClass: tableDataClass, sortable: true, @@ -47,6 +47,13 @@ export default { type: Array, }, }, + methods: { + onSelect(items) { + if (items[0]) { + this.$emit('trace-selected', items[0]); + } + }, + }, }; </script> @@ -55,19 +62,24 @@ export default { <h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ $options.i18n.title }}</h4> <gl-table - class="gl-mt-5" :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(date)="data"> + <template #cell(timestamp)="data"> {{ data.item.timestamp }} </template> - <template #cell(service)="data"> + <template #cell(service_name)="data"> {{ data.item.service_name }} </template> diff --git a/app/assets/javascripts/tracing/details_index.vue b/app/assets/javascripts/tracing/details_index.vue new file mode 100644 index 00000000000..5702a88766c --- /dev/null +++ b/app/assets/javascripts/tracing/details_index.vue @@ -0,0 +1,49 @@ +<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 new file mode 100644 index 00000000000..88a54b2e69f --- /dev/null +++ b/app/assets/javascripts/tracing/filters.js @@ -0,0 +1,104 @@ +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/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index d0447fa167c..114587bb363 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -20,6 +20,7 @@ export const DEFAULT_SNOWPLOW_OPTIONS = { export const ACTION_ATTR_SELECTOR = '[data-track-action]'; export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]'; export const INTERNAL_EVENTS_SELECTOR = '[data-event-tracking]'; +export const LOAD_INTERNAL_EVENTS_SELECTOR = '[data-event-tracking-load="true"]'; export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls'; diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index 7c2cd6fde27..ffbd932c02b 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -71,4 +71,5 @@ export function initDefaultTrackers() { Tracking.trackLoadEvents(); InternalEvents.bindInternalEventDocument(); + InternalEvents.trackInternalLoadEvents(); } diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js index 16cbb3e86e1..a5fbb55ff63 100644 --- a/app/assets/javascripts/tracking/internal_events.js +++ b/app/assets/javascripts/tracking/internal_events.js @@ -1,9 +1,13 @@ import API from '~/api'; import Tracking from './tracking'; -import { GITLAB_INTERNAL_EVENT_CATEGORY, SERVICE_PING_SCHEMA } from './constants'; +import { + GITLAB_INTERNAL_EVENT_CATEGORY, + LOAD_INTERNAL_EVENTS_SELECTOR, + SERVICE_PING_SCHEMA, +} from './constants'; import { Tracker } from './tracker'; -import { InternalEventHandler } from './utils'; +import { InternalEventHandler, createInternalEventPayload } from './utils'; const InternalEvents = { /** @@ -11,7 +15,7 @@ const InternalEvents = { * @param {string} event */ track_event(event) { - API.trackRedisHllUserEvent(event); + API.trackInternalEvent(event); Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, { context: { schema: SERVICE_PING_SCHEMA, @@ -53,6 +57,27 @@ const InternalEvents = { parent.addEventListener(handler.name, handler.func); return handler; }, + /** + * Attaches internal event handlers for load events. + * @param {HTMLElement} parent - element containing event targets + * @returns {Array} + */ + trackInternalLoadEvents(parent = document) { + if (!Tracker.enabled()) { + return []; + } + + const loadEvents = parent.querySelectorAll(LOAD_INTERNAL_EVENTS_SELECTOR); + + loadEvents.forEach((element) => { + const action = createInternalEventPayload(element); + if (action) { + this.track_event(action); + } + }); + + return loadEvents; + }, }; export default InternalEvents; 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 94bc15fa0d0..f271b284d78 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 @@ -2,6 +2,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 { ERROR_MESSAGE, LEARN_MORE_LABEL, @@ -11,10 +12,13 @@ import { TOTAL_USAGE_DEFAULT_TEXT, HELP_LINK_ARIA_LABEL, RECALCULATE_REPOSITORY_LABEL, - projectContainerRegistryPopoverContent, + PROJECT_STORAGE_TYPES, + NAMESPACE_STORAGE_TYPES, + usageQuotasHelpPaths, + storageTypeHelpPaths, } from '../constants'; import getProjectStorageStatistics from '../queries/project_storage.query.graphql'; -import { parseGetProjectStorageResults } from '../utils'; +import { getStorageTypesFromProjectStatistics, descendingStorageUsageSort } from '../utils'; import UsageGraph from './usage_graph.vue'; import ProjectStorageDetail from './project_storage_detail.vue'; @@ -28,10 +32,7 @@ export default { UsageGraph, ProjectStorageDetail, }, - inject: ['projectPath', 'helpLinks'], - provide: { - containerRegistryPopoverContent: projectContainerRegistryPopoverContent, - }, + inject: ['projectPath'], apollo: { project: { query: getProjectStorageStatistics, @@ -40,9 +41,6 @@ export default { fullPath: this.projectPath, }; }, - update(data) { - return parseGetProjectStorageResults(data, this.helpLinks); - }, error() { this.error = ERROR_MESSAGE; }, @@ -56,11 +54,39 @@ export default { }; }, computed: { + isStatisticsEmpty() { + return this.project?.statistics == null; + }, totalUsage() { - return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT; + if (!this.isStatisticsEmpty) { + return numberToHumanSize(this.project?.statistics?.storageSize, 1); + } + + return TOTAL_USAGE_DEFAULT_TEXT; }, - storageTypes() { - return this.project?.storage?.storageTypes || []; + projectStorageTypes() { + if (this.isStatisticsEmpty) { + return []; + } + + return getStorageTypesFromProjectStatistics( + PROJECT_STORAGE_TYPES, + this.project?.statistics, + this.project?.statisticsDetailsPaths, + storageTypeHelpPaths, + ).sort(descendingStorageUsageSort('value')); + }, + namespaceStorageTypes() { + if (this.isStatisticsEmpty) { + return []; + } + + return getStorageTypesFromProjectStatistics( + NAMESPACE_STORAGE_TYPES, + this.project?.statistics, + this.project?.statisticsDetailsPaths, + storageTypeHelpPaths, + ); }, }, methods: { @@ -83,6 +109,7 @@ export default { alertEl?.classList.remove('gl-display-none'); }, }, + usageQuotasHelpPaths, LEARN_MORE_LABEL, USAGE_QUOTAS_LABEL, TOTAL_USAGE_TITLE, @@ -99,17 +126,15 @@ export default { <div class="gl-pt-5 gl-px-3"> <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> <div> - <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p> + <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"> {{ $options.TOTAL_USAGE_SUBTITLE }} <gl-link - :href="helpLinks.usageQuotas" + :href="$options.usageQuotasHelpPaths.usageQuotas" target="_blank" :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)" - data-testid="usage-quotas-help-link" + >{{ $options.LEARN_MORE_LABEL }}</gl-link > - {{ $options.LEARN_MORE_LABEL }} - </gl-link> </p> </div> <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage"> @@ -117,7 +142,7 @@ export default { </p> </div> </div> - <div v-if="project.statistics" class="gl-w-full"> + <div v-if="!isStatisticsEmpty" class="gl-w-full"> <usage-graph :root-storage-statistics="project.statistics" :limit="0" /> </div> <div class="gl-w-full gl-my-5"> @@ -129,6 +154,19 @@ export default { {{ $options.RECALCULATE_REPOSITORY_LABEL }} </gl-button> </div> - <project-storage-detail :storage-types="storageTypes" /> + <project-storage-detail + :storage-types="projectStorageTypes" + data-testid="usage-quotas-project-usage-details" + /> + <div> + <h2 class="gl-mb-2 gl-mt-5 gl-font-lg gl-font-weight-bold"> + {{ s__('UsageQuota|Namespace entities') }} + </h2> + + <project-storage-detail + :storage-types="namespaceStorageTypes" + data-testid="usage-quotas-namespace-usage-details" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue index ce487beca07..6cc1f63e04f 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue @@ -7,10 +7,7 @@ import { HELP_LINK_ARIA_LABEL, PROJECT_TABLE_LABEL_STORAGE_TYPE, PROJECT_TABLE_LABEL_USAGE, - containerRegistryId, - containerRegistryPopoverId, } from '../constants'; -import { descendingStorageUsageSort } from '../utils'; import StorageTypeIcon from './storage_type_icon.vue'; export default { @@ -23,33 +20,12 @@ export default { StorageTypeIcon, GlPopover, }, - inject: ['containerRegistryPopoverContent'], props: { storageTypes: { type: Array, required: true, }, }, - computed: { - sizeSortedStorageTypes() { - const warnings = { - [containerRegistryId]: { - popoverId: containerRegistryPopoverId, - popoverContent: this.containerRegistryPopoverContent, - }, - }; - - return this.storageTypes - .map((type) => { - const warning = warnings[type.storageType.id] || null; - return { - warning, - ...type, - }; - }) - .sort(descendingStorageUsageSort('value')); - }, - }, methods: { helpLinkAriaLabel(linkTitle) { return sprintf(HELP_LINK_ARIA_LABEL, { @@ -73,42 +49,39 @@ export default { }; </script> <template> - <gl-table-lite :items="sizeSortedStorageTypes" :fields="$options.projectTableFields"> + <gl-table-lite :items="storageTypes" :fields="$options.projectTableFields"> <template #cell(storageType)="{ item }"> <div class="gl-display-flex gl-flex-direction-row"> - <storage-type-icon - :name="item.storageType.id" - :data-testid="`${item.storageType.id}-icon`" - /> + <storage-type-icon :name="item.id" :data-testid="`${item.id}-icon`" /> <div> - <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> + <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.id}-name`"> <gl-link - v-if="item.storageType.detailsPath && item.value" - :data-testid="`${item.storageType.id}-details-link`" - :href="item.storageType.detailsPath" - >{{ item.storageType.name }}</gl-link + v-if="item.detailsPath && item.value" + :data-testid="`${item.id}-details-link`" + :href="item.detailsPath" + >{{ item.name }}</gl-link > <template v-else> - {{ item.storageType.name }} + {{ item.name }} </template> <gl-link - v-if="item.storageType.helpPath" - :href="item.storageType.helpPath" + v-if="item.helpPath" + :href="item.helpPath" target="_blank" - :aria-label="helpLinkAriaLabel(item.storageType.name)" - :data-testid="`${item.storageType.id}-help-link`" + :aria-label="helpLinkAriaLabel(item.name)" + :data-testid="`${item.id}-help-link`" > <gl-icon name="question-o" :size="12" /> </gl-link> </p> - <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> - {{ item.storageType.description }} + <p class="gl-mb-0" :data-testid="`${item.id}-description`"> + {{ item.description }} </p> - <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> + <p v-if="item.warningMessage" class="gl-mb-0 gl-font-sm"> <gl-icon name="warning" :size="12" /> - <gl-sprintf :message="item.storageType.warningMessage"> + <gl-sprintf :message="item.warningMessage"> <template #warningLink="{ content }"> - <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ + <gl-link :href="item.warningLink" target="_blank" class="gl-font-sm">{{ content }}</gl-link> </template> @@ -119,20 +92,23 @@ export default { </template> <template #cell(value)="{ item }"> - {{ numberToHumanSize(item.value, 1) }} + <span :data-testid="item.id + '-value'"> + {{ numberToHumanSize(item.value, 1) }} + </span> <template v-if="item.warning"> <gl-icon - :id="item.warning.popoverId" + :id="item.id + '-warning-icon'" name="warning" class="gl-mt-2 gl-lg-mt-0 gl-lg-ml-2" + :data-testid="item.id + '-warning-icon'" /> <gl-popover triggers="hover focus" placement="top" - :target="item.warning.popoverId" + :target="item.id + '-warning-icon'" :content="item.warning.popoverContent" - :data-testid="item.warning.popoverId" + :data-testid="item.id + '-popover'" /> </template> </template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue index c1e513d3a00..33f202e69db 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -18,7 +18,6 @@ export default { computed: { storageTypes() { const { - containerRegistrySize, buildArtifactsSize, lfsObjectsSize, packagesSize, @@ -52,12 +51,6 @@ export default { size: packagesSize, }, { - id: 'containerRegistry', - style: this.usageStyle(this.barRatio(containerRegistrySize)), - class: 'gl-bg-data-viz-aqua-800', - size: containerRegistrySize, - }, - { id: 'buildArtifacts', style: this.usageStyle(this.barRatio(buildArtifactsSize)), class: 'gl-bg-data-viz-green-500', diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js index 8926e8c1e86..3fdf61a5947 100644 --- a/app/assets/javascripts/usage_quotas/storage/constants.js +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -14,25 +14,34 @@ export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.'); export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); export const RECALCULATE_REPOSITORY_LABEL = s__('UsageQuota|Recalculate repository usage'); -export const projectContainerRegistryPopoverContent = s__( - 'UsageQuotas|The project-level storage statistics for the Container Registry are directional only and do not include savings for instance-wide deduplication.', -); - export const containerRegistryId = 'containerRegistrySize'; export const containerRegistryPopoverId = 'container-registry-popover'; +export const containerRegistryPopover = { + content: s__( + 'UsageQuotas|Container Registry storage statistics are not used to calculate the total project storage. Total project storage is calculated after namespace container deduplication, where the total of all unique containers is added to the namespace storage total.', + ), + docsLink: helpPagePath( + 'user/packages/container_registry/reduce_container_registry_storage.html', + { anchor: 'check-container-registry-storage-use' }, + ), +}; + export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type'); export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); +export const usageQuotasHelpPaths = { + usageQuotas: helpPagePath('user/usage_quotas'), + usageQuotasProjectStorageLimit: helpPagePath('user/usage_quotas', { + anchor: 'project-storage-limit', + }), + usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', { + anchor: 'namespace-storage-limit', + }), +}; + export const PROJECT_STORAGE_TYPES = [ { - id: 'containerRegistry', - name: __('Container Registry'), - description: s__( - 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.', - ), - }, - { id: 'buildArtifacts', name: __('Job artifacts'), description: s__('UsageQuota|Job artifacts created by CI/CD.'), @@ -64,11 +73,20 @@ export const PROJECT_STORAGE_TYPES = [ }, ]; -export const projectHelpPaths = { - usageQuotas: helpPagePath('user/usage_quotas'), - usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', { - anchor: 'namespace-storage-limit', - }), +export const NAMESPACE_STORAGE_TYPES = [ + { + id: 'containerRegistry', + name: __('Container Registry'), + description: s__( + `UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.`, + ), + warning: { + popoverContent: containerRegistryPopover.content, + }, + }, +]; + +export const storageTypeHelpPaths = { lfsObjects: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git', { anchor: 'repository-cleanup', }), diff --git a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js index 00cb274902d..e7378fcde0e 100644 --- a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js +++ b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { projectHelpPaths as helpLinks } from './constants'; import ProjectStorageApp from './components/project_storage_app.vue'; Vue.use(VueApollo); @@ -25,7 +24,6 @@ export default (containerId = 'js-project-storage-count-app') => { name: 'ProjectStorageApp', provide: { projectPath, - helpLinks, }, render(createElement) { return createElement(ProjectStorageApp); diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js index 0460cd0a9b2..445c3efc9e6 100644 --- a/app/assets/javascripts/usage_quotas/storage/utils.js +++ b/app/assets/javascripts/usage_quotas/storage/utils.js @@ -1,54 +1,32 @@ -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { PROJECT_STORAGE_TYPES } from './constants'; - +/** + * Populates an array of storage types with usage value and other details + * + * @param {Array} selectedStorageTypes selected storage types that will be populated + * @param {Object} projectStatistics object of storage values, with storage type as keys + * @param {Object} statisticsDetailsPaths object of storage detail paths, with storage type as keys + * @param {Object} helpLinks object of help paths, with storage type as keys + * @returns {Array} + */ export const getStorageTypesFromProjectStatistics = ( + selectedStorageTypes, projectStatistics, - helpLinks = {}, statisticsDetailsPaths = {}, + helpLinks = {}, ) => - PROJECT_STORAGE_TYPES.reduce((types, currentType) => { + selectedStorageTypes.reduce((types, currentType) => { const helpPath = helpLinks[currentType.id]; const value = projectStatistics[`${currentType.id}Size`]; const detailsPath = statisticsDetailsPaths[currentType.id]; return types.concat({ - storageType: { - ...currentType, - helpPath, - detailsPath, - }, + ...currentType, + helpPath, + detailsPath, value, }); }, []); /** - * This method parses the results from `getProjectStorageStatistics` call. - * - * @param {Object} data graphql result - * @returns {Object} - */ -export const parseGetProjectStorageResults = (data, helpLinks) => { - const projectStatistics = data?.project?.statistics; - if (!projectStatistics) { - return {}; - } - const { storageSize } = projectStatistics; - const storageTypes = getStorageTypesFromProjectStatistics( - projectStatistics, - helpLinks, - data?.project?.statisticsDetailsPaths, - ); - - return { - storage: { - totalUsage: numberToHumanSize(storageSize, 1), - storageTypes, - }, - statistics: projectStatistics, - }; -}; - -/** * Creates a sorting function to sort storage types by usage in the graph and in the table * * @param {string} storageUsageKey key storing value of storage usage diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue index 37c9548ad64..ec6c7cf6c8d 100644 --- a/app/assets/javascripts/user_lists/components/add_user_modal.vue +++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue @@ -55,7 +55,6 @@ export default { <gl-modal v-bind="$options.modalOptions" :visible="visible" - data-testid="add-users-modal" @primary="submitUsers" @canceled="clearInput" > diff --git a/app/assets/javascripts/user_lists/components/edit_user_list.vue b/app/assets/javascripts/user_lists/components/edit_user_list.vue index 18f411f6cf2..e357874da7a 100644 --- a/app/assets/javascripts/user_lists/components/edit_user_list.vue +++ b/app/assets/javascripts/user_lists/components/edit_user_list.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { s__, sprintf } from '~/locale'; import statuses from '../constants/edit'; diff --git a/app/assets/javascripts/user_lists/components/new_user_list.vue b/app/assets/javascripts/user_lists/components/new_user_list.vue index 17ef4c037d2..71f93bb177e 100644 --- a/app/assets/javascripts/user_lists/components/new_user_list.vue +++ b/app/assets/javascripts/user_lists/components/new_user_list.vue @@ -1,5 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import UserListForm from './user_list_form.vue'; diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue index e86b3f81daa..29b9b68883b 100644 --- a/app/assets/javascripts/user_lists/components/user_list.vue +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -6,6 +6,7 @@ import { GlLoadingIcon, GlModalDirective as GlModal, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { s__, __ } from '~/locale'; import { states, ADD_USER_MODAL_ID } from '../constants/show'; @@ -137,6 +138,7 @@ export default { :title="$options.translations.emptyStateTitle" :description="$options.translations.emptyStateDescription" :svg-path="emptyStatePath" + :svg-height="150" /> </div> </div> diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue index 0e3c6b396db..e50f4d81c1e 100644 --- a/app/assets/javascripts/user_lists/components/user_lists.vue +++ b/app/assets/javascripts/user_lists/components/user_lists.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlButton } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import EmptyState from '~/feature_flags/components/empty_state.vue'; import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/user_lists/store/edit/index.js b/app/assets/javascripts/user_lists/store/edit/index.js index 9b9df59ed32..c0f00428b6c 100644 --- a/app/assets/javascripts/user_lists/store/edit/index.js +++ b/app/assets/javascripts/user_lists/store/edit/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/user_lists/store/index/index.js b/app/assets/javascripts/user_lists/store/index/index.js index 9b9df59ed32..c0f00428b6c 100644 --- a/app/assets/javascripts/user_lists/store/index/index.js +++ b/app/assets/javascripts/user_lists/store/index/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/user_lists/store/new/index.js b/app/assets/javascripts/user_lists/store/new/index.js index 9b9df59ed32..c0f00428b6c 100644 --- a/app/assets/javascripts/user_lists/store/new/index.js +++ b/app/assets/javascripts/user_lists/store/new/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/user_lists/store/show/index.js b/app/assets/javascripts/user_lists/store/show/index.js index 9b9df59ed32..c0f00428b6c 100644 --- a/app/assets/javascripts/user_lists/store/show/index.js +++ b/app/assets/javascripts/user_lists/store/show/index.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue index bf983d911ea..5dfa9c67852 100644 --- a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue +++ b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue @@ -1,23 +1,37 @@ <script> import { GlDisclosureDropdown } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; export default { components: { GlDisclosureDropdown, + AbuseCategorySelector, }, props: { userId: { type: String, required: true, }, + rssSubscriptionPath: { + type: String, + required: false, + default: '', + }, + reportedUserId: { + type: Number, + required: false, + default: null, + }, + reportedFromUrl: { + type: String, + required: false, + default: '', + }, }, data() { return { - // Only implement the copy function in MR for now - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122971 - // The rest will be implemented in the upcoming MR. - dropdownItems: [ + defaultDropdownItems: [ { action: this.onUserIdCopy, text: sprintf(this.$options.i18n.userId, { id: this.userId }), @@ -26,20 +40,56 @@ export default { }, }, ], + open: false, }; }, + computed: { + dropdownItems() { + const dropdownItems = this.defaultDropdownItems.slice(); + if (this.rssSubscriptionPath) { + dropdownItems.push({ + href: this.rssSubscriptionPath, + text: this.$options.i18n.rssSubscribe, + extraAttrs: { + 'data-testid': 'user-profile-rss-subscription-link', + }, + }); + } + if (this.reportedUserId) { + dropdownItems.push({ + action: () => this.toggleDrawer(true), + text: this.$options.i18n.reportToAdmin, + }); + } + return dropdownItems; + }, + }, methods: { onUserIdCopy() { this.$toast.show(this.$options.i18n.userIdCopied); }, + toggleDrawer(open) { + this.open = open; + }, }, i18n: { userId: s__('UserProfile|Copy user ID: %{id}'), userIdCopied: s__('UserProfile|User ID copied to clipboard'), + rssSubscribe: s__('UserProfile|Subscribe'), + reportToAdmin: s__('ReportAbuse|Report abuse to administrator'), }, }; </script> <template> - <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" /> + <span> + <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" /> + <abuse-category-selector + v-if="reportedUserId" + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" + :show-drawer="open" + @close-drawer="toggleDrawer(false)" + /> + </span> </template> diff --git a/app/assets/javascripts/users/profile/actions/index.js b/app/assets/javascripts/users/profile/actions/index.js index 37a3faf82a5..e1f9352966b 100644 --- a/app/assets/javascripts/users/profile/actions/index.js +++ b/app/assets/javascripts/users/profile/actions/index.js @@ -7,17 +7,29 @@ export const initUserActionsApp = () => { if (!mountingEl) return false; - const { userId } = mountingEl.dataset; + const { + userId, + rssSubscriptionPath, + reportAbusePath, + reportedUserId, + reportedFromUrl, + } = mountingEl.dataset; Vue.use(GlToast); return new Vue({ el: mountingEl, name: 'UserActionsRoot', + provide: { + reportAbusePath, + }, render(createElement) { return createElement(UserActionsApp, { props: { userId, + rssSubscriptionPath, + reportedUserId: reportedUserId ? parseInt(reportedUserId, 10) : null, + reportedFromUrl, }, }); }, diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js index c6b85489785..3ae3cc2de98 100644 --- a/app/assets/javascripts/users/profile/index.js +++ b/app/assets/javascripts/users/profile/index.js @@ -13,7 +13,7 @@ export const initReportAbuse = () => { name: 'ReportAbuseButtonRoot', provide: { reportAbusePath, - reportedUserId: parseInt(reportedUserId, 10), + reportedUserId: reportedUserId ? parseInt(reportedUserId, 10) : null, reportedFromUrl, }, render(createElement) { diff --git a/app/assets/javascripts/users_select/constants.js b/app/assets/javascripts/users_select/constants.js index c100c1f4ca5..c2ca2ca3782 100644 --- a/app/assets/javascripts/users_select/constants.js +++ b/app/assets/javascripts/users_select/constants.js @@ -1,12 +1,10 @@ export const AJAX_USERS_SELECT_PARAMS_MAP = { project_id: 'projectId', group_id: 'groupId', - skip_ldap: 'skipLdap', todo_filter: 'todoFilter', todo_state_filter: 'todoStateFilter', current_user: 'showCurrentUser', author_id: 'authorId', - skip_users: 'skipUsers', states: 'states', }; diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 66e54b59187..ab707e7e69c 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { escape, template, uniqBy } from 'lodash'; -import { AJAX_USERS_SELECT_PARAMS_MAP } from 'ee_else_ce/users_select/constants'; +import { AJAX_USERS_SELECT_PARAMS_MAP } from '~/users_select/constants'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { TYPE_MERGE_REQUEST } from '~/issues/constants'; import { isUserBusy } from '~/set_status_modal/utils'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue index ebf42fa0be0..c7cfbece611 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue @@ -1,4 +1,5 @@ <script> +// eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import createStore from '../stores/artifacts_list'; import ArtifactsList from './artifacts_list.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue index 41edbc83cdb..8290e7e9232 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED } from './constants'; import DeploymentActions from './deployment_actions.vue'; @@ -36,7 +37,7 @@ export default { </script> <template> - <div class="deploy-heading"> + <div class="deploy-heading gl-px-5"> <div class="ci-widget media"> <div class="media-body"> <div class="deploy-body"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue index 501f5f1523f..98d334cbba1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue @@ -49,11 +49,7 @@ export default { }; </script> <template> - <mr-collapsible-extension - v-if="showCollapsedDeployments" - :title="__('View all environments.')" - data-testid="mr-collapsed-deployments" - > + <mr-collapsible-extension v-if="showCollapsedDeployments" :title="__('View all environments.')"> <template #header> <div class="gl-mr-3 gl-line-height-normal"> <gl-sprintf :message="multipleDeploymentsTitle"> 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 f7c0f960c0e..31bf62b7e52 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 @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue index cd4e31e0dae..9939152074b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlSkeletonLoader } from '@gitlab/ui'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index e435dc56503..b7017cebda3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -48,7 +48,7 @@ export default { <div class="d-flex align-items-center pl-3 gl-py-3"> <div v-if="hasError" class="ci-widget media"> <div class="media-body"> - <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state"> + <span class="gl-font-sm gl-ml-7 gl-line-height-24 js-error-state"> {{ title }} </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index 26527361b2e..d82cb57e78c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -90,11 +90,18 @@ export default { : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings }, mergeInfo2() { - return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings + return this.isFork + ? `git push "${this.sourceProjectDefaultUrl}" ${this.escapedForkPushBranch}` // eslint-disable-line @gitlab/require-i18n-strings + : `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings }, escapedForkBranch() { return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`); }, + escapedForkPushBranch() { + return escapeShellString( + `${this.sourceProjectPath}-${this.sourceBranch}:${this.sourceBranch}`, + ); + }, escapedSourceBranch() { return escapeShellString(this.sourceBranch); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 4e16b92fc05..e94e0fbe6dc 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 @@ -13,7 +13,7 @@ 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 PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import LegacyPipelineMiniGraph from '~/pipelines/components/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'; @@ -27,8 +27,8 @@ export default { GlIcon, GlSprintf, GlTooltip, + LegacyPipelineMiniGraph, PipelineArtifacts, - PipelineMiniGraph, TimeAgoTooltip, TooltipOnTruncate, }, @@ -194,7 +194,7 @@ export default { </p> </template> <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="gl-align-self-center gl-mr-3"> + <a :href="status.details_path" class="gl-align-self-start gl-mt-2 gl-mr-3"> <ci-icon :status="status" :size="24" class="gl-display-flex" /> </a> <div class="ci-widget-container d-flex"> @@ -203,16 +203,41 @@ export default { <div data-testid="pipeline-info-container" data-qa-selector="merge_request_pipeline_info_content" + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-justify-content-space-between" > - {{ pipeline.details.event_type_name }} - <gl-link - :href="pipeline.path" - class="pipeline-id" - data-testid="pipeline-id" - data-qa-selector="pipeline_link" - >#{{ pipeline.id }}</gl-link + <p + class="mr-pipeline-title gl-m-0! gl-mr-3! gl-font-weight-bold gl-line-height-32 gl-text-gray-900" > - {{ pipeline.details.status.label }} + {{ pipeline.details.event_type_name }} + <gl-link + :href="pipeline.path" + class="pipeline-id" + data-testid="pipeline-id" + data-qa-selector="pipeline_link" + >#{{ pipeline.id }}</gl-link + > + {{ pipeline.details.status.label }} + </p> + <div + class="gl-align-items-center gl-display-inline-flex gl-flex-grow-1 gl-justify-content-space-between" + > + <legacy-pipeline-mini-graph + v-if="pipeline.details.stages" + :downstream-pipelines="downstreamPipelines" + :is-merge-train="isMergeTrain" + :pipeline-path="pipeline.path" + :stages="pipeline.details.stages" + :upstream-pipeline="pipeline.triggered_by" + /> + <pipeline-artifacts + :pipeline-id="pipeline.id" + :artifacts="artifacts" + class="gl-ml-3" + /> + </div> + </div> + <p data-testid="pipeline-details-container" class="gl-font-sm gl-text-gray-500 gl-m-0"> + {{ pipeline.details.event_type_name }} {{ pipeline.details.status.label }} <template v-if="hasCommitInfo"> {{ s__('Pipeline|for') }} <gl-link @@ -228,7 +253,7 @@ export default { v-safe-html="sourceBranchLink" :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate gl-font-weight-normal" + class="label-branch label-truncate gl-font-weight-normal gl-vertical-align-text-bottom" /> </template> <template v-if="finishedAt"> @@ -238,8 +263,8 @@ export default { data-testid="finished-at" /> </template> - </div> - <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage"> + </p> + <div v-if="pipeline.coverage" class="coverage gl-mt-1" data-testid="pipeline-coverage"> {{ s__('Pipeline|Test coverage') }} {{ pipeline.coverage }}% <span v-if="pipelineCoverageDelta" @@ -275,19 +300,6 @@ export default { </div> </div> </div> - <div> - <span class="gl-align-items-center gl-display-inline-flex"> - <pipeline-mini-graph - v-if="pipeline.details.stages" - :downstream-pipelines="downstreamPipelines" - :is-merge-train="isMergeTrain" - :pipeline-path="pipeline.path" - :stages="pipeline.details.stages" - :upstream-pipeline="pipeline.triggered_by" - /> - <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" /> - </span> - </div> </div> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue index c38c253564a..9dd4e76befe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue @@ -19,8 +19,7 @@ export default { }, tertiaryButtons: { type: Array, - required: false, - default: () => [], + required: true, }, }, data() { @@ -76,7 +75,6 @@ export default { <template> <div class="gl-display-flex gl-align-items-flex-start"> <gl-dropdown - v-if="tertiaryButtons.length" v-gl-tooltip :title="__('Options')" :text="dropdownLabel" @@ -102,33 +100,31 @@ export default { {{ btn.text }} </gl-dropdown-item> </gl-dropdown> - <template v-if="tertiaryButtons.length"> - <gl-button - v-for="(btn, index) in tertiaryButtons" - :id="btn.id" - :key="index" - v-gl-tooltip.hover - :title="setTooltip(btn)" - :href="btn.href" - :target="btn.target" - :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" - :data-clipboard-text="btn.dataClipboardText" - :data-qa-selector="actionButtonQaSelector(btn)" - :data-method="btn.dataMethod" - :icon="btn.icon" - :data-testid="btn.testId || 'extension-actions-button'" - :variant="btn.variant || 'confirm'" - :loading="btn.loading" - :disabled="btn.loading" - category="tertiary" - size="small" - class="gl-display-none gl-md-display-block gl-float-left" - @click="onClickAction(btn)" - > - <template v-if="btn.text"> - {{ btn.text }} - </template> - </gl-button> - </template> + <gl-button + v-for="(btn, index) in tertiaryButtons" + :id="btn.id" + :key="index" + v-gl-tooltip.hover + :title="setTooltip(btn)" + :href="btn.href" + :target="btn.target" + :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" + :data-clipboard-text="btn.dataClipboardText" + :data-qa-selector="actionButtonQaSelector(btn)" + :data-method="btn.dataMethod" + :icon="btn.icon" + :data-testid="btn.testId || 'extension-actions-button'" + :variant="btn.variant || 'confirm'" + :loading="btn.loading" + :disabled="btn.loading" + category="tertiary" + size="small" + class="gl-display-none gl-md-display-block gl-float-left" + @click="onClickAction(btn)" + > + <template v-if="btn.text"> + {{ btn.text }} + </template> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 258fa4edcda..9bb39ba22e0 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,8 +5,9 @@ export default { import( '~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue' ), - MrTerraformWidget: () => import('~/vue_merge_request_widget/extensions/terraform/index.vue'), + MrCodeQualityWidget: () => + import('~/vue_merge_request_widget/extensions/code_quality/index.vue'), }, props: { @@ -21,8 +22,14 @@ export default { return this.mr.terraformReportsPath && 'MrTerraformWidget'; }, + codeQualityWidget() { + return this.mr.codequalityReportsPath ? 'MrCodeQualityWidget' : undefined; + }, + widgets() { - return [this.terraformPlansWidget, 'MrSecurityWidget'].filter((w) => w); + return [this.codeQualityWidget, 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 ec979861283..618d1e71f81 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 @@ -52,6 +52,9 @@ export default { shouldShowThirdLevel() { return this.data.children?.length > 0 && this.level === 2; }, + hasActionButtons() { + return this.data.actions?.length > 0; + }, }, methods: { onClickedAction(action) { @@ -73,15 +76,22 @@ export default { <template #body> <div class="gl-w-full gl-display-flex" :class="{ 'gl-flex-direction-column': level === 1 }"> <div class="gl-display-flex gl-flex-grow-1"> - <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column"> - <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p> - <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link> - <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p> + <div class="gl-display-flex gl-flex-grow-1 gl-align-items-baseline"> + <div> + <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p> + <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link> + <p + v-if="data.supportingText" + v-safe-html="generatedSupportingText" + class="gl-mb-0" + ></p> + </div> <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> {{ data.badge.text }} </gl-badge> </div> <actions + v-if="hasActionButtons" :widget="widgetName" :tertiary-buttons="data.actions" class="gl-ml-auto gl-pl-3" 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 e327d848d8f..2c8bf90064e 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 @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; @@ -16,14 +17,19 @@ import DynamicContent from './dynamic_content.vue'; import StatusIcon from './status_icon.vue'; import ActionButtons from './action_buttons.vue'; -const FETCH_TYPE_COLLAPSED = 'collapsed'; -const FETCH_TYPE_EXPANDED = 'expanded'; const WIDGET_PREFIX = 'Widget'; const MISSING_RESPONSE_HEADERS = 'MR Widget: raesponse object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.'; +const LOADING_STATE_COLLAPSED = 'collapsed'; +const LOADING_STATE_EXPANDED = 'expanded'; +const LOADING_STATE_STATUS_ICON = 'status_icon'; + export default { MISSING_RESPONSE_HEADERS, + LOADING_STATE_COLLAPSED, + LOADING_STATE_EXPANDED, + LOADING_STATE_STATUS_ICON, components: { ActionButtons, @@ -42,20 +48,29 @@ export default { SafeHtml, }, props: { - /** - * @param {value.collapsed} Object - * @param {value.expanded} Object - */ - value: { - type: Object, - required: false, - default: () => ({}), - }, loadingText: { type: String, required: false, default: __('Loading'), }, + // Use this property when you need to control the loading state from the + // parent component. + loadingState: { + type: String, + required: false, + default: undefined, + validator: (s) => { + if (!s) { + return true; + } + + return [ + LOADING_STATE_EXPANDED, + LOADING_STATE_COLLAPSED, + LOADING_STATE_STATUS_ICON, + ].includes(s); + }, + }, errorText: { type: String, required: false, @@ -158,7 +173,7 @@ export default { return { isExpandedForTheFirstTime: true, isCollapsed: true, - isLoading: true, + isLoadingCollapsedContent: true, isLoadingExpandedContent: false, summaryError: null, contentError: null, @@ -166,6 +181,12 @@ export default { }; }, computed: { + isSummaryLoading() { + return this.isLoadingCollapsedContent || this.loadingState === LOADING_STATE_COLLAPSED; + }, + shouldShowLoadingIcon() { + return this.isSummaryLoading || this.loadingState === LOADING_STATE_STATUS_ICON; + }, generatedSummary() { return generateText(this.summary?.title || ''); }, @@ -192,7 +213,7 @@ export default { }, immediate: true, }, - isLoading(newValue) { + isLoadingCollapsedContent(newValue) { this.$emit('is-loading', newValue); }, }, @@ -202,18 +223,18 @@ export default { } }, async mounted() { - this.isLoading = true; + this.isLoadingCollapsedContent = true; this.telemetryHub?.viewed(); try { if (this.fetchCollapsedData) { - await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED); + await this.fetch(this.fetchCollapsedData); } } catch { this.summaryError = this.errorText; } - this.isLoading = false; + this.isLoadingCollapsedContent = false; }, methods: { onActionClick(action) { @@ -240,7 +261,7 @@ export default { this.contentError = null; try { - await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED); + await this.fetch(this.fetchExpandedData); } catch { this.contentError = this.errorText; @@ -251,7 +272,7 @@ export default { this.isLoadingExpandedContent = false; }, - fetch(handler, dataType) { + fetch(handler) { const requests = this.multiPolling ? handler() : [handler]; const promises = requests.map((request) => { @@ -288,9 +309,7 @@ export default { }); }); - return Promise.all(promises).then((data) => { - this.$emit('input', { ...this.value, [dataType]: this.multiPolling ? data : data[0] }); - }); + return Promise.all(promises); }, }, failedStatusIcon: EXTENSION_ICONS.failed, @@ -306,7 +325,7 @@ export default { <status-icon :level="1" :name="widgetName" - :is-loading="isLoading" + :is-loading="shouldShowLoadingIcon" :icon-name="summaryStatusIcon" /> <div @@ -316,9 +335,9 @@ export default { <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> <span v-if="summaryError">{{ summaryError }}</span> <slot v-else name="summary" - ><div v-safe-html="isLoading ? loadingText : generatedSummary"></div> + ><div v-safe-html="isSummaryLoading ? loadingText : generatedSummary"></div> <div - v-if="!isLoading && generatedSubSummary" + v-if="!isSummaryLoading && generatedSubSummary" v-safe-html="generatedSubSummary" class="gl-font-sm gl-text-gray-700" ></div @@ -356,7 +375,7 @@ export default { </slot> </div> <div - v-if="isCollapsible && !isLoading" + v-if="isCollapsible && !isSummaryLoading" class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" > <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index a59f48fb8b2..1a469f9b7bb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -13,12 +13,18 @@ export const WARNING = 'warning'; export const INFO = 'info'; export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds'; +export const MWCP_MERGE_STRATEGY = 'merge_when_checks_pass'; export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds'; export const MT_MERGE_STRATEGY = 'merge_train'; export const PIPELINE_FAILED_STATE = 'failed'; -export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY]; +export const AUTO_MERGE_STRATEGIES = [ + MWPS_MERGE_STRATEGY, + MTWPS_MERGE_STRATEGY, + MT_MERGE_STRATEGY, + MWCP_MERGE_STRATEGY, +]; // SP - "Suggest Pipelines" export const SP_TRACK_LABEL = 'no_pipeline_noticed'; 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 new file mode 100644 index 00000000000..d30acf24684 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue @@ -0,0 +1,157 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import axios from '~/lib/utils/axios_utils'; +import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; +import { i18n, codeQualityPrefixes } from './constants'; + +const translations = i18n; + +export default { + name: 'WidgetCodeQuality', + components: { + MrWidget, + }, + i18n: translations, + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + pollingFinished: false, + hasError: false, + collapsedData: {}, + poll: null, + }; + }, + computed: { + summary() { + const { new_errors, resolved_errors } = this.collapsedData; + + if (!this.pollingFinished) { + return { title: i18n.loading }; + } else if (this.hasError) { + return { title: i18n.error }; + } else if ( + this.collapsedData?.new_errors?.length >= 1 && + this.collapsedData?.resolved_errors?.length >= 1 + ) { + return { + title: i18n.improvementAndDegradationCopy( + i18n.findings(resolved_errors, codeQualityPrefixes.fixed), + i18n.findings(new_errors, codeQualityPrefixes.new), + ), + }; + } else 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) { + return { title: i18n.singularCopy(i18n.findings(new_errors, codeQualityPrefixes.new)) }; + } + return { title: i18n.noChanges }; + }, + expandedData() { + const fullData = []; + this.collapsedData?.new_errors?.forEach((e) => { + fullData.push({ + text: e.check_name + ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}` + : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, + link: { + href: e.web_url, + text: `${i18n.prependText} ${e.file_path}:${e.line}`, + }, + icon: { + name: SEVERITY_ICONS_MR_WIDGET[e.severity], + }, + }); + }); + + this.collapsedData?.resolved_errors?.forEach((e) => { + fullData.push({ + text: e.check_name + ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}` + : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, + supportingText: `${i18n.prependText} ${e.file_path}:${e.line}`, + icon: { + name: SEVERITY_ICONS_MR_WIDGET[e.severity], + }, + badge: { + variant: 'neutral', + text: i18n.fixed, + }, + }); + }); + + return fullData; + }, + statusIcon() { + if (this.collapsedData?.new_errors?.length >= 1) { + return EXTENSION_ICONS.warning; + } else if (this.collapsedData?.resolved_errors?.length >= 1) { + return EXTENSION_ICONS.success; + } + return EXTENSION_ICONS.neutral; + }, + shouldCollapse() { + const { new_errors: newErrors, resolved_errors: resolvedErrors } = this.collapsedData; + + if ((newErrors?.length === 0 && resolvedErrors?.length === 0) || this.hasError) { + return false; + } + return true; + }, + apiCodeQualityPath() { + return this.mr.codequalityReportsPath; + }, + }, + methods: { + setCollapsedError(err) { + this.hasError = true; + + Sentry.captureException(err); + }, + fetchCodeQuality() { + return axios + .get(this.apiCodeQualityPath) + .then(({ data, headers = {}, status }) => { + if (status === HTTP_STATUS_OK) { + this.pollingFinished = true; + } + if (data) { + this.collapsedData = data; + } + return { + headers, + status, + data, + }; + }) + .catch((e) => { + return this.setCollapsedError(e); + }); + }, + }, +}; +</script> + +<template> + <mr-widget + :fetch-collapsed-data="fetchCodeQuality" + :error-text="$options.i18n.error" + :has-error="hasError" + :content="expandedData" + :loading-text="$options.i18n.loading" + :summary="summary" + :widget-name="$options.name" + :status-icon-name="statusIcon" + :is-collapsible="shouldCollapse" + /> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index a2f088a7a58..e8b97098a2b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; +import MrWidgetOptions from 'any_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '../vue_shared/translate'; 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 52a2f42f8ec..acdcbf7afd7 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 codeQualityExtension from './extensions/code_quality'; import testReportExtension from './extensions/test_report'; import ReportWidgetContainer from './components/report_widget_container.vue'; import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; @@ -215,9 +214,6 @@ export default { return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline; }, - shouldRenderCodeQuality() { - return this.mr?.codequalityReportsPath; - }, shouldRenderCollaborationStatus() { return this.mr.allowCollaboration && this.mr.isOpen; }, @@ -280,11 +276,6 @@ export default { this.initPostMergeDeploymentsPolling(); } }, - shouldRenderCodeQuality(newVal) { - if (newVal) { - this.registerCodeQualityExtension(); - } - }, shouldShowAccessibilityReport(newVal) { if (newVal) { this.registerAccessibilityExtension(); @@ -534,11 +525,6 @@ export default { registerExtension(accessibilityExtension); } }, - registerCodeQualityExtension() { - if (this.shouldRenderCodeQuality) { - registerExtension(codeQualityExtension); - } - }, registerTestReportExtension() { if (this.shouldRenderTestReport) { registerExtension(testReportExtension); @@ -559,7 +545,6 @@ export default { </header> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" - data-testid="mr-suggest-pipeline" class="mr-widget-workflow" :pipeline-path="mr.mergeRequestAddCiConfigPath" :pipeline-svg-path="mr.pipelinesEmptySvgPath" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js index a2edfa94a48..2bce09f489e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; 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 9ddf8241020..b1c069d9b1e 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 @@ -6,6 +6,7 @@ import { machine } from '~/lib/utils/finite_state_machine'; import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, + MWCP_MERGE_STRATEGY, MWPS_MERGE_STRATEGY, STATE_MACHINE, stateToTransitionMap, @@ -352,6 +353,8 @@ export default class MergeRequestStore { return MTWPS_MERGE_STRATEGY; } else if (availableAutoMergeStrategies.includes(MT_MERGE_STRATEGY)) { return MT_MERGE_STRATEGY; + } else if (availableAutoMergeStrategies.includes(MWCP_MERGE_STRATEGY)) { + return MWCP_MERGE_STRATEGY; } else if (availableAutoMergeStrategies.includes(MWPS_MERGE_STRATEGY)) { return MWPS_MERGE_STRATEGY; } @@ -375,6 +378,14 @@ export default class MergeRequestStore { return false; } + get isApprovalNeeded() { + return this.hasApprovalsAvailable ? !this.isApproved : false; + } + + get preventMerge() { + return this.isApprovalNeeded; + } + // Because the state machine doesn't yet handle every state and transition, // some use-cases will need to force a state that can't be reached by // a known transition. This is undesirable long-term (as it subverts diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue deleted file mode 100644 index 1d6dbef799a..00000000000 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ /dev/null @@ -1,74 +0,0 @@ -<script> -import { - GlDisclosureDropdown, - GlDisclosureDropdownGroup, - GlDisclosureDropdownItem, -} from '@gitlab/ui'; - -export default { - components: { - GlDisclosureDropdown, - GlDisclosureDropdownGroup, - GlDisclosureDropdownItem, - }, - props: { - toggleText: { - type: String, - required: true, - }, - actions: { - type: Array, - required: true, - }, - category: { - type: String, - required: false, - default: 'secondary', - }, - variant: { - type: String, - required: false, - default: 'default', - }, - }, - methods: { - handleItemClick(action) { - return action.handle?.(); - }, - }, -}; -</script> - -<template> - <gl-disclosure-dropdown - :variant="variant" - :category="category" - :toggle-text="toggleText" - data-qa-selector="action_dropdown" - fluid-width - block - @shown="$emit('shown')" - @hidden="$emit('hidden')" - > - <gl-disclosure-dropdown-group class="edit-dropdown-group-width"> - <gl-disclosure-dropdown-item - v-for="action in actions" - :key="action.key" - v-bind="action.attrs" - :item="action" - :data-qa-selector="`${action.key}_menu_item`" - @action="handleItemClick(action)" - > - <template #list-item> - <div class="gl-display-flex gl-flex-direction-column"> - <span class="gl-font-weight-bold gl-mb-2">{{ action.text }}</span> - <span class="gl-text-gray-700"> - {{ action.secondaryText }} - </span> - </div> - </template> - </gl-disclosure-dropdown-item> - </gl-disclosure-dropdown-group> - <slot></slot> - </gl-disclosure-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js b/app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js new file mode 100644 index 00000000000..805a32273f4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/badges/beta_badge.stories.js @@ -0,0 +1,24 @@ +import BetaBadge from './beta_badge.vue'; + +export default { + component: BetaBadge, + title: 'vue_shared/beta-badge', +}; + +const template = ` + <div style="height:600px;" class="gl-display-flex gl-justify-content-center gl-align-items-center"> + <beta-badge :size="size" /> + </div> + `; + +const Template = (args, { argTypes }) => ({ + components: { BetaBadge }, + data() { + return { value: args.value }; + }, + props: Object.keys(argTypes), + template, +}); + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue b/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue new file mode 100644 index 00000000000..e8d33b5538e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue @@ -0,0 +1,67 @@ +<script> +import { GlBadge, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'BetaBadge', + components: { GlBadge, GlPopover }, + i18n: { + badgeLabel: s__('BetaBadge|Beta'), + popoverTitle: s__("BetaBadge|What's Beta?"), + descriptionParagraph: s__( + "BetaBadge|A Beta feature is not production-ready, but is unlikely to change drastically before it's released. We encourage users to try Beta features and provide feedback.", + ), + listIntroduction: s__('BetaBadge|A Beta feature:'), + listItemStability: s__('BetaBadge|May be unstable.'), + listItemDataLoss: s__('BetaBadge|Should not cause data loss.'), + listItemReasonableEffort: s__('BetaBadge|Is supported by a commercially reasonable effort.'), + listItemNearCompletion: s__('BetaBadge|Is complete or near completion.'), + }, + props: { + size: { + type: String, + required: false, + default: 'md', + }, + }, + methods: { + target() { + /** + * BVPopover retrieves the target during the `beforeDestroy` hook to deregister attached + * events. Since during `beforeDestroy` refs are `undefined`, it throws a warning in the + * console because we're trying to access the `$el` property of `undefined`. Optional + * chaining is not working in templates, which is why the method is used. + * + * See more on https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49628#note_464803276 + */ + return this.$refs.badge?.$el; + }, + }, +}; +</script> + +<template> + <div> + <gl-badge ref="badge" href="#" :size="size" variant="neutral" class="gl-cursor-pointer">{{ + $options.i18n.badgeLabel + }}</gl-badge> + <gl-popover + triggers="hover focus click" + :show-close-button="true" + :target="target" + :title="$options.i18n.popoverTitle" + data-testid="beta-badge" + > + <p>{{ $options.i18n.descriptionParagraph }}</p> + + <p class="gl-mb-0">{{ $options.i18n.listIntroduction }}</p> + + <ul class="gl-pl-4"> + <li>{{ $options.i18n.listItemStability }}</li> + <li>{{ $options.i18n.listItemDataLoss }}</li> + <li>{{ $options.i18n.listItemReasonableEffort }}</li> + <li>{{ $options.i18n.listItemNearCompletion }}</li> + </ul> + </gl-popover> + </div> +</template> 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 3e24a35ea39..11ce6afbb1d 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 @@ -2,6 +2,7 @@ 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 ViewerMixin from './mixins'; export default { @@ -27,6 +28,8 @@ export default { this.isLoading = false; await this.$nextTick(); handleBlobRichViewer(this.$refs.content, this.type); + handleLocationHash(); + this.$emit('richContentLoaded'); }); }, safeHtmlConfig: { 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 9023807eba3..9aa7a7d6c49 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -24,6 +24,12 @@ import CiIcon from './ci_icon.vue'; * - On-demand scans list */ +const badgeSizeOptions = { + sm: 'sm', + md: 'md', + lg: 'lg', +}; + export default { components: { CiIcon, @@ -45,10 +51,16 @@ export default { badgeSize: { type: String, required: false, - default: 'md', + default: badgeSizeOptions.md, + validator(value) { + return badgeSizeOptions[value] !== undefined; + }, }, }, computed: { + isSmallBadgeSize() { + return this.badgeSize === badgeSizeOptions.sm; + }, title() { return !this.showText ? this.status?.text : ''; }, @@ -108,6 +120,7 @@ export default { <template> <gl-badge v-gl-tooltip + :class="{ 'gl-pl-0!': isSmallBadgeSize }" :title="title" :href="detailsPath" :size="badgeSize" diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue index b4751d51fcb..7889b558279 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue @@ -1,5 +1,6 @@ <script> import { v4 as uuidv4 } from 'uuid'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { CHART_CONTAINER_HEIGHT } from './constants'; @@ -7,6 +8,7 @@ export default { name: 'CiCdAnalyticsAreaChart', components: { GlAreaChart, + GlSkeletonLoader, }, props: { chartData: { @@ -17,6 +19,11 @@ export default { type: Object, required: true, }, + loading: { + type: Boolean, + required: false, + default: false, + }, }, data: () => ({ chartKey: uuidv4(), @@ -35,7 +42,9 @@ export default { <p> <slot></slot> </p> + <gl-skeleton-loader v-if="loading" :width="300" :lines="3" /> <gl-area-chart + v-else v-bind="$attrs" :key="chartKey" responsive diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index a30b18348ec..d1e1fe162f4 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -18,6 +18,11 @@ export default { required: true, type: Object, }, + loading: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -35,12 +40,22 @@ export default { return sprintf(s__('CiCdAnalytics|Date range: %{range}'), { range: this.chart.range }); }, }, + methods: { + onInput(selectedChart) { + this.selectedChart = selectedChart; + this.$emit('select-chart', selectedChart); + }, + }, }; </script> <template> <div> <div class="gl-display-flex gl-flex-wrap gl-gap-5"> - <segmented-control-button-group v-model="selectedChart" :options="chartRanges" /> + <segmented-control-button-group + :options="chartRanges" + :value="selectedChart" + @input="onInput" + /> <slot name="extend-button-group"></slot> </div> <ci-cd-analytics-area-chart @@ -48,7 +63,9 @@ export default { v-bind="$attrs" :chart-data="chart.data" :area-chart-options="chartOptions" + :loading="loading" > + <slot name="alerts"></slot> <p>{{ dateRange }}</p> <slot name="metrics" :selected-chart="selectedChart"></slot> <template #tooltip-title> diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue index 78db2bf15b0..149082d036a 100644 --- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -110,7 +110,7 @@ export default { <gl-form-input-group max-length="7" type="text" - class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base" + class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base gl-max-w-26" :value="value" :state="state" @input="handleColorChange" diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 388353bc35b..c2f672b2edd 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; import { isString, isEmpty } from 'lodash'; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue index f7b817423de..68da772d1cd 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -1,5 +1,7 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { 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 18f9d26a13d..db0b0ea185b 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 7816c1d74ec..59a8a24baad 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; 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 5b98af8c732..2b3d1b2c1f5 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 @@ -17,12 +17,19 @@ export const OPERATOR_NOT = '!='; export const OPERATOR_NOT_TEXT = __('is not one of'); export const OPERATOR_OR = '||'; export const OPERATOR_OR_TEXT = __('is one of'); +export const OPERATOR_AFTER = '≥'; +export const OPERATOR_AFTER_TEXT = __('on or after'); +export const OPERATOR_BEFORE = '<'; +export const OPERATOR_BEFORE_TEXT = __('before'); export const OPERATORS_IS = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; export const OPERATORS_NOT = [{ value: OPERATOR_NOT, description: OPERATOR_NOT_TEXT }]; export const OPERATORS_OR = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; +export const OPERATORS_AFTER = [{ value: OPERATOR_AFTER, description: OPERATOR_AFTER_TEXT }]; +export const OPERATORS_BEFORE = [{ value: OPERATOR_BEFORE, description: OPERATOR_BEFORE_TEXT }]; export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT]; export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR]; +export const OPERATORS_AFTER_BEFORE = [...OPERATORS_AFTER, ...OPERATORS_BEFORE]; export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; @@ -45,6 +52,13 @@ export const SORT_DIRECTION = { export const FILTERED_SEARCH_TERM = 'filtered-search-term'; +export const TOKEN_EMPTY_SEARCH_TERM = { + type: FILTERED_SEARCH_TERM, + value: { + data: '', + }, +}; + export const TOKEN_TITLE_APPROVED_BY = __('Approved-By'); export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); @@ -62,6 +76,8 @@ export const TOKEN_TITLE_STATUS = __('Status'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); +export const TOKEN_TITLE_CREATED = __('Created date'); +export const TOKEN_TITLE_CLOSED = __('Closed date'); export const TOKEN_TYPE_APPROVED_BY = 'approved-by'; export const TOKEN_TYPE_ASSIGNEE = 'assignee'; @@ -88,3 +104,5 @@ export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; +export const TOKEN_TYPE_CREATED = 'created'; +export const TOKEN_TYPE_CLOSED = 'closed'; 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 c72356dc713..f31d4d53a23 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 @@ -105,6 +105,11 @@ export default { required: false, default: false, }, + searchTextOptionLabel: { + type: String, + required: false, + default: __('Search for this text'), + }, }, data() { return { @@ -362,7 +367,7 @@ export default { :close-button-title="__('Close')" :clear-recent-searches-text="__('Clear recent searches')" :no-recent-searches-text="__(`You don't have any recent searches`)" - :search-text-option-label="__('Search for this text')" + :search-text-option-label="searchTextOptionLabel" :show-friendly-text="showFriendlyText" :terms-as-tokens="termsAsTokens" class="flex-grow-1" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 65c783ada55..d33c0bb4708 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -1,4 +1,4 @@ -import { isEmpty, uniqWith, isEqual } from 'lodash'; +import { isEmpty, uniqWith, isEqual, isString } from 'lodash'; import AccessorUtilities from '~/lib/utils/accessor'; import { queryToObject } from '~/lib/utils/url_utility'; @@ -62,17 +62,30 @@ export function prepareTokens(filters = {}) { }, []); } +/** + * This function takes a token array and translates it into a filter object + * @param filters + * @returns A Filter Object + */ export function processFilters(filters) { return filters.reduce((acc, token) => { - const { type, value } = token; - const { operator } = value; - const tokenValue = value.data; + let type; + let value; + let operator; + if (typeof token === 'string') { + type = FILTERED_SEARCH_TERM; + value = token; + } else { + type = token.type; + operator = token.value.operator; + value = token.value.data; + } if (!acc[type]) { acc[type] = []; } - acc[type].push({ value: tokenValue, operator }); + acc[type].push({ value, operator }); return acc; }, {}); } @@ -89,59 +102,93 @@ function filteredSearchQueryParam(filter) { * { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] } * gets translated into: * { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' } + * By default it supports '=' and '!=' operators. This can be extended by providing the `customOperators` option * @param {Object} filters * @param {Object} filters.myFilterName a single filter value or an array of filters * @param {Object} options * @param {Object} [options.filteredSearchTermKey] if set, 'filtered-search-term' filters are assigned to this key, 'search' is suggested + * @param {Object} [options.customOperators] Allows to extend the supported operators, e.g. + * + * filterToQueryObject({foo: [{ value: '100', operator: '>' }]}, {customOperators: {operator: '>',prefix: 'gt'}}) + * returns {gt[foo]: '100'} + * It's also possible to restrict custom operators to a given key by setting `applyOnlyToKey` string attribute. + * * @return {Object} query object with both filter name and not-name with values */ export function filterToQueryObject(filters = {}, options = {}) { - const { filteredSearchTermKey } = options; + const { filteredSearchTermKey, customOperators } = options; return Object.keys(filters).reduce((memo, key) => { const filter = filters[key]; - if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) { + if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM && filter) { return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) }; } - let selected; - let unselected; + const operators = [ + { operator: '=' }, + { operator: '!=', prefix: 'not' }, + ...(customOperators ?? []), + ]; - if (Array.isArray(filter)) { - selected = filter.filter((item) => item.operator === '=').map((item) => item.value); - unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value); - } else { - selected = filter?.operator === '=' ? filter.value : null; - unselected = filter?.operator === '!=' ? filter.value : null; - } + const result = {}; - if (isEmpty(selected)) { - selected = null; - } - if (isEmpty(unselected)) { - unselected = null; + for (const op of operators) { + const { operator, prefix, applyOnlyToKey } = op; + + if (!applyOnlyToKey || applyOnlyToKey === key) { + let value; + if (Array.isArray(filter)) { + value = filter.filter((item) => item.operator === operator).map((item) => item.value); + } else { + value = filter?.operator === operator ? filter.value : null; + } + if (isEmpty(value)) { + value = null; + } + if (prefix) { + result[`${prefix}[${key}]`] = value; + } else { + result[key] = value; + } + } } - return { ...memo, [key]: selected, [`not[${key}]`]: unselected }; + return { ...memo, ...result }; }, {}); } /** - * Extracts filter name from url name, e.g. `not[my_filter]` => `my_filter` - * and returns the operator with it depending on the filter name + * Extracts filter name from url name and operator, e.g. + * e.g. input: not[my_filter]` output: {filterName: `my_filter`, operator: '!='}` + * + * By default it supports filter with the format `my_filter=foo` and `not[my_filter]=bar`. This can be extended with the `customOperators` option. * @param {String} filterName from url + * @param {Object.customOperators} It allows to extend the supported parameter, e.g. + * input: 'gt[filter]', { customOperators: [{ operator: '>', prefix: 'gt' }]}) + * output: '{filterName: 'filter', operator: '>'} * @return {Object} * @return {Object.filterName} extracted filter name * @return {Object.operator} `=` or `!=` */ -function extractNameAndOperator(filterName) { - // eslint-disable-next-line @gitlab/require-i18n-strings - if (filterName.startsWith('not[') && filterName.endsWith(']')) { - return { filterName: filterName.slice(4, -1), operator: '!=' }; - } +function extractNameAndOperator(filterName, customOperators) { + const ops = [ + { + prefix: 'not', + operator: '!=', + }, + ...(customOperators ?? []), + ]; - return { filterName, operator: '=' }; + const operator = ops.find( + ({ prefix }) => filterName.startsWith(`${prefix}[`) && filterName.endsWith(']'), + ); + + if (!operator) { + return { filterName, operator: '=' }; + } + const { prefix } = operator; + return { filterName: filterName.slice(prefix.length + 1, -1), operator: operator.operator }; } /** @@ -151,11 +198,7 @@ function extractNameAndOperator(filterName) { */ function filteredSearchTermValue(value) { const values = Array.isArray(value) ? value : [value]; - return values - .filter((term) => term) - .join(' ') - .split(' ') - .map((term) => ({ value: term })); + return [{ value: values.filter((term) => term).join(' ') }]; } /** @@ -163,15 +206,21 @@ function filteredSearchTermValue(value) { * '?myFilterName=foo' * gets translated into: * { myFilterName: { value: 'foo', operator: '=' } } - * @param {String} query URL query string, e.g. from `window.location.search` - * @param {Object} options + * By default it only support '=' and '!=' operators. This can be extended with the customOperator option. + * @param {String|Object} query URL query string or object, e.g. from `window.location.search` or `this.$route.query` * @param {Object} options * @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested * @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped + * @param {Object} [options.customOperator] It allows to extend the supported parameter, e.g. + * input: 'gt[myFilter]=100', { customOperators: [{ operator: '>', prefix: 'gt' }]}) + * output: '{ myFilter: {value: '100', operator: '>'}} * @return {Object} filter object with filter names and their values */ -export function urlQueryToFilter(query = '', { filteredSearchTermKey, filterNamesAllowList } = {}) { - const filters = queryToObject(query, { gatherArrays: true }); +export function urlQueryToFilter( + query = '', + { filteredSearchTermKey, filterNamesAllowList, customOperators } = {}, +) { + const filters = isString(query) ? queryToObject(query, { gatherArrays: true }) : query; return Object.keys(filters).reduce((memo, key) => { const value = filters[key]; if (!value) { @@ -184,7 +233,7 @@ export function urlQueryToFilter(query = '', { filteredSearchTermKey, filterName }; } - const { filterName, operator } = extractNameAndOperator(key); + const { filterName, operator } = extractNameAndOperator(key, customOperators); if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) { return memo; } diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue new file mode 100644 index 00000000000..4446886dc88 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue @@ -0,0 +1,73 @@ +<script> +import { GlDatepicker, GlFilteredSearchToken } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +export default { + components: { + GlDatepicker, + GlFilteredSearchToken, + }, + props: { + active: { + type: Boolean, + required: true, + }, + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + selectedDate: null, + }; + }, + methods: { + selectValue(value) { + this.selectedDate = formatDate(value, 'yyyy-mm-dd'); + }, + close(submitValue) { + if (this.selectedDate == null) { + return; + } + + submitValue(this.selectedDate); + }, + handle() { + const listeners = { ...this.$listeners }; + // If we don't remove this, clicking the month/year in the datepicker will deactivate + delete listeners.deactivate; + return listeners; + }, + }, + dataSegmentInputAttributes: { + id: 'glfs-datepicker', + placeholder: 'YYYY-MM-DD', + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + :value="value" + :active="active" + :data-segment-input-attributes="$options.dataSegmentInputAttributes" + v-bind="{ ...$props, ...$attrs }" + v-on="handle()" + > + <template #before-data-segment-input="{ submitValue }"> + <gl-datepicker + class="gl-display-none!" + target="#glfs-datepicker" + :container="null" + @input="selectValue($event)" + @close="close(submitValue)" + /> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 0ce784fab1a..f17e88d73a4 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -48,7 +48,7 @@ export default { this.emojis = Array.isArray(response) ? response : response.data; }) .catch(() => { - createAlert({ message: __('There was a problem fetching emojis.') }); + createAlert({ message: __('There was a problem fetching emoji.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/form/index.js b/app/assets/javascripts/vue_shared/components/form/index.js new file mode 100644 index 00000000000..65bc14dd807 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/index.js @@ -0,0 +1,59 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue'; + +export function initInputCopyToggleVisibility() { + const els = document.getElementsByClassName('js-input-copy-visibility'); + + Array.from(els).forEach((el) => { + const { + name, + value, + initialVisibility, + showToggleVisibilityButton, + showCopyButton, + copyButtonTitle, + readonly, + formInputGroupProps, + formGroupAttributes, + } = el.dataset; + + const parsedFormInputGroupProps = convertObjectPropsToCamelCase( + JSON.parse(formInputGroupProps || '{}'), + ); + const parsedFormGroupAttributes = convertObjectPropsToCamelCase( + JSON.parse(formGroupAttributes || '{}'), + ); + + return new Vue({ + el, + data() { + return { + value, + }; + }, + render(createElement) { + return createElement(InputCopyToggleVisibility, { + props: { + value: this.value, + initialVisibility, + showToggleVisibilityButton, + showCopyButton, + copyButtonTitle, + readonly, + formInputGroupProps: { + name, + ...parsedFormInputGroupProps, + }, + }, + attrs: parsedFormGroupAttributes, + on: { + input: (newValue) => { + this.value = newValue; + }, + }, + }); + }, + }); + }); +} diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js index 377f1e7c136..531ed5fe0ea 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js @@ -8,16 +8,21 @@ export default { const defaultProps = { value: 'hR8x1fuJbzwu5uFKLf9e', formInputGroupProps: { class: 'gl-form-input-xl' }, + readonly: false, }; const Template = (args, { argTypes }) => ({ components: { InputCopyToggleVisibility }, + data() { + return { value: args.value }; + }, props: Object.keys(argTypes), template: `<input-copy-toggle-visibility - :value="value" + v-model="value" :initial-visibility="initialVisibility" :show-toggle-visibility-button="showToggleVisibilityButton" :show-copy-button="showCopyButton" + :readonly="readonly" :form-input-group-props="formInputGroupProps" :copy-button-title="copyButtonTitle" />`, diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index dea279890b1..ebc6b2cd740 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -8,6 +8,7 @@ import { } from '@gitlab/ui'; import { __ } from '~/locale'; +import { Mousetrap, MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { @@ -52,6 +53,11 @@ export default { required: false, default: __('Copy'), }, + readonly: { + type: Boolean, + required: false, + default: false, + }, formInputGroupProps: { type: Object, required: false, @@ -59,8 +65,19 @@ export default { return {}; }, }, + size: { + type: String, + required: false, + default: null, + }, }, data() { + if (!this.readonly && !this.value) { + return { + valueIsVisible: true, + }; + } + return { valueIsVisible: this.initialVisibility, }; @@ -77,33 +94,60 @@ export default { computedValueIsVisible() { return !this.showToggleVisibilityButton || this.valueIsVisible; }, - displayedValue() { - return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20); + inputType() { + return this.computedValueIsVisible ? 'text' : 'password'; }, }, + mounted() { + this.$options.mousetrap = new Mousetrap(this.$refs.input.$el); + this.$options.mousetrap.bind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT, this.handleFormInputCopy); + }, + beforeDestroy() { + this.$options.mousetrap?.unbind(MOUSETRAP_COPY_KEYBOARD_SHORTCUT); + }, + methods: { handleToggleVisibilityButtonClick() { this.valueIsVisible = !this.valueIsVisible; this.$emit('visibility-change', this.valueIsVisible); }, - handleClick() { - this.$refs.input.$el.select(); + async handleClick() { + if (this.readonly) { + this.$refs.input.$el.select(); + } else if (!this.valueIsVisible) { + const { selectionStart, selectionEnd } = this.$refs.input.$el; + this.handleToggleVisibilityButtonClick(); + + setTimeout(() => { + // When the input type is changed from 'password'' to 'text', cursor position is reset in some browsers. + // This makes clicking to edit difficult due to typing in unexpected location, so we preserve the cursor position / selection + this.$refs.input.$el.setSelectionRange(selectionStart, selectionEnd); + }, 0); + } }, handleCopyButtonClick() { this.$emit('copy'); }, - handleFormInputCopy(event) { - this.handleCopyButtonClick(); - + async handleFormInputCopy() { + // Value will be copied by native browser behavior if (this.computedValueIsVisible) { return; } - event.clipboardData.setData('text/plain', this.value); - event.preventDefault(); + try { + // user is trying to copy from the password input, set their clipboard for them + await navigator.clipboard?.writeText(this.value); + this.handleCopyButtonClick(); + } catch (e) { + // Nothing we can do here, best effort to set clipboard value + } + }, + handleInput(newValue) { + this.$emit('input', newValue); }, }, + mousetrap: null, }; </script> <template> @@ -111,11 +155,13 @@ export default { <gl-form-input-group> <gl-form-input ref="input" - readonly + :readonly="readonly" + :size="size" class="gl-font-monospace! gl-cursor-default!" v-bind="formInputGroupProps" - :value="displayedValue" - @copy="handleFormInputCopy" + :value="value" + :type="inputType" + @input="handleInput" @click="handleClick" /> diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue index 5d6633fa6d7..b17681319f3 100644 --- a/app/assets/javascripts/vue_shared/components/form/title.vue +++ b/app/assets/javascripts/vue_shared/components/form/title.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlFormInput, GlFormGroup } from '@gitlab/ui'; diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue index dd0c0358ef6..abcd2f681f8 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -1,5 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js new file mode 100644 index 00000000000..235523054c3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js @@ -0,0 +1,19 @@ +import { groups } from 'jest/vue_shared/components/groups_list/mock_data'; +import GroupsList from './groups_list.vue'; + +export default { + component: GroupsList, + title: 'vue_shared/groups_list', +}; + +const Template = (args, { argTypes }) => ({ + components: { GroupsList }, + props: Object.keys(argTypes), + template: '<groups-list v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + groups, + showGroupIcon: true, +}; 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 new file mode 100644 index 00000000000..7da45169fee --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue @@ -0,0 +1,29 @@ +<script> +import GroupsListItem from './groups_list_item.vue'; + +export default { + components: { GroupsListItem }, + props: { + groups: { + type: Array, + required: true, + }, + showGroupIcon: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <groups-list-item + v-for="group in groups" + :key="group.id" + :group="group" + :show-group-icon="showGroupIcon" + /> + </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 new file mode 100644 index 00000000000..8a301cd0dd0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue @@ -0,0 +1,168 @@ +<script> +import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui'; + +import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { __ } from '~/locale'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + i18n: { + subgroups: __('Subgroups'), + projects: __('Projects'), + directMembers: __('Direct members'), + showMore: __('Show more'), + showLess: __('Show less'), + }, + avatarSize: { default: 32, md: 48 }, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, + components: { + GlAvatarLabeled, + GlIcon, + UserAccessRoleBadge, + GlTruncateText, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + group: { + type: Object, + required: true, + }, + showGroupIcon: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + visibility() { + return this.group.visibility; + }, + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.visibility]; + }, + visibilityTooltip() { + return GROUP_VISIBILITY_TYPE[this.visibility]; + }, + accessLevel() { + return this.group.accessLevel?.integerValue; + }, + accessLevelLabel() { + return ACCESS_LEVEL_LABELS[this.accessLevel]; + }, + shouldShowAccessLevel() { + return this.accessLevel !== undefined; + }, + groupIconName() { + return this.group.parent ? 'subgroup' : 'group'; + }, + statsPadding() { + return this.showGroupIcon ? 'gl-pl-11' : 'gl-pl-8'; + }, + descendantGroupsCount() { + return numberToMetricPrefix(this.group.descendantGroupsCount); + }, + projectsCount() { + return numberToMetricPrefix(this.group.projectsCount); + }, + groupMembersCount() { + return numberToMetricPrefix(this.group.groupMembersCount); + }, + }, +}; +</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> + </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> + <div + v-gl-tooltip="$options.i18n.projects" + :aria-label="$options.i18n.projects" + class="gl-text-secondary" + data-testid="projects-count" + > + <gl-icon name="project" /> + <span>{{ projectsCount }}</span> + </div> + <div + v-gl-tooltip="$options.i18n.directMembers" + :aria-label="$options.i18n.directMembers" + class="gl-text-secondary" + data-testid="members-count" + > + <gl-icon name="users" /> + <span>{{ groupMembersCount }}</span> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index 92d468cf970..0e82ef3aa65 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -26,6 +26,11 @@ export default { required: false, default: 'question-o', }, + triggerClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, methods: { targetFn() { @@ -36,7 +41,13 @@ export default { </script> <template> <span> - <gl-button ref="popoverTrigger" variant="link" :icon="icon" :aria-label="__('Help')" /> + <gl-button + ref="popoverTrigger" + :class="triggerClass" + variant="link" + :icon="icon" + :aria-label="__('Help')" + /> <gl-popover :target="targetFn" v-bind="options"> <template v-if="options.title" #title> <span v-safe-html="options.title"></span> diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js index b447822b1e0..e098103adde 100644 --- a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js +++ b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js @@ -6,7 +6,7 @@ export const initListboxInputs = () => { const els = [...document.querySelectorAll('.js-listbox-input')]; els.forEach((el, index) => { - const { label, description, name, defaultToggleText, value = null } = el.dataset; + const { label, description, name, defaultToggleText, value = null, toggleClass } = el.dataset; const { id } = el; const items = JSON.parse(el.dataset.items); @@ -34,6 +34,7 @@ export const initListboxInputs = () => { block: parseBoolean(el.dataset.block), fluidWidth: parseBoolean(el.dataset.fluidWidth), items, + toggleClass, }, attrs: { id, diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue index a59a7494472..d20593d104e 100644 --- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue @@ -62,6 +62,11 @@ export default { required: false, default: GlCollapsibleListbox.props.block.default, }, + toggleClass: { + type: [Array, String, Object], + required: false, + default: null, + }, }, data() { return { @@ -117,7 +122,7 @@ export default { }, toggleText() { return this.selected - ? this.allOptions.find((option) => option.value === this.selected).text + ? this.allOptions.find((option) => option.value === this.selected)?.text : this.defaultToggleText; }, }, @@ -134,6 +139,7 @@ export default { <gl-collapsible-listbox :selected="selected" :toggle-text="toggleText" + :toggle-class="toggleClass" :items="filteredItems" :searchable="isSearchable" :no-results-text="$options.i18n.noResultsText" 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 966a5556d24..b1c6f5e6056 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 @@ -1,7 +1,10 @@ <script> import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui'; 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'; export default { apollo: { @@ -18,6 +21,7 @@ export default { GlButton, GlTooltip, }, + mixins: [InternalEvents.mixin()], props: { newCommentTemplatePath: { type: String, @@ -52,9 +56,14 @@ export default { this.commentTemplateSearch = search; }, onSelect(id) { + const isInMr = Boolean(getDerivedMergeRequestInformation({ endpoint: window.location }).id); const savedReply = this.savedReplies.find((r) => r.id === id); 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); + } } }, }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/constants.js b/app/assets/javascripts/vue_shared/components/markdown/constants.js new file mode 100644 index 00000000000..47ef7cccbc2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/constants.js @@ -0,0 +1,2 @@ +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'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 7c569763a75..a26f8f71601 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import $ from 'jquery'; @@ -398,7 +399,6 @@ export default { v-show="previewMarkdown" ref="markdown-preview" class="js-vue-md-preview md-preview-holder gl-px-5" - :class="{ md: !hasSuggestion }" > <suggestions v-if="hasSuggestion" @@ -409,7 +409,7 @@ export default { :help-page-path="helpPagePath" /> <template v-else> - <div v-safe-html:[$options.safeHtmlConfig]="markdownPreview"></div> + <div v-safe-html:[$options.safeHtmlConfig]="markdownPreview" class="md"></div> </template> </div> <div diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 0907e064e01..286a1b87ad0 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; import $ from 'jquery'; @@ -278,6 +279,7 @@ export default { :button-title="__('Insert suggestion')" :cursor-offset="4" :tag-content="lineContent" + tracking-property="codeSuggestion" icon="doc-code" data-qa-selector="suggestion_button" class="js-suggestion-btn" @@ -327,6 +329,7 @@ export default { " :shortcuts="$options.shortcuts.bold" icon="bold" + tracking-property="bold" /> <toolbar-button v-show="!previewMarkdown" @@ -339,6 +342,7 @@ export default { " :shortcuts="$options.shortcuts.italic" icon="italic" + tracking-property="italic" /> <toolbar-button v-if="!restrictedToolBarItems.includes('strikethrough')" @@ -353,6 +357,7 @@ export default { " :shortcuts="$options.shortcuts.strikethrough" icon="strikethrough" + tracking-property="strike" /> <toolbar-button v-if="!restrictedToolBarItems.includes('quote')" @@ -361,6 +366,7 @@ export default { :tag="tag" :button-title="__('Insert a quote')" icon="quote" + tracking-property="blockquote" @click="handleQuote" /> <toolbar-button @@ -369,6 +375,7 @@ export default { tag-block="```" :button-title="__('Insert code')" icon="code" + tracking-property="code" /> <toolbar-button v-show="!previewMarkdown" @@ -382,6 +389,7 @@ export default { " :shortcuts="$options.shortcuts.link" icon="link" + tracking-property="link" /> <toolbar-button v-if="!restrictedToolBarItems.includes('bullet-list')" @@ -390,6 +398,7 @@ export default { tag="- " :button-title="__('Add a bullet list')" icon="list-bulleted" + tracking-property="bulletList" /> <toolbar-button v-if="!restrictedToolBarItems.includes('numbered-list')" @@ -398,6 +407,7 @@ export default { tag="1. " :button-title="__('Add a numbered list')" icon="list-numbered" + tracking-property="orderedList" /> <toolbar-button v-if="!restrictedToolBarItems.includes('task-list')" @@ -406,6 +416,7 @@ export default { tag="- [ ] " :button-title="__('Add a checklist')" icon="list-task" + tracking-property="taskList" /> <toolbar-button v-if="!restrictedToolBarItems.includes('indent')" @@ -420,6 +431,7 @@ export default { :shortcuts="$options.shortcuts.indent" command="indentLines" icon="list-indent" + tracking-property="indent" /> <toolbar-button v-if="!restrictedToolBarItems.includes('outdent')" @@ -434,6 +446,7 @@ export default { :shortcuts="$options.shortcuts.outdent" command="outdentLines" icon="list-outdent" + tracking-property="outdent" /> <toolbar-button v-if="!restrictedToolBarItems.includes('collapsible-section')" @@ -443,6 +456,7 @@ export default { tag-select="Click to expand" :button-title="__('Add a collapsible section')" icon="details-block" + tracking-property="details" /> <toolbar-button v-if="!restrictedToolBarItems.includes('table')" @@ -451,17 +465,15 @@ export default { :prepend="true" :button-title="__('Add a table')" icon="table" + tracking-property="table" /> - <gl-button + <toolbar-button v-if="!previewMarkdown && !restrictedToolBarItems.includes('attach-file')" - v-gl-tooltip - :aria-label="__('Attach a file or image')" - :title="__('Attach a file or image')" - class="gl-mr-3" data-testid="button-attach-file" - category="tertiary" + :button-title="__('Attach a file or image')" icon="paperclip" - size="small" + class="gl-mr-3" + tracking-property="upload" @click="handleAttachFile" /> <drawio-toolbar-button @@ -477,6 +489,7 @@ export default { tag="/" :button-title="__('Add a quick action')" icon="quick-actions" + tracking-property="quickAction" /> <comment-templates-dropdown v-if="!previewMarkdown && newCommentTemplatePath && glFeatures.savedReplies" @@ -484,16 +497,13 @@ export default { @select="insertSavedReply" /> <div v-if="!previewMarkdown" class="full-screen"> - <gl-button + <toolbar-button v-if="!restrictedToolBarItems.includes('full-screen')" - v-gl-tooltip class="js-zen-enter" - category="tertiary" icon="maximize" - size="small" - :title="__('Go full screen')" + :button-title="__('Go full screen')" :prepend="true" - :aria-label="__('Go full screen')" + tracking-property="fullScreen" /> </div> </div> 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 8b8247a5b2c..493b329f1b1 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -13,6 +13,22 @@ import { import MarkdownField from './field.vue'; import eventHub from './eventhub'; +async function sleep(t = 10) { + return new Promise((resolve) => { + setTimeout(resolve, t); + }); +} + +async function waitFor(getEl, interval = 10, timeout = 2000) { + if (timeout <= 0) return null; + + const el = getEl(); + if (el) return el; + + await sleep(interval); + return waitFor(getEl, timeout - interval); +} + export default { components: { LocalStorageSync, @@ -190,8 +206,15 @@ export default { this.$emit(editingMode); this.notifyEditingModeChange(editingMode); }, - notifyEditingModeChange(editingMode) { + async notifyEditingModeChange(editingMode) { this.$emit(editingMode); + + const componentToFocus = + editingMode === EDITING_MODE_CONTENT_EDITOR + ? () => this.$refs.contentEditor + : () => this.$refs.textarea; + + (await waitFor(componentToFocus)).focus(); }, autofocusTextarea() { if (this.autofocus && this.editingMode === EDITING_MODE_MARKDOWN_FIELD) { diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 4423b26560f..532dec337e1 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import Vue from 'vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index d4b1abedc02..a4516fae73d 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { updateText } from '~/lib/utils/text_markdown'; @@ -150,7 +151,7 @@ export default { target="_blank" category="tertiary" size="small" - title="Markdown is supported" + :title="__('Markdown is supported')" class="gl-px-3!" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 636c89c99d4..cf484443c07 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { TOOLBAR_CONTROL_TRACKING_ACTION, MARKDOWN_EDITOR_TRACKING_LABEL } from './tracking'; export default { components: { @@ -66,12 +67,28 @@ export default { required: false, default: () => [], }, + trackingProperty: { + type: String, + required: false, + default: null, + }, }, computed: { shortcutsString() { const shortcutArray = Array.isArray(this.shortcuts) ? this.shortcuts : [this.shortcuts]; return JSON.stringify(shortcutArray); }, + trackingProps() { + const { trackingProperty } = this; + + return trackingProperty + ? { + 'data-track-action': TOOLBAR_CONTROL_TRACKING_ACTION, + 'data-track-label': MARKDOWN_EDITOR_TRACKING_LABEL, + 'data-track-property': trackingProperty, + } + : {}; + }, }, }; </script> @@ -90,6 +107,7 @@ export default { :title="buttonTitle" :aria-label="buttonTitle" :icon="icon" + v-bind="trackingProps" type="button" category="tertiary" size="small" diff --git a/app/assets/javascripts/vue_shared/components/markdown/tracking.js b/app/assets/javascripts/vue_shared/components/markdown/tracking.js index 2628054ae5f..6ce800730c7 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/tracking.js +++ b/app/assets/javascripts/vue_shared/components/markdown/tracking.js @@ -1,14 +1,14 @@ import Tracking from '~/tracking'; -export const EDITOR_TRACKING_LABEL = 'editor_tracking'; -export const EDITOR_TYPE_ACTION = 'editor_type_used'; -export const EDITOR_TYPE_PLAIN_TEXT_EDITOR = 'editor_type_plain_text_editor'; -export const EDITOR_TYPE_RICH_TEXT_EDITOR = 'editor_type_rich_text_editor'; +export const MARKDOWN_EDITOR_TRACKING_LABEL = 'markdown_editor'; +export const RICH_TEXT_EDITOR_TRACKING_LABEL = 'rich_text_editor'; -export const trackSavedUsingEditor = (isRichText, context) => { - Tracking.event(undefined, EDITOR_TYPE_ACTION, { - label: EDITOR_TRACKING_LABEL, - editorType: isRichText ? EDITOR_TYPE_RICH_TEXT_EDITOR : EDITOR_TYPE_PLAIN_TEXT_EDITOR, - context, +export const SAVE_MARKDOWN_TRACKING_ACTION = 'save_markdown'; +export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control'; + +export const trackSavedUsingEditor = (isRichText, property) => { + Tracking.event(undefined, SAVE_MARKDOWN_TRACKING_ACTION, { + label: isRichText ? RICH_TEXT_EDITOR_TRACKING_LABEL : MARKDOWN_EDITOR_TRACKING_LABEL, + property, }); }; diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue index 2cadc87eca3..96eede98fa1 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue @@ -1,5 +1,6 @@ <script> import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue index bbbaaeb8a9e..ac57a1df9c2 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue @@ -10,6 +10,7 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; import { __, s__ } from '~/locale'; diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js index f13dde9a2bc..db67b633d07 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actionsFactory from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue index ba557878246..7871721f38b 100644 --- a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue @@ -283,7 +283,6 @@ export default { <gl-disclosure-dropdown-item v-if="isOpen && canUpdateMergeRequest" - data-testid="close-merge-request" @action="stateAction('close')" > <template #list-item> 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 9ea04553536..9179331cdec 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -69,7 +69,7 @@ export default { }; </script> <template> - <div class="issuable-note-warning" data-testid="confidential-warning"> + <div class="issuable-note-warning"> <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 57b19620c10..5e2483cbcec 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -17,6 +17,7 @@ * /> */ import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderMarkdown } from '~/notes/utils'; diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 06ca90fa8c6..81cbbf951ad 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -18,6 +18,7 @@ */ import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import $ from 'jquery'; +// eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; diff --git a/app/assets/javascripts/vue_shared/components/page_size_selector.vue b/app/assets/javascripts/vue_shared/components/page_size_selector.vue index 9783946b786..5c097220d23 100644 --- a/app/assets/javascripts/vue_shared/components/page_size_selector.vue +++ b/app/assets/javascripts/vue_shared/components/page_size_selector.vue @@ -1,37 +1,42 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { n__ } from '~/locale'; -export const PAGE_SIZES = [20, 50, 100]; +export const PAGE_SIZES = [20, 50, 100].map((value) => ({ + value, + text: n__('SecurityReports|Show %d item', 'SecurityReports|Show %d items', value), +})); export default { - components: { GlDropdown, GlDropdownItem }, + components: { GlCollapsibleListbox }, props: { value: { type: Number, required: true, }, }, + computed: { + selectedItem() { + return PAGE_SIZES.find(({ value }) => value === this.value); + }, + toggleText() { + return this.selectedItem.text; + }, + }, methods: { emitInput(pageSize) { this.$emit('input', pageSize); }, - getPageSizeText(pageSize) { - return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize }); - }, }, PAGE_SIZES, }; </script> <template> - <gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0"> - <gl-dropdown-item - v-for="pageSize in $options.PAGE_SIZES" - :key="pageSize" - @click="emitInput(pageSize)" - > - <span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span> - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + :toggle-text="toggleText" + :items="$options.PAGE_SIZES" + :selected="value" + @select="emitInput($event)" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/constants.js b/app/assets/javascripts/vue_shared/components/projects_list/constants.js new file mode 100644 index 00000000000..aa0b1418a06 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/constants.js @@ -0,0 +1,2 @@ +export const ACTION_EDIT = 'edit'; +export const ACTION_DELETE = 'delete'; diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue index cb8220a0407..3a4da54c84c 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue @@ -46,6 +46,7 @@ export default { :key="project.id" :project="project" :show-project-icon="showProjectIcon" + @delete="$emit('delete', $event)" /> </ul> </template> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index d919f76e684..9fc4571b0dc 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,6 +7,7 @@ import { GlTooltipDirective, GlPopover, GlSprintf, + GlDisclosureDropdown, } from '@gitlab/ui'; import uniqueId from 'lodash/uniqueId'; @@ -19,6 +20,8 @@ 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 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; @@ -33,6 +36,7 @@ export default { topicsPopoverTargetText: __('+ %{count} more'), moreTopics: __('More topics'), updated: __('Updated'), + actions: __('Actions'), }, avatarSize: { default: 32, md: 48 }, safeHtmlConfig: { @@ -47,6 +51,8 @@ export default { GlPopover, GlSprintf, TimeAgoTooltip, + GlDisclosureDropdown, + DeleteModal, }, directives: { GlTooltip: GlTooltipDirective, @@ -73,6 +79,9 @@ export default { * }; * descriptionHtml: string; * updatedAt: string; + * isForked: boolean; + * actions?: ('edit' | 'delete')[]; + * editPath?: string; * } */ project: { @@ -88,6 +97,7 @@ export default { data() { return { topicsPopoverTarget: uniqueId('project-topics-popover-'), + isDeleteModalVisible: false, }; }, computed: { @@ -136,9 +146,50 @@ export default { popoverTopics() { return this.project.topics.slice(MAX_TOPICS_TO_SHOW); }, + starCount() { + return numberToMetricPrefix(this.project.starCount); + }, + forksCount() { + if (!this.isForkingEnabled) { + return null; + } + + return numberToMetricPrefix(this.project.forksCount); + }, + openIssuesCount() { + if (!this.isIssuesEnabled) { + return null; + } + + return numberToMetricPrefix(this.project.openIssuesCount); + }, + actionsDropdownItems() { + return [ + { + id: ACTION_EDIT, + text: __('Edit'), + href: this.project.editPath, + }, + { + id: ACTION_DELETE, + text: __('Delete'), + extraAttrs: { + class: 'gl-text-red-500!', + }, + action: () => { + this.isDeleteModalVisible = true; + }, + }, + ].filter(({ id }) => this.project.actions?.includes(id)); + }, + hasActions() { + return this.actionsDropdownItems.length; + }, + hasDeleteAction() { + return this.actionsDropdownItems.find((action) => action.id === ACTION_DELETE); + }, }, methods: { - numberToMetricPrefix, topicPath(topic) { return `/explore/projects/topics/${encodeURIComponent(topic)}`; }, @@ -158,128 +209,154 @@ export default { </script> <template> - <li class="projects-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="showProjectIcon" - class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" - name="project" - /> - <gl-avatar-labeled - :entity-id="project.id" - :entity-name="project.name" - :label="project.name" - :label-link="project.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="projects-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start"> + <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1"> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-icon + v-if="showProjectIcon" + class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" + name="project" + /> + <gl-avatar-labeled + :entity-id="project.id" + :entity-name="project.name" + :label="project.name" + :label-link="project.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> - </div> - </template> - <div - v-if="project.descriptionHtml" - v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml" - class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md" - data-testid="project-description" - ></div> - <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> + </template> <div - class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2" - > - <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span> - <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> - {{ topicTitle(topic) }} - </gl-badge> - </div> - <template v-if="popoverTopics.length"> - <div - :id="topicsPopoverTarget" - class="gl-p-2 gl-text-secondary" - role="button" - tabindex="0" - > - <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> - <template #count>{{ popoverTopics.length }}</template> - </gl-sprintf> + v-if="project.descriptionHtml" + v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml" + class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md" + data-testid="project-description" + ></div> + <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> + <div + class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2" + > + <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span> + <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> </div> - <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> - <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2"> - <div - v-for="topic in popoverTopics" - :key="topic" - class="gl-p-2 gl-display-inline-block" - > - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> - {{ topicTitle(topic) }} - </gl-badge> - </div> + <template v-if="popoverTopics.length"> + <div + :id="topicsPopoverTarget" + class="gl-p-2 gl-text-secondary" + role="button" + tabindex="0" + > + <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> + <template #count>{{ popoverTopics.length }}</template> + </gl-sprintf> </div> - </gl-popover> - </template> + <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> + <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2"> + <div + v-for="topic in popoverTopics" + :key="topic" + class="gl-p-2 gl-display-inline-block" + > + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> + </div> + </div> + </gl-popover> + </template> + </div> </div> - </div> - </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" - :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'" - > - <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> - <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge> - <gl-link - v-gl-tooltip="$options.i18n.stars" - :href="starsHref" - :aria-label="$options.i18n.stars" - class="gl-text-secondary" - > - <gl-icon name="star-o" /> - <span>{{ numberToMetricPrefix(project.starCount) }}</span> - </gl-link> - <gl-link - v-if="isForkingEnabled" - v-gl-tooltip="$options.i18n.forks" - :href="forksHref" - :aria-label="$options.i18n.forks" - class="gl-text-secondary" - > - <gl-icon name="fork" /> - <span>{{ numberToMetricPrefix(project.forksCount) }}</span> - </gl-link> - <gl-link - v-if="isIssuesEnabled" - v-gl-tooltip="$options.i18n.issues" - :href="issuesHref" - :aria-label="$options.i18n.issues" - class="gl-text-secondary" - > - <gl-icon name="issues" /> - <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span> - </gl-link> + </gl-avatar-labeled> </div> <div - v-if="project.updatedAt" - class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3" + class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0" + :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'" > - <span>{{ $options.i18n.updated }}</span> - <time-ago-tooltip :time="project.updatedAt" /> + <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> + <gl-badge v-if="project.archived" variant="warning">{{ + $options.i18n.archived + }}</gl-badge> + <gl-link + v-gl-tooltip="$options.i18n.stars" + :href="starsHref" + :aria-label="$options.i18n.stars" + class="gl-text-secondary" + > + <gl-icon name="star-o" /> + <span>{{ starCount }}</span> + </gl-link> + <gl-link + v-if="isForkingEnabled" + v-gl-tooltip="$options.i18n.forks" + :href="forksHref" + :aria-label="$options.i18n.forks" + class="gl-text-secondary" + > + <gl-icon name="fork" /> + <span>{{ forksCount }}</span> + </gl-link> + <gl-link + v-if="isIssuesEnabled" + v-gl-tooltip="$options.i18n.issues" + :href="issuesHref" + :aria-label="$options.i18n.issues" + class="gl-text-secondary" + > + <gl-icon name="issues" /> + <span>{{ openIssuesCount }}</span> + </gl-link> + </div> + <div + v-if="project.updatedAt" + class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3" + > + <span>{{ $options.i18n.updated }}</span> + <time-ago-tooltip :time="project.updatedAt" /> + </div> </div> </div> + <gl-disclosure-dropdown + 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" + /> + + <delete-modal + v-if="hasDeleteAction" + v-model="isDeleteModalVisible" + :confirm-phrase="project.name" + :is-fork="project.isForked" + :issues-count="openIssuesCount" + :forks-count="forksCount" + :stars-count="starCount" + @primary="$emit('delete', project)" + /> </li> </template> diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue index 32d7cdad568..3404423b5bb 100644 --- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue +++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue @@ -1,12 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; export default { name: 'PersistedDropdownSelection', components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, LocalStorageSync, }, props: { @@ -21,16 +20,15 @@ export default { }, data() { return { - selected: null, + selected: this.options[0].value, }; }, computed: { - dropdownText() { - const selected = this.parsedOptions.find((o) => o.selected); - return selected?.label || this.options[0].label; - }, - parsedOptions() { - return this.options.map((o) => ({ ...o, selected: o.value === this.selected })); + listboxItems() { + return this.options.map((option) => ({ + value: option.value, + text: option.label, + })); }, }, methods: { @@ -44,16 +42,6 @@ export default { <template> <local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected"> - <gl-dropdown :text="dropdownText" lazy> - <gl-dropdown-item - v-for="option in parsedOptions" - :key="option.value" - :is-checked="option.selected" - is-check-item - @click="setSelected(option.value)" - > - {{ option.label }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox v-model="selected" :items="listboxItems" @select="setSelected" /> </local-storage-sync> </template> diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 730e9e1c6cc..e41cd344b3f 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -60,7 +60,13 @@ export default { methods: { generateQueryData({ sorting = {}, filter = [] } = {}) { // Ensure that we clean up the query when we remove a token from the search - const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] }; + const result = { + ...this.baselineQueryStringFilters, + ...sorting, + search: [], + after: null, + before: null, + }; filter.forEach((f) => { if (f.type === FILTERED_SEARCH_TERM) { diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index ad979387596..2db56343210 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -38,7 +38,7 @@ export default { metadataSlots: [], }; }, - mounted() { + created() { this.recalculateMetadataSlots(); }, updated() { @@ -47,8 +47,9 @@ export default { methods: { recalculateMetadataSlots() { const METADATA_PREFIX = 'metadata-'; - // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots - const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX)); + const metadataSlots = Object.keys(this.$scopedSlots).filter((k) => + k.startsWith(METADATA_PREFIX), + ); if (!isEqual(metadataSlots, this.metadataSlots)) { this.metadataSlots = metadataSlots; @@ -77,9 +78,7 @@ export default { </h2> <div - v-if=" - $slots['sub-header'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ - " + v-if="$scopedSlots['sub-header']" class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > <slot name="sub-header"></slot> @@ -110,8 +109,7 @@ export default { </template> </div> </div> - <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> - <div v-if="$slots['right-actions']" class="gl-mt-3"> + <div v-if="$scopedSlots['right-actions']" class="gl-mt-3"> <slot name="right-actions"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 28a16cd846a..bab5e5ff3a7 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIntersectionObserver } from '@gitlab/ui'; import LineHighlighter from '~/blob/line_highlighter'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 6c49a601401..582093e5739 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -97,6 +97,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { sql: 'sql', stan: 'stan', stata: 'stata', + svelte: 'svelte', swift: 'swift', tap: 'tap', tcl: 'tcl', @@ -151,3 +152,5 @@ export const LEGACY_FALLBACKS = ['python', 'haml']; export const CODEOWNERS_FILE_NAME = 'CODEOWNERS'; export const CODEOWNERS_LANGUAGE = 'codeowners'; + +export const SVELTE_LANGUAGE = 'svelte'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js b/app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js new file mode 100644 index 00000000000..df92bdf87db --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/languages/svelte.js @@ -0,0 +1,81 @@ +/* +Language: Svelte.js +Requires: xml, javascript, typescript, css, scss +Description: Components of Svelte Framework +*/ + +export default (hljs) => { + return { + subLanguage: 'xml', + contains: [ + hljs.COMMENT('<!--', '-->', { + relevance: 11, + }), + { + begin: /^(\s*)(<script.*(lang="ts").*>)/gm, + end: /^(\s*)(<\/script>)/gm, + subLanguage: 'typescript', + excludeBegin: true, + excludeEnd: true, + relevance: 20, + contains: [ + // special svelte $ syntax + { + begin: /^(\s*)(\$:)/gm, + end: /(\s*)/gm, + className: 'keyword', + }, + ], + }, + { + begin: /^(\s*)(<script(\s*context="module")?.*>)/gm, + end: /^(\s*)(<\/script>)/gm, + subLanguage: 'javascript', + excludeBegin: true, + excludeEnd: true, + relevance: 15, + contains: [ + // special svelte $ syntax + { + begin: /^(\s*)(\$:)/gm, + end: /(\s*)/gm, + className: 'keyword', + }, + ], + }, + { + begin: /^(\s*)(<style.*(lang="scss"|type="text\/scss").*>)/gm, + end: /^(\s*)(<\/style>)/gm, + subLanguage: 'scss', + excludeBegin: true, + excludeEnd: true, + relevance: 20, + }, + { + begin: /^(\s*)(<style.*>)/gm, + end: /^(\s*)(<\/style>)/gm, + subLanguage: 'css', + excludeBegin: true, + excludeEnd: true, + relevance: 15, + }, + { + begin: /\{/gm, + end: /}/gm, + subLanguage: 'javascript', + contains: [ + { + begin: /[{]/, + end: /[}]/, + skip: true, + }, + { + begin: /([#:/@])(if|else|each|await|then|catch|debug|html)/gm, + className: 'keyword', + relevance: 10, + }, + ], + }, + ], + }; +}; 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 9dc6dc1b93a..a4d50466f8f 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 @@ -5,6 +5,7 @@ import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import Tracking from '~/tracking'; +import axios from '~/lib/utils/axios_utils'; import { EVENT_ACTION, EVENT_LABEL_VIEWER, @@ -14,6 +15,7 @@ import { LEGACY_FALLBACKS, CODEOWNERS_FILE_NAME, CODEOWNERS_LANGUAGE, + SVELTE_LANGUAGE, } from './constants'; import Chunk from './components/chunk.vue'; import { registerPlugins } from './plugins/index'; @@ -52,13 +54,24 @@ export default { }; }, computed: { + isLfsBlob() { + const { storedExternally, externalStorage, simpleViewer } = this.blob; + + return storedExternally && externalStorage === 'lfs' && simpleViewer?.fileType === 'text'; + }, splitContent() { return this.content.split(/\r?\n/); }, language() { - return this.blob.name === this.$options.codeownersFileName - ? this.$options.codeownersLanguage - : ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()]; + 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) { + // override for codeowners files + return this.$options.codeownersLanguage; + } + + return ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()]; }, lineNumbers() { return this.splitContent.length; @@ -76,6 +89,15 @@ export default { }, }, async created() { + if (this.isLfsBlob) { + await axios + .get(this.blob.externalStorageUrl || this.blob.rawPath) + .then((result) => { + this.content = result.data; + }) + .catch(() => this.$emit('error')); + } + addBlobLinksTracking(); this.trackEvent(EVENT_LABEL_VIEWER); @@ -168,12 +190,36 @@ export default { // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); }, + async loadSubLanguages(languageDefinition) { + if (!languageDefinition?.contains) return; + + // generate list of languages to load + const languages = new Set( + languageDefinition.contains + .filter((component) => Boolean(component.subLanguage)) + .map((component) => component.subLanguage), + ); + + if (languageDefinition.subLanguage) { + languages.add(languageDefinition.subLanguage); + } + + // load all sub-languages at once + await Promise.all( + [...languages].map(async (subLanguage) => { + const subLanguageDefinition = await languageLoader[subLanguage](); + this.hljs.registerLanguage(subLanguage, subLanguageDefinition.default); + }), + ); + }, async loadLanguage() { let languageDefinition; try { languageDefinition = await languageLoader[this.language](); this.hljs.registerLanguage(this.language, languageDefinition.default); + + await this.loadSubLanguages(this.hljs.getLanguage(this.language)); } catch (message) { this.$emit('error', message); } 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 8e4c438719e..0fb6e577f32 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 @@ -40,6 +40,10 @@ export default { this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); }, + mounted() { + const { hash } = this.$route; + this.lineHighlighter.highlightHash(hash); + }, userColorScheme: window.gon.user_color_scheme, }; </script> 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 9a06c0ecf30..79d14b5f2d0 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -1,8 +1,15 @@ <script> -import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import { + GlModal, + GlSprintf, + GlLink, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; -import ActionsButton from '~/vue_shared/components/actions_button.vue'; +import Tracking from '~/tracking'; import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants'; @@ -22,16 +29,21 @@ export const i18n = { toggleText: __('Edit'), }; +const TRACKING_ACTION_NAME = 'click_consolidated_edit'; + export default { name: 'CEWebIdeLink', components: { - ActionsButton, GlModal, GlSprintf, GlLink, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, ConfirmForkModal, }, i18n, + mixins: [Tracking.mixin()], props: { isFork: { type: Boolean, @@ -173,10 +185,9 @@ export default { key: KEY_EDIT, text: __('Edit single file'), secondaryText: __('Edit this file only.'), - attrs: { - 'data-qa-selector': 'edit_button', - 'data-track-action': 'click_consolidated_edit', - 'data-track-label': 'edit', + tracking: { + action: TRACKING_ACTION_NAME, + label: 'single_file', }, ...handleOptions, }; @@ -216,10 +227,9 @@ export default { key: KEY_WEB_IDE, text: this.webIdeActionText, secondaryText: this.$options.i18n.webIdeText, - attrs: { - 'data-qa-selector': 'web_ide_button', - 'data-track-action': 'click_consolidated_edit_ide', - 'data-track-label': 'web_ide', + tracking: { + action: TRACKING_ACTION_NAME, + label: 'web_ide', }, ...handleOptions, }; @@ -246,10 +256,11 @@ export default { key: KEY_PIPELINE_EDITOR, text: __('Edit in pipeline editor'), secondaryText, - attrs: { - 'data-qa-selector': 'pipeline_editor_button', - }, href: this.pipelineEditorUrl, + tracking: { + action: TRACKING_ACTION_NAME, + label: 'pipeline_editor', + }, }; }, gitpodAction() { @@ -270,8 +281,9 @@ export default { key: KEY_GITPOD, text: this.gitpodActionText, secondaryText, - attrs: { - 'data-qa-selector': 'gitpod_button', + tracking: { + action: TRACKING_ACTION_NAME, + label: 'gitpod', }, ...handleOptions, }; @@ -306,25 +318,50 @@ export default { showModal(dataKey) { this[dataKey] = true; }, + executeAction(action) { + this.track(action.tracking.action, { label: action.tracking.label }); + action.handle?.(); + }, }, - webIdeButtonId: 'web-ide-link', }; </script> <template> <div class="gl-sm-ml-3"> - <actions-button + <gl-disclosure-dropdown v-if="hasActions" - :id="$options.webIdeButtonId" - :actions="actions" - :toggle-text="$options.i18n.toggleText" :variant="isBlob ? 'confirm' : 'default'" :category="isBlob ? 'primary' : 'secondary'" - @hidden="$emit('hidden')" + :toggle-text="$options.i18n.toggleText" + data-qa-selector="action_dropdown" + fluid-width + block @shown="$emit('shown')" + @hidden="$emit('hidden')" > - <slot></slot> - </actions-button> + <slot name="before-actions"></slot> + <gl-disclosure-dropdown-group class="edit-dropdown-group-width"> + <gl-disclosure-dropdown-item + v-for="action in actions" + :key="action.key" + :item="action" + :data-qa-selector="`${action.key}_menu_item`" + @action="executeAction(action)" + > + <template #list-item> + <div class="gl-display-flex gl-flex-direction-column"> + <span data-testid="action-primary-text" class="gl-font-weight-bold gl-mb-2">{{ + action.text + }}</span> + <span data-testid="action-secondary-text" class="gl-text-gray-700"> + {{ action.secondaryText }} + </span> + </div> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + <slot name="after-actions"></slot> + </gl-disclosure-dropdown> <gl-modal v-if="computedShowGitpodButton && !gitpodEnabled" v-model="showEnableGitpodModal" diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 8946a02e663..d9bc2c82688 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 === TYPE_ISSUE ? __('issue') : __('epic'), + issuableType: issuableType.toLowerCase(), permissions: issuableType === TYPE_ISSUE ? __('at least the Reporter role, the author, and assignees') diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index a693d4f114d..43110c0c9af 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -6,6 +6,7 @@ export const AUTOCOMPLETE_ERROR_MESSAGE = s__( export const ALL_GITLAB = __('All GitLab'); export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab'); +export const PLACES = s__('GlobalSearch|Places'); export const SEARCH_DESCRIBED_BY_DEFAULT = s__( 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.', 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 a19b568801d..699b41f3bf3 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 @@ -1,8 +1,9 @@ <script> import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { __ } from '~/locale'; export default { VARIANT_EMBEDDED, @@ -10,7 +11,7 @@ export default { GlForm, GlFormInput, GlFormGroup, - MarkdownField, + MarkdownEditor, LabelsSelect, }, props: { @@ -31,6 +32,14 @@ export default { required: true, }, }, + descriptionFormFieldProps: { + ariaLabel: __('Description'), + class: 'rspec-issuable-form-description', + placeholder: __('Write a comment or drag your files here…'), + dataQaSelector: 'issuable_form_description_field', + id: 'issuable-description', + name: 'issuable-description', + }, data() { return { issuableTitle: '', @@ -68,26 +77,12 @@ export default { <div data-testid="issuable-description" class="form-group row"> <label for="issuable-description" class="col-12">{{ __('Description') }}</label> <div class="col-12"> - <markdown-field - :markdown-preview-path="descriptionPreviewPath" + <markdown-editor + v-model="issuableDescription" + :render-markdown-path="descriptionPreviewPath" :markdown-docs-path="descriptionHelpPath" - :add-spacing-classes="false" - :show-suggest-popover="true" - :textarea-value="issuableDescription" - > - <template #textarea> - <textarea - id="issuable-description" - ref="textarea" - v-model="issuableDescription" - dir="auto" - class="note-textarea rspec-issuable-form-description js-gfm-input js-autosize markdown-area" - data-qa-selector="issuable_form_description_field" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" - ></textarea> - </template> - </markdown-field> + :form-field-props="$options.descriptionFormFieldProps" + /> </div> </div> <div data-testid="issuable-labels" class="form-group row"> diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue index efb6e626c07..b4287d86289 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue @@ -47,11 +47,11 @@ export default { <template> <gl-form-group class="row" label-class="gl-display-none"> - <label class="col-12 gl-display-flex gl-align-center gl-mb-1"> + <label class="col-12 gl-display-flex gl-align-center"> {{ $options.i18n.fieldLabel }} </label> <div class="col-12"> - <div class="issuable-form-select-holder"> + <div class="issuable-form-label-select-holder"> <input v-for="selectedLabel in selectedLabels" :key="selectedLabel.id" 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 ce33d7a9b4b..31dd49ca415 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 @@ -7,8 +7,10 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; -import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import { STATE_CLOSED } from '~/work_items/constants'; +import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils'; export default { components: { @@ -57,6 +59,16 @@ export default { required: false, default: false, }, + isActive: { + type: Boolean, + required: false, + default: false, + }, + preventRedirect: { + type: Boolean, + required: false, + default: false, + }, }, computed: { issuableId() { @@ -80,26 +92,41 @@ export default { reference() { return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; }, + type() { + return this.issuable.type || this.issuable.workItemType?.name.toUpperCase(); + }, labels() { - return this.issuable.labels?.nodes || this.issuable.labels || []; + return ( + this.issuable.labels?.nodes || + this.issuable.labels || + this.issuable.widgets?.find(isLabelsWidget)?.labels.nodes || + [] + ); }, labelIdsString() { return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id))); }, assignees() { - return this.issuable.assignees?.nodes || this.issuable.assignees || []; + return ( + this.issuable.assignees?.nodes || + this.issuable.assignees || + this.issuable.widgets?.find(isAssigneesWidget)?.assignees.nodes || + [] + ); }, createdAt() { return this.timeFormatted(this.issuable.createdAt); }, + isClosed() { + return this.issuable.state === STATUS_CLOSED || this.issuable.state === STATE_CLOSED; + }, timestamp() { - if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { - return this.issuable.closedAt; - } - return this.issuable.updatedAt; + return this.isClosed && this.issuable.closedAt + ? this.issuable.closedAt + : this.issuable.updatedAt; }, formattedTimestamp() { - if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { + if (this.isClosed && this.issuable.closedAt) { return sprintf(__('closed %{timeago}'), { timeago: this.timeFormatted(this.issuable.closedAt), }); @@ -157,7 +184,10 @@ export default { return Boolean(this.$slots[slotName]); }, scopedLabel(label) { - return this.hasScopedLabelsFeature && isScopedLabel(label); + const allowsScopedLabels = + this.hasScopedLabelsFeature || + this.issuable.widgets?.find(isLabelsWidget)?.allowsScopedLabels; + return allowsScopedLabels && isScopedLabel(label); }, labelTitle(label) { return label.title || label.name; @@ -177,6 +207,13 @@ export default { } return ''; }, + handleIssuableItemClick(e) { + if (e.metaKey || e.ctrlKey || !this.preventRedirect) { + return; + } + e.preventDefault(); + this.$emit('select-issuable', { iid: this.issuableIid, webUrl: this.webUrl }); + }, }, }; </script> @@ -185,9 +222,10 @@ export default { <li :id="`issuable_${issuableId}`" class="issue gl-display-flex! gl-px-5!" - :class="{ closed: issuable.closedAt }" + :class="{ closed: issuable.closedAt, 'gl-bg-blue-50': isActive }" :data-labels="labelIdsString" :data-qa-issue-id="issuableId" + data-testid="issuable-item-wrapper" > <gl-form-checkbox v-if="showCheckbox" @@ -195,7 +233,7 @@ export default { :checked="checked" :data-id="issuableId" :data-iid="issuableIid" - :data-type="issuable.type" + :data-type="type" @input="$emit('checked-input', $event)" > <span class="gl-sr-only">{{ issuable.title }}</span> @@ -204,7 +242,7 @@ export default { <div data-testid="issuable-title" class="issue-title title"> <work-item-type-icon v-if="showWorkItemTypeIcon" - :work-item-type="issuable.type" + :work-item-type="type" show-tooltip-on-hover /> <gl-icon @@ -226,7 +264,9 @@ export default { 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" /> 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 4023337a1cb..7a9404e06c7 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 @@ -203,6 +203,16 @@ export default { required: false, default: false, }, + activeIssuable: { + type: Object, + required: false, + default: null, + }, + preventRedirect: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -299,6 +309,9 @@ export default { handlePageSizeChange(newPageSize) { this.$emit('page-size-change', newPageSize); }, + isIssuableActive(issuable) { + return Boolean(issuable.iid === this.activeIssuable?.iid); + }, }, PAGE_SIZE_STORAGE_KEY, }; @@ -373,7 +386,10 @@ export default { :show-checkbox="showBulkEditSidebar" :checked="issuableChecked(issuable)" :show-work-item-type-icon="showWorkItemTypeIcon" + :prevent-redirect="preventRedirect" + :is-active="isIssuableActive(issuable)" @checked-input="handleIssuableCheckedInput(issuable, $event)" + @select-issuable="$emit('select-issuable', $event)" > <template #reference> <slot name="reference" :issuable="issuable"></slot> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 387fc5e0d1c..7c3dd5c3623 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue @@ -130,7 +130,6 @@ export default { :label="__('Description')" :label-sr-only="!showFieldTitle" label-for="issuable-description" - label-class="gl-pb-0!" class="col-12 gl-px-0 common-note-form" > <markdown-field 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 53e976d698b..29aef89a991 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 @@ -1,17 +1,9 @@ <script> -import { - GlIcon, - GlBadge, - GlButton, - GlTooltipDirective, - GlAvatarLink, - GlAvatarLabeled, -} from '@gitlab/ui'; - +import { GlIcon, GlBadge, GlButton, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { issuableStatusText, STATUS_OPEN } from '~/issues/constants'; +import { issuableStatusText, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { isExternal } from '~/lib/utils/url_utility'; -import { n__, sprintf } from '~/locale'; +import { __, n__, sprintf } from '~/locale'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -22,8 +14,8 @@ export default { GlIcon, GlBadge, GlButton, - GlAvatarLink, - GlAvatarLabeled, + GlLink, + GlSprintf, TimeAgoTooltip, WorkItemTypeIcon, }, @@ -64,21 +56,36 @@ export default { required: false, default: false, }, - taskCompletionStatus: { - type: Object, + isFirstContribution: { + type: Boolean, required: false, - default: null, + default: false, + }, + isHidden: { + type: Boolean, + required: false, + default: false, }, issuableType: { type: String, required: false, default: '', }, + serviceDeskReplyTo: { + type: String, + required: false, + default: '', + }, showWorkItemTypeIcon: { type: Boolean, required: false, default: false, }, + taskCompletionStatus: { + type: Object, + required: false, + default: null, + }, workspaceType: { type: String, required: false, @@ -90,13 +97,38 @@ export default { return issuableStatusText[this.issuableState]; }, badgeVariant() { - return this.issuableState === STATUS_OPEN ? 'success' : 'info'; + return this.issuableState === STATUS_OPEN || this.issuableState === STATUS_REOPENED + ? 'success' + : 'info'; + }, + blockedTooltip() { + return sprintf(__('This %{issuable} is locked. Only project members can comment.'), { + issuable: this.issuableType, + }); + }, + hiddenTooltip() { + return sprintf(__('This %{issuable} is hidden because its author has been banned'), { + issuable: this.issuableType, + }); + }, + shouldShowWorkItemTypeIcon() { + return this.showWorkItemTypeIcon && this.issuableType; + }, + createdMessage() { + if (this.serviceDeskReplyTo) { + return this.shouldShowWorkItemTypeIcon + ? __('created %{timeAgo} by %{email} via %{author}') + : __('Created %{timeAgo} by %{email} via %{author}'); + } + return this.shouldShowWorkItemTypeIcon + ? __('created %{timeAgo} by %{author}') + : __('Created %{timeAgo} by %{author}'); }, authorId() { return getIdFromGraphQLId(`${this.author.id}`); }, isAuthorExternal() { - return isExternal(this.author.webUrl); + return isExternal(this.author.webUrl ?? ''); }, taskStatusString() { const { count, completedCount } = this.taskCompletionStatus; @@ -130,72 +162,87 @@ export default { <template> <div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row"> - <div class="detail-page-header-body"> - <gl-badge class="issuable-status-badge gl-mr-3" :variant="badgeVariant"> + <div class="detail-page-header-body gl-flex-wrap"> + <gl-badge class="gl-mr-2" :variant="badgeVariant"> <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> - <span class="gl-display-none gl-sm-display-block gl-ml-2"> + <span class="gl-display-none gl-sm-display-block" :class="{ 'gl-ml-2': statusIcon }"> <slot name="status-badge">{{ badgeText }}</slot> </span> </gl-badge> - <div class="issuable-meta gl-display-flex! gl-align-items-center gl-flex-wrap"> - <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> - <gl-icon name="lock" :aria-label="__('Blocked')" /> - </div> - <confidentiality-badge - v-if="confidential" - :issuable-type="issuableType" - :workspace-type="workspaceType" + <confidentiality-badge + v-if="confidential" + :issuable-type="issuableType" + :workspace-type="workspaceType" + /> + <span v-if="blocked" class="issuable-warning-icon"> + <gl-icon + v-gl-tooltip.bottom + name="lock" + :title="blockedTooltip" + :aria-label="__('Blocked')" /> - <span> - <template v-if="showWorkItemTypeIcon"> - <work-item-type-icon :work-item-type="issuableType" show-text /> - {{ __('created') }} - </template> - <template v-else> - {{ __('Created') }} - </template> - <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" /> - {{ __('by') }} - </span> - <gl-avatar-link - data-testid="avatar" - :data-user-id="authorId" - :data-username="author.username" - :data-name="author.name" - :href="author.webUrl" - target="_blank" - class="js-user-link gl-vertical-align-middle gl-ml-2" - > - <gl-avatar-labeled - :size="24" - :src="author.avatarUrl" - :label="author.name" - :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline-flex gl-mx-1']" - > - <template #meta> - <gl-icon v-if="isAuthorExternal" name="external-link" class="gl-ml-1" /> - </template> - </gl-avatar-labeled> - <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!" - >@{{ author.username }}</strong + </span> + <span v-if="isHidden" class="issuable-warning-icon"> + <gl-icon + v-gl-tooltip.bottom + name="spam" + :title="hiddenTooltip" + :aria-label="__('Hidden')" + /> + </span> + <work-item-type-icon + v-if="shouldShowWorkItemTypeIcon" + show-text + :work-item-type="issuableType.toUpperCase()" + /> + <gl-sprintf :message="createdMessage"> + <template #timeAgo> + <time-ago-tooltip class="gl-mx-2" :time="createdAt" /> + </template> + <template #email> + {{ serviceDeskReplyTo }} + </template> + <template #author> + <gl-link + class="gl-font-weight-bold gl-mx-2 js-user-link" + :href="author.webUrl" + :data-user-id="authorId" > - </gl-avatar-link> - <span - v-if="taskCompletionStatus && hasTasks" - data-testid="task-status" - class="gl-display-none gl-md-display-block gl-lg-display-inline-block" - >{{ taskStatusString }}</span - > - </div> + <span :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline']"> + {{ author.name }} + </span> + <gl-icon + v-if="isAuthorExternal" + name="external-link" + :aria-label="__('external link')" + /> + <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!" + >@{{ author.username }}</strong + > + </gl-link> + </template> + </gl-sprintf> + <gl-icon + v-if="isFirstContribution" + v-gl-tooltip + class="gl-mr-2" + name="first-contribution" + :title="__('1st contribution!')" + :aria-label="__('1st contribution!')" + /> + <span + v-if="taskCompletionStatus && hasTasks" + class="gl-display-none gl-md-display-block gl-lg-display-inline-block" + >{{ taskStatusString }}</span + > <gl-button - data-testid="sidebar-toggle" icon="chevron-double-lg-left" - class="d-block d-sm-none gutter-toggle issuable-gutter-toggle" + class="gl-ml-auto gl-display-block gl-sm-display-none! js-sidebar-toggle" :aria-label="__('Expand sidebar')" @click="handleRightSidebarToggleClick" /> </div> - <div data-testid="header-actions" class="detail-page-header-actions gl-display-flex"> + <div class="detail-page-header-actions gl-display-flex"> <slot name="header-actions"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index 1b4da047057..01f9c223b55 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import Tracking from '~/tracking'; @@ -28,7 +29,7 @@ export default { > <a :href="`#${panel.name}`" - data-qa-selector="panel_link" + data-testid="panel-link" :data-qa-panel-name="panel.name" class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-3 gl-hover-text-decoration-none!" @click="track('click_tab', { label: panel.name })" diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index fe408354f66..c1ec39e1545 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -107,7 +107,6 @@ export default { <template> <gl-button v-if="!feature.configured" - data-testid="configure-via-mr-button" :loading="isLoading" :variant="variant" :category="category" diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue index 4fafeff8804..f20d4d9312b 100644 --- a/app/assets/javascripts/webhooks/components/form_url_app.vue +++ b/app/assets/javascripts/webhooks/components/form_url_app.vue @@ -170,6 +170,7 @@ export default { id="webhook-url" v-model="url" name="hook[url]" + class="gl-form-input-xl" :state="urlState" :placeholder="$options.i18n.urlPlaceholder" data-testid="form-url" diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index dd5d4edda59..d8f6e526dee 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import Tracking from '~/tracking'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index 044a6db6d93..c3e1e2a8fbc 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlBadge, GlIcon, GlLink, GlButton } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -22,7 +23,7 @@ export default { computed: { releaseDate() { const { published_at } = this.feature; - const date = new Date(published_at); + const date = new Date(`${published_at}T00:00:00`); // eslint-disable-line camelcase if (!isValidDate(date) || date.getTime() === 0) { return ''; diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index 3ac3a3a3611..bc9e2d5c3b1 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import { mapState } from 'vuex'; import App from './components/app.vue'; import store from './store'; diff --git a/app/assets/javascripts/whats_new/store/index.js b/app/assets/javascripts/whats_new/store/index.js index aea980060aa..5b8e4b58136 100644 --- a/app/assets/javascripts/whats_new/store/index.js +++ b/app/assets/javascripts/whats_new/store/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import actions from './actions'; import mutations from './mutations'; diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue deleted file mode 100644 index 9053d8972de..00000000000 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ /dev/null @@ -1,79 +0,0 @@ -<script> -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { STATE_OPEN, STATE_CLOSED } from '../constants'; - -export default { - i18n: { - status: __('Status'), - }, - states: [ - { - value: STATE_OPEN, - text: __('Open'), - }, - { - value: STATE_CLOSED, - text: __('Closed'), - }, - ], - components: { - GlFormGroup, - GlFormSelect, - }, - props: { - state: { - type: String, - required: true, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - currentState() { - return this.$options.states[this.state]; - }, - }, - methods: { - setState(newState) { - if (newState !== this.state) { - this.$emit('changed', newState); - } - }, - }, - labelId: 'work-item-state-select', -}; -</script> - -<template> - <gl-form-group - :label="$options.i18n.status" - :label-for="$options.labelId" - label-cols="3" - label-cols-lg="2" - label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label" - class="gl-align-items-center" - > - <gl-form-select - :id="$options.labelId" - :value="state" - :options="$options.states" - :disabled="disabled" - data-testid="work-item-state-select" - class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1 work-item-field-value" - :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" - @change="setState" - /> - </gl-form-group> -</template> - -<style> -.hide-select-decoration:not(:focus, :hover), -.hide-select-decoration:disabled { - background-image: none; - box-shadow: none; -} -</style> diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 1dc6d341811..74bcc2717bd 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -52,7 +52,7 @@ export default { :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base gl-display-block" + class="hide-unfocused-input-decoration gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-rounded-base gl-display-block" :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }" @paste="handlePaste" @blur="handleBlur" 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 c330eccb186..66ad3d50287 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 @@ -265,6 +265,7 @@ export default { :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" + @error="$emit('error', $event)" /> <textarea v-else 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 c317ec48732..b143c529014 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -1,22 +1,13 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import { - I18N_WORK_ITEM_ERROR_UPDATING, - sprintfWorkItem, - STATE_OPEN, - STATE_EVENT_REOPEN, - STATE_EVENT_CLOSE, - TRACKING_CATEGORY_SHOW, - i18n, -} from '~/work_items/constants'; +import { STATE_OPEN, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; export default { i18n: { @@ -25,6 +16,7 @@ export default { 'Notes|Internal notes are only visible to members with the role of Reporter or higher', ), addInternalNote: __('Add internal note'), + cancelButtonText: __('Cancel'), }, constantOptions: { markdownDocsPath: helpPagePath('user/markdown'), @@ -34,6 +26,7 @@ export default { MarkdownEditor, GlFormCheckbox, GlIcon, + WorkItemStateToggleButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -123,14 +116,6 @@ export default { isWorkItemOpen() { return this.workItemState === STATE_OPEN; }, - toggleWorkItemStateText() { - return this.isWorkItemOpen - ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }) - : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }); - }, - cancelButtonText() { - return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); - }, commentButtonTextComputed() { return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; }, @@ -166,48 +151,6 @@ export default { this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, - async toggleWorkItemState() { - const input = { - id: this.workItemId, - stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, - }; - - this.updateInProgress = true; - - try { - this.track('updated_state'); - - const { mutation, variables } = getUpdateWorkItemMutation({ - workItemParentId: this.workItemParentId, - input, - }); - - const { data } = await this.$apollo.mutate({ - mutation, - variables, - }); - - const errors = data.workItemUpdate?.errors; - - if (errors?.length) { - this.$emit('error', i18n.updateError); - } - } catch (error) { - const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); - - this.$emit('error', msg); - Sentry.captureException(error); - } - - this.updateInProgress = false; - }, - cancelButtonAction() { - if (this.isNewDiscussion) { - this.toggleWorkItemState(); - } else { - this.cancelEditing(); - } - }, }, }; </script> @@ -257,13 +200,23 @@ export default { @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="cancelButtonAction" - >{{ cancelButtonText }} + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} </gl-button> </form> </div> 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 a2667a379e1..92560f2da9e 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 @@ -3,7 +3,7 @@ import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; -import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants'; +import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import Tracking from '~/tracking'; import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import { renderMarkdown } from '~/notes/utils'; @@ -17,6 +17,7 @@ import NoteActions from '~/work_items/components/notes/work_item_note_actions.vu import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { isAssigneesWidget } from '../../utils'; import WorkItemCommentForm from './work_item_comment_form.vue'; import WorkItemNoteAwardsList from './work_item_note_awards_list.vue'; @@ -228,8 +229,6 @@ export default { newAssignees = [...this.assignees, this.author]; } - const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES; - const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget); const editedWorkItemWidgets = [...this.workItem.widgets]; 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 new file mode 100644 index 00000000000..0a38dcb77f6 --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -0,0 +1,196 @@ +<script> +import { GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; +import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue'; +import { + STATE_OPEN, + TASK_TYPE_NAME, + WIDGET_TYPE_PROGRESS, + WIDGET_TYPE_HIERARCHY, + WIDGET_TYPE_HEALTH_STATUS, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, + WORK_ITEM_NAME_TO_ICON_MAP, +} from '../../constants'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; + +export default { + i18n: { + confidential: __('Confidential'), + created: __('Created'), + closed: __('Closed'), + }, + components: { + GlLabel, + GlLink, + GlIcon, + RichTimestampTooltip, + WorkItemLinkChildMetadata, + WorkItemLinksMenu, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + childItem: { + type: Object, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + parentWorkItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + childPath: { + type: String, + required: true, + }, + }, + computed: { + labels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; + }, + metadataWidgets() { + return this.childItem.widgets?.reduce((metadataWidgets, widget) => { + // Skip Hierarchy widget as it is not part of metadata. + if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) { + // eslint-disable-next-line no-param-reassign + metadataWidgets[widget.type] = widget; + } + return metadataWidgets; + }, {}); + }, + allowsScopedLabels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; + }, + isChildItemOpen() { + return this.childItem.state === STATE_OPEN; + }, + iconName() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isChildItemOpen ? 'issue-open-m' : 'issue-close'; + } + return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; + }, + childItemType() { + return this.childItem.workItemType.name; + }, + iconClass() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isChildItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + } + return ''; + }, + stateTimestamp() { + return this.isChildItemOpen ? this.childItem.createdAt : this.childItem.closedAt; + }, + stateTimestampTypeText() { + return this.isChildItemOpen ? this.$options.i18n.created : this.$options.i18n.closed; + }, + hasMetadata() { + if (this.metadataWidgets) { + return ( + Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) || + Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) || + Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) || + this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 || + this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0 + ); + } + return false; + }, + }, + methods: { + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + }, +}; +</script> + +<template> + <div + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base" + data-testid="links-child" + > + <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" + > + <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> + <span + :id="`stateIcon-${childItem.id}`" + class="gl-cursor-help" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <span v-if="childItem.confidential"> + <gl-icon + v-gl-tooltip.top + name="eye-slash" + class="gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="$options.i18n.confidential" + :title="$options.i18n.confidential" + /> + </span> + <gl-link + :href="childPath" + class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :metadata-widgets="metadataWidgets" + class="gl-ml-6 ml-xl-0" + /> + </div> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" + /> + </div> + </div> + <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> + <work-item-links-menu + data-testid="links-menu" + @removeChild="$emit('removeChild', childItem)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue index ddeac2b92ae..ddeac2b92ae 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue index 53e8eedf060..53e8eedf060 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue 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 76a04bede61..e8fe64c932b 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -8,6 +8,7 @@ import { GlModalDirective, GlToggle, } from '@gitlab/ui'; +import { produce } from 'immer'; import * as Sentry from '@sentry/browser'; @@ -15,6 +16,7 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import toast from '~/vue_shared/plugins/global_toast'; import { isLoggedIn } from '~/lib/utils/common_utils'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { sprintfWorkItem, @@ -127,6 +129,10 @@ export default { required: false, default: false, }, + workItemIid: { + type: String, + required: true, + }, }, apollo: { workItemTypes: { @@ -168,16 +174,6 @@ export default { return this.workItemTypes.find((type) => type.name === WORK_ITEM_TYPE_VALUE_OBJECTIVE).id; }, }, - watch: { - subscribedToNotifications() { - /** - * To toggle the value if mutation fails, assign the - * subscribedToNotifications boolean value directly - * to data prop. - */ - this.initialSubscribed = this.subscribedToNotifications; - }, - }, methods: { copyToClipboard(text, message) { if (this.isModal) { @@ -203,10 +199,9 @@ export default { }, toggleNotifications(subscribed) { const inputVariables = { - id: this.workItemId, - notificationsWidget: { - subscribed, - }, + projectPath: this.fullPath, + iid: this.workItemIid, + subscribedState: subscribed, }; this.$apollo .mutate({ @@ -215,27 +210,34 @@ export default { input: inputVariables, }, optimisticResponse: { - workItemUpdate: { - errors: [], - workItem: { + updateWorkItemNotificationsSubscription: { + issue: { id: this.workItemId, - widgets: [ - { - type: WIDGET_TYPE_NOTIFICATIONS, - subscribed, - __typename: 'WorkItemWidgetNotifications', - }, - ], - __typename: 'WorkItem', + subscribed, + }, + errors: [], + }, + }, + update: ( + cache, + { + data: { + updateWorkItemNotificationsSubscription: { issue = {} }, }, - __typename: 'WorkItemUpdatePayload', }, + ) => { + // As the mutation and the query both are different, + // overwrite the subscribed value in the cache + this.updateWorkItemNotificationsWidgetCache({ + cache, + issue, + }); }, }) .then( ({ data: { - workItemUpdate: { errors }, + updateWorkItemNotificationsSubscription: { errors }, }, }) => { if (errors?.length) { @@ -251,6 +253,25 @@ export default { Sentry.captureException(error); }); }, + updateWorkItemNotificationsWidgetCache({ cache, issue }) { + const query = { + query: workItemByIidQuery, + variables: { fullPath: this.fullPath, iid: this.workItemIid }, + }; + // Read the work item object + const sourceData = cache.readQuery(query); + + const newData = produce(sourceData, (draftState) => { + const { widgets } = draftState.workspace.workItems.nodes[0]; + + const widgetNotifications = widgets.find(({ type }) => type === WIDGET_TYPE_NOTIFICATIONS); + // overwrite the subscribed value + widgetNotifications.subscribed = issue.subscribed; + }); + + // write to the cache + cache.writeQuery({ ...query, data: newData }); + }, throwConvertError() { this.$emit('error', this.i18n.convertError); }, @@ -275,6 +296,7 @@ export default { } this.$toast.show(s__('WorkItem|Promoted to objective.')); this.track('promote_kr_to_objective'); + this.$emit('promotedToObjective'); } catch (error) { this.throwConvertError(); Sentry.captureException(error); 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 f7ac63e16c3..4b4aa7f96ca 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -148,7 +148,7 @@ export default { }; }, containerClass() { - return !this.isEditing ? 'gl-shadow-none!' : ''; + return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : ''; }, isLoadingUsers() { return this.$apollo.queries.users.loading; @@ -318,7 +318,7 @@ export default { :loading="isLoadingUsers && !isLoadingMore" :view-only="!canUpdate" :allow-clear-all="isEditing" - class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2 work-item-field-value" + class="assignees-selector hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" data-testid="work-item-assignees-input" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index c727075eaac..139f0f7919c 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -11,7 +11,6 @@ import { WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, } from '../constants'; -import WorkItemState from './work_item_state.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; @@ -23,7 +22,6 @@ export default { WorkItemMilestone, WorkItemAssignees, WorkItemDueDate, - WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), @@ -97,12 +95,6 @@ export default { <template> <div class="work-item-attributes-wrapper"> - <work-item-state - :work-item="workItem" - :work-item-parent-id="workItemParentId" - :can-update="canUpdate" - @error="$emit('error', $event)" - /> <work-item-assignees v-if="workItemAssignees" :can-update="canUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue index 3dd3a072d0f..44bd17b59a2 100644 --- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue +++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue @@ -51,7 +51,7 @@ export default { return window.gon.current_user_fullname; }, /** - * Parse and convert award emoji list to a format that AwardsList can understand + * Parse and convert emoji reactions list to a format that AwardsList can understand */ awards() { if (!this.awardEmoji) { @@ -91,12 +91,15 @@ export default { skip() { return !this.workItemIid; }, - result() { + result({ data }) { if (this.hasNextPage) { this.fetchAwardEmojis(); } else { this.isLoading = false; } + if (data) { + this.$emit('emoji-updated', data.workspace?.workItems?.nodes[0]); + } }, error() { this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR); @@ -125,7 +128,7 @@ export default { ); }, /** - * Prepare award emoji nodes based on emoji name + * Prepare emoji reactions nodes based on emoji name * and whether the user has toggled the emoji off or on */ getAwardEmojiNodes(name, toggledOn) { @@ -204,7 +207,7 @@ export default { }, }, ) => { - // update the cache of award emoji widget object + // update the cache of emoji reactions widget object this.updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }); }, }) 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 78a86aa49a4..f93ea4a0753 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 @@ -1,7 +1,11 @@ <script> -import { GlAvatarLink, GlSprintf } from '@gitlab/ui'; +import { GlAvatarLink, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { WORKSPACE_PROJECT } from '~/issues/constants'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; export default { @@ -9,6 +13,10 @@ export default { GlAvatarLink, GlSprintf, TimeAgoTooltip, + WorkItemStateBadge, + WorkItemTypeIcon, + ConfidentialityBadge, + GlLoadingIcon, }, inject: ['fullPath'], props: { @@ -17,6 +25,11 @@ export default { required: false, default: null, }, + updateInProgress: { + type: Boolean, + required: false, + default: false, + }, }, computed: { createdAt() { @@ -31,6 +44,18 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, + workItemState() { + return this.workItem?.state; + }, + workItemType() { + return this.workItem?.workItemType?.name; + }, + workItemIconName() { + return this.workItem?.workItemType?.iconName; + }, + isWorkItemConfidential() { + return this.workItem?.confidential; + }, }, apollo: { workItem: { @@ -49,13 +74,29 @@ export default { }, }, }, + WORKSPACE_PROJECT, }; </script> <template> - <div class="gl-mb-3"> - <span data-testid="work-item-created"> - <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')"> + <div class="gl-mb-3 gl-text-gray-700"> + <work-item-state-badge v-if="workItemState" :work-item-state="workItemState" /> + <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" + :issuable-type="workItemType" + /> + <work-item-type-icon + class="gl-vertical-align-middle gl-mr-0!" + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType" + show-text + /> + <span data-testid="work-item-created" class="gl-vertical-align-middle"> + <gl-sprintf v-if="author.name" :message="__('created %{timeAgo} by %{author}')"> <template #timeAgo> <time-ago-tooltip :time="createdAt" /> </template> @@ -70,7 +111,7 @@ export default { </gl-avatar-link> </template> </gl-sprintf> - <gl-sprintf v-else-if="createdAt" :message="__('Created %{timeAgo}')"> + <gl-sprintf v-else-if="createdAt" :message="__('created %{timeAgo}')"> <template #timeAgo> <time-ago-tooltip :time="createdAt" /> </template> @@ -79,7 +120,7 @@ export default { <span v-if="updatedAt" - class="gl-ml-5 gl-display-none gl-sm-display-inline-block" + class="gl-ml-5 gl-display-none gl-sm-display-inline-block gl-vertical-align-middle" data-testid="work-item-updated" > <gl-sprintf :message="__('Updated %{timeAgo}')"> 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 1402b313cee..d826ef9cbe7 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -5,7 +5,6 @@ import { GlSkeletonLoader, GlLoadingIcon, GlIcon, - GlBadge, GlButton, GlTooltipDirective, GlEmptyState, @@ -19,8 +18,9 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import { WORKSPACE_PROJECT } from '~/issues/constants'; import { - sprintfWorkItem, i18n, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_NOTIFICATIONS, @@ -49,6 +49,7 @@ import WorkItemDescription from './work_item_description.vue'; 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'; export default { i18n, @@ -57,8 +58,8 @@ export default { }, isLoggedIn: isLoggedIn(), components: { + WorkItemStateToggleButton, GlAlert, - GlBadge, GlButton, GlLoadingIcon, GlSkeletonLoader, @@ -77,6 +78,7 @@ export default { WorkItemDetailModal, AbuseCategorySelector, GlIntersectionObserver, + ConfidentialityBadge, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'reportAbusePath'], @@ -134,6 +136,7 @@ export default { if (!res.data) { return; } + this.$emit('work-item-updated', this.workItem); if (isEmpty(this.workItem)) { this.setEmptyState(); } @@ -169,7 +172,7 @@ export default { return this.workItem.workItemType?.id; }, workItemBreadcrumbReference() { - return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : ''; + return this.workItemType ? `#${this.workItem.iid}` : ''; }, canUpdate() { return this.workItem?.userPermissions?.updateWorkItem; @@ -183,9 +186,6 @@ export default { canAssignUnassignUser() { return this.workItemAssignees && this.canSetWorkItemMetadata; }, - confidentialTooltip() { - return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); - }, fullPath() { return this.workItem?.project.fullPath; }, @@ -374,8 +374,8 @@ export default { } }, }, - WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WORKSPACE_PROJECT, }; </script> @@ -397,13 +397,13 @@ export default { <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0" + class="list-unstyled gl-display-flex gl-min-w-0 gl-mr-auto gl-mb-0 gl-z-index-0" data-testid="work-item-parent" > - <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> + <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-min-w-0"> <gl-button v-gl-tooltip.hover - class="gl-text-truncate gl-max-w-full" + class="gl-text-truncate" :icon="parentWorkItemIconName" category="tertiary" :href="parentUrl" @@ -418,7 +418,8 @@ export default { > <work-item-type-icon :work-item-icon-name="workItemIconName" - :work-item-type="workItemType && workItemType.toUpperCase()" + :work-item-type="workItemType" + show-text /> {{ workItemBreadcrumbReference }} </li> @@ -430,20 +431,19 @@ export default { > <work-item-type-icon :work-item-icon-name="workItemIconName" - :work-item-type="workItemType && workItemType.toUpperCase()" + :work-item-type="workItemType" + show-text /> {{ workItemBreadcrumbReference }} </div> - <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> - <gl-badge - v-if="workItem.confidential" - v-gl-tooltip.bottom - :title="confidentialTooltip" - variant="warning" - icon="eye-slash" - class="gl-mr-3 gl-cursor-help" - >{{ __('Confidential') }}</gl-badge - > + <work-item-state-toggle-button + v-if="canUpdate" + :work-item-id="workItem.id" + :work-item-state="workItem.state" + :work-item-parent-id="workItemParentId" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" @@ -464,9 +464,11 @@ export default { :work-item-reference="workItem.reference" :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" + :work-item-iid="workItemIid" @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> <gl-button v-if="isModal" @@ -488,7 +490,10 @@ export default { :can-update="canUpdate" @error="updateError = $event" /> - <work-item-created-updated :work-item-iid="workItemIid" /> + <work-item-created-updated + :work-item-iid="workItemIid" + :update-in-progress="updateInProgress" + /> </div> <gl-intersection-observer v-if="showIntersectionObserver" @@ -508,15 +513,12 @@ export default { {{ workItem.title }} </span> <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" /> - <gl-badge + <confidentiality-badge v-if="workItem.confidential" - v-gl-tooltip.bottom - :title="confidentialTooltip" - variant="warning" - icon="eye-slash" - class="gl-mr-3 gl-cursor-help" - >{{ __('Confidential') }}</gl-badge - > + data-testid="confidential" + :workspace-type="$options.WORKSPACE_PROJECT" + :issuable-type="workItemType" + /> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" @@ -537,11 +539,13 @@ export default { :work-item-reference="workItem.reference" :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" + :work-item-iid="workItemIid" @deleteWorkItem=" $emit('deleteWorkItem', { workItemType, workItemId: workItem.id }) " @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> </div> </div> @@ -573,6 +577,7 @@ export default { :award-emoji="workItemAwardEmoji.awardEmoji" :work-item-iid="workItemIid" @error="updateError = $event" + @emoji-updated="$emit('work-item-emoji-updated', $event)" /> <work-item-tree v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" @@ -584,6 +589,7 @@ export default { :can-update="canUpdate" :confidential="workItem.confidential" @show-modal="openInModal" + @addChild="$emit('addChild')" /> <work-item-notes v-if="workItemNotes" diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue index b4b3049d669..1aa62a2b906 100644 --- a/app/assets/javascripts/work_items/components/work_item_due_date.vue +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -219,7 +219,6 @@ export default { ref="startDatePicker" v-model="dirtyStartDate" container="body" - data-testid="work-item-start-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.startDateInputId" show-clear-button @@ -250,7 +249,6 @@ export default { ref="dueDatePicker" v-model="dirtyDueDate" container="body" - data-testid="work-item-due-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.dueDateInputId" :min-date="dirtyStartDate" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 8676456a6a4..1405a12a101 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -9,13 +9,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; - -import { - i18n, - I18N_WORK_ITEM_ERROR_FETCHING_LABELS, - TRACKING_CATEGORY_SHOW, - WIDGET_TYPE_LABELS, -} from '../constants'; +import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants'; +import { isLabelsWidget } from '../utils'; function isTokenSelectorElement(el) { return ( @@ -121,13 +116,13 @@ export default { return this.labelsWidget?.allowsScopedLabels; }, containerClass() { - return !this.isEditing ? 'gl-shadow-none!' : ''; + return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : ''; }, isLoading() { return this.$apollo.queries.searchLabels.loading; }, labelsWidget() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.workItem?.widgets?.find(isLabelsWidget); }, labels() { return this.labelsWidget?.labels?.nodes || []; @@ -272,7 +267,7 @@ export default { :loading="isLoading" :view-only="!canUpdate" :allow-clear-all="isEditing" - class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2! work-item-field-value" + class="hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" menu-class="token-selector-menu-class" data-testid="work-item-labels-input" :class="{ 'gl-hover-border-gray-200': canUpdate }" diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue index dc5bcdc3dcc..c5be1a3ead3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -1,7 +1,7 @@ <script> -import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; const objectiveActionItems = [ { @@ -29,10 +29,30 @@ export default { keyResultActionItems, objectiveActionItems, components: { - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, + }, + computed: { + objectiveDropdownItems() { + return { + name: __('Objective'), + items: this.$options.objectiveActionItems.map((item) => ({ + text: item.title, + action: () => this.change(item), + })), + }; + }, + keyResultDropdownItems() { + return { + name: __('Key result'), + items: this.$options.keyResultActionItems.map((item) => ({ + text: item.title, + action: () => this.change(item), + })), + }; + }, + dropdownItems() { + return [this.objectiveDropdownItems, this.keyResultDropdownItems]; + }, }, methods: { change({ eventName }) { @@ -43,24 +63,10 @@ export default { </script> <template> - <gl-dropdown :text="__('Add')" size="small" right> - <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="item in $options.objectiveActionItems" - :key="item.eventName" - @click="change(item)" - > - {{ item.title }} - </gl-dropdown-item> - - <gl-dropdown-divider /> - <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="item in $options.keyResultActionItems" - :key="item.eventName" - @click="change(item)" - > - {{ item.title }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown + :toggle-text="__('Add')" + size="small" + placement="right" + :items="dropdownItems" + /> </template> 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 ec44a654e89..a9b0c2b98bf 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,39 +1,27 @@ <script> -import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import * as Sentry from '@sentry/browser'; import { __, s__ } from '~/locale'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; -import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; -import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import { STATE_OPEN, TASK_TYPE_NAME, WORK_ITEM_TYPE_VALUE_OBJECTIVE, - WIDGET_TYPE_PROGRESS, - WIDGET_TYPE_HEALTH_STATUS, - WIDGET_TYPE_MILESTONE, WIDGET_TYPE_HIERARCHY, - WIDGET_TYPE_ASSIGNEES, - WIDGET_TYPE_LABELS, WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; -import WorkItemLinksMenu from './work_item_links_menu.vue'; +import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { - GlLabel, - GlLink, GlButton, - GlIcon, - RichTimestampTooltip, - WorkItemLinkChildMetadata, - WorkItemLinksMenu, WorkItemTreeChildren, + WorkItemLinkChildContents, }, directives: { GlTooltip: GlTooltipDirective, @@ -74,25 +62,9 @@ export default { }; }, computed: { - labels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; - }, - allowsScopedLabels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; - }, canHaveChildren() { return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; }, - metadataWidgets() { - return this.childItem.widgets?.reduce((metadataWidgets, widget) => { - // Skip Hierarchy widget as it is not part of metadata. - if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) { - // eslint-disable-next-line no-param-reassign - metadataWidgets[widget.type] = widget; - } - return metadataWidgets; - }, {}); - }, isItemOpen() { return this.childItem.state === STATE_OPEN; }, @@ -126,18 +98,6 @@ export default { chevronTooltip() { return this.isExpanded ? __('Collapse') : __('Expand'); }, - hasMetadata() { - if (this.metadataWidgets) { - return ( - Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) || - Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) || - Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) || - this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 || - this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0 - ); - } - return false; - }, }, watch: { childItem: { @@ -270,81 +230,15 @@ export default { data-testid="expand-child" @click="toggleItem" /> - <div - class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base" - data-testid="links-child" - > - <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> - <div - class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" - > - <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> - <span - :id="`stateIcon-${childItem.id}`" - class="gl-cursor-help" - data-testid="item-status-icon" - > - <gl-icon - class="gl-text-secondary" - :class="iconClass" - :name="iconName" - :aria-label="stateTimestampTypeText" - /> - </span> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" - /> - <span v-if="childItem.confidential"> - <gl-icon - v-gl-tooltip.top - name="eye-slash" - class="gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" - /> - </span> - <gl-link - :href="childPath" - class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" - data-testid="item-title" - @click="$emit('click', $event)" - @mouseover="$emit('mouseover')" - @mouseout="$emit('mouseout')" - > - {{ childItem.title }} - </gl-link> - </div> - <work-item-link-child-metadata - v-if="hasMetadata" - :metadata-widgets="metadataWidgets" - class="gl-ml-6 ml-xl-0" - /> - </div> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> - <gl-label - v-for="label in labels" - :key="label.id" - :title="label.title" - :background-color="label.color" - :description="label.description" - :scoped="showScopedLabel(label)" - class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" - tooltip-placement="top" - /> - </div> - </div> - <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> - <work-item-links-menu - :work-item-id="childItem.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="$emit('removeChild', childItem)" - /> - </div> - </div> + <work-item-link-child-contents + :child-item="childItem" + :can-update="canUpdate" + :parent-work-item-id="issuableGid" + :work-item-type="workItemType" + :child-path="childPath" + @click="$emit('click', $event)" + @removeChild="$emit('removeChild', childItem)" + /> </div> <work-item-tree-children v-if="isExpanded" 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 bfc6ceefccc..a0ff693e156 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 @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlIcon, + GlLoadingIcon, + GlTooltipDirective, +} from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -20,8 +26,8 @@ import WorkItemChildrenWrapper from './work_item_children_wrapper.vue'; export default { components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlIcon, GlLoadingIcon, WidgetWrapper, @@ -211,26 +217,30 @@ export default { </span> </template> <template #header-right> - <gl-dropdown + <gl-disclosure-dropdown v-if="canUpdate && canAddTask" - right + placement="right" size="small" - :text="$options.i18n.addChildButtonLabel" + :toggle-text="$options.i18n.addChildButtonLabel" data-testid="toggle-form" > - <gl-dropdown-item + <gl-disclosure-dropdown-item data-testid="toggle-create-form" - @click="showAddForm($options.FORM_TYPES.create)" + @action="showAddForm($options.FORM_TYPES.create)" > - {{ $options.i18n.createChildOptionLabel }} - </gl-dropdown-item> - <gl-dropdown-item + <template #list-item> + {{ $options.i18n.createChildOptionLabel }} + </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item data-testid="toggle-add-form" - @click="showAddForm($options.FORM_TYPES.add)" + @action="showAddForm($options.FORM_TYPES.add)" > - {{ $options.i18n.addChildOptionLabel }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item> + {{ $options.i18n.addChildOptionLabel }} + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> <template #body> <div class="gl-new-card-content"> 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 db649913602..4960189fb48 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 @@ -306,6 +306,7 @@ export default { [this.error] = data.workItemCreate.errors; } else { this.unsetError(); + this.$emit('addChild'); } }) .catch(() => { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 83f3c391769..246eac82c78 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 @@ -145,6 +145,7 @@ export default { :children-ids="childrenIds" :parent-confidential="confidential" @cancel="hideAddForm" + @addChild="$emit('addChild')" /> <work-item-children-wrapper :children="children" 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 new file mode 100644 index 00000000000..1d1bc7352b1 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue @@ -0,0 +1,41 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STATE_OPEN } from '../constants'; + +export default { + components: { + GlBadge, + }, + props: { + workItemState: { + type: String, + required: true, + }, + }, + computed: { + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + stateText() { + return this.isWorkItemOpen ? __('Open') : __('Closed'); + }, + workItemStateIcon() { + return this.isWorkItemOpen ? 'issue-open-m' : 'issue-close'; + }, + workItemStateVariant() { + return this.isWorkItemOpen ? 'success' : 'info'; + }, + }, +}; +</script> + +<template> + <gl-badge + :icon="workItemStateIcon" + :variant="workItemStateVariant" + class="gl-mr-2 gl-vertical-align-middle" + > + {{ stateText }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue index 3880ae25c8c..0ea30845466 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue @@ -1,26 +1,35 @@ <script> +import { GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; +import { __, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, STATE_OPEN, - STATE_CLOSED, STATE_EVENT_CLOSE, STATE_EVENT_REOPEN, TRACKING_CATEGORY_SHOW, } from '../constants'; -import { getUpdateWorkItemMutation } from './update_work_item'; -import ItemState from './item_state.vue'; export default { components: { - ItemState, + GlButton, }, mixins: [Tracking.mixin()], props: { - workItem: { - type: Object, + workItemState: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, required: true, }, workItemParentId: { @@ -28,11 +37,6 @@ export default { required: false, default: null, }, - canUpdate: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -40,8 +44,16 @@ export default { }; }, computed: { - workItemType() { - return this.workItem.workItemType?.name; + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + toggleWorkItemStateText() { + const baseText = this.isWorkItemOpen + ? __('Close %{workItemType}') + : __('Reopen %{workItemType}'); + return capitalizeFirstCharacter( + sprintf(baseText, { workItemType: this.workItemType.toLowerCase() }), + ); }, tracking() { return { @@ -52,25 +64,10 @@ export default { }, }, methods: { - updateWorkItemState(newState) { - const stateEventMap = { - [STATE_OPEN]: STATE_EVENT_REOPEN, - [STATE_CLOSED]: STATE_EVENT_CLOSE, - }; - - const stateEvent = stateEventMap[newState]; - - this.updateWorkItem(stateEvent); - }, - - async updateWorkItem(updatedState) { - if (!updatedState) { - return; - } - + async updateWorkItem() { const input = { - id: this.workItem.id, - stateEvent: updatedState, + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, }; this.updateInProgress = true; @@ -107,10 +104,10 @@ export default { </script> <template> - <item-state - v-if="workItem.state" - :state="workItem.state" - :disabled="updateInProgress || !canUpdate" - @changed="updateWorkItemState" - /> + <gl-button + :loading="updateInProgress" + data-testid="work-item-state-toggle" + @click="updateWorkItem" + >{{ toggleWorkItemStateText }}</gl-button + > </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 96a6493357c..f27ae5f4e6d 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 @@ -32,13 +32,18 @@ export default { }, }, computed: { + workItemTypeUppercase() { + return this.workItemType.toUpperCase().split(' ').join('_'); + }, iconName() { return ( - this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' + this.workItemIconName || + WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.icon || + 'issue-type-issue' ); }, workItemTypeName() { - return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name; + return WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.name; }, workItemTooltipTitle() { return this.showTooltipOnHover ? this.workItemTypeName : ''; @@ -48,12 +53,12 @@ export default { </script> <template> - <span> + <span class="gl-mr-2"> <gl-icon v-gl-tooltip.hover="showTooltipOnHover" :name="iconName" :title="workItemTooltipTitle" - class="gl-mr-2 gl-text-secondary" + class="gl-text-secondary" /> <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> </span> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index b8324d7d552..57206550328 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -159,7 +159,7 @@ export const WORK_ITEMS_TYPE_MAP = { }, [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: { icon: `issue-type-keyresult`, - name: s__('WorkItem|Key Result'), + name: s__('WorkItem|Key result'), value: WORK_ITEM_TYPE_VALUE_KEY_RESULT, }, }; @@ -247,3 +247,13 @@ export const EMOJI_ACTION_ADD = 'ADD'; export const EMOJI_ACTION_REMOVE = 'REMOVE'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; + +export const WORK_ITEM_TO_ISSUE_MAP = { + [WIDGET_TYPE_ASSIGNEES]: 'assignees', + [WIDGET_TYPE_LABELS]: 'labels', + [WIDGET_TYPE_MILESTONE]: 'milestone', + [WIDGET_TYPE_WEIGHT]: 'weight', + [WIDGET_TYPE_START_AND_DUE_DATE]: 'dueDate', + [WIDGET_TYPE_HEALTH_STATUS]: 'healthStatus', + [WIDGET_TYPE_AWARD_EMOJI]: 'awardEmoji', +}; diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql index 5c93370aac9..9828363990b 100644 --- a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql @@ -5,4 +5,5 @@ fragment MilestoneFragment on Milestone { state startDate dueDate + webPath } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql index f8952b62f28..f28317b79b5 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql @@ -1,13 +1,9 @@ -mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) { - workItemUpdate(input: $input) { - workItem { +mutation updateWorkItemNotificationsWidget($input: IssueSetSubscriptionInput!) { + updateWorkItemNotificationsSubscription: issueSetSubscription(input: $input) { + issue { id - widgets { - ... on WorkItemWidgetNotifications { - type - subscribed - } - } + subscribed } + errors } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql index 4c3be007d96..66ac9dcd8d1 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql @@ -1,7 +1,7 @@ #import "./work_item.fragment.graphql" query workItemByIid($fullPath: ID!, $iid: String) { - workspace: project(fullPath: $fullPath) { + workspace: project(fullPath: $fullPath) @persist { id workItems(iid: $iid) { nodes { 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 new file mode 100644 index 00000000000..fe7cb719bbb --- /dev/null +++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -0,0 +1,73 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { STATUS_OPEN } from '~/issues/constants'; +import { __, s__ } from '~/locale'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; +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, + }, + inject: ['fullPath'], + data() { + return { + error: undefined, + searchTokens: [], + sortOptions: [], + state: STATUS_OPEN, + workItems: [], + }; + }, + apollo: { + workItems: { + query: getWorkItemsQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return data.group.workItems.nodes ?? []; + }, + error(error) { + this.error = s__( + 'WorkItem|Something went wrong when fetching work items. Please try again.', + ); + Sentry.captureException(error); + }, + }, + }, + methods: { + getStatus(issue) { + return issue.state === STATE_CLOSED ? __('Closed') : undefined; + }, + }, +}; +</script> + +<template> + <issuable-list + :current-tab="state" + :error="error" + :issuables="workItems" + 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 #status="{ issuable }"> + {{ getStatus(issuable) }} + </template> + </issuable-list> +</template> diff --git a/app/assets/javascripts/work_items/list/index.js b/app/assets/javascripts/work_items/list/index.js new file mode 100644 index 00000000000..5cd38600779 --- /dev/null +++ b/app/assets/javascripts/work_items/list/index.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import WorkItemsListApp from './components/work_items_list_app.vue'; + +export const mountWorkItemsListApp = () => { + const el = document.querySelector('.js-work-items-list-root'); + + if (!el) { + return null; + } + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'WorkItemsListRoot', + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), + provide: { + fullPath: el.dataset.fullPath, + }, + render: (createComponent) => createComponent(WorkItemsListApp), + }); +}; 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 new file mode 100644 index 00000000000..7ada2cf12dd --- /dev/null +++ b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql @@ -0,0 +1,56 @@ +query getWorkItems($fullPath: ID!) { + group(fullPath: $fullPath) { + id + workItems { + nodes { + id + author { + id + avatarUrl + name + username + webUrl + } + closedAt + confidential + createdAt + iid + reference(full: true) + state + title + updatedAt + webUrl + widgets { + ... on WorkItemWidgetAssignees { + assignees { + nodes { + id + avatarUrl + name + username + webUrl + } + } + type + } + ... on WorkItemWidgetLabels { + allowsScopedLabels + labels { + nodes { + id + color + description + title + } + } + type + } + } + workItemType { + id + name + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 81dbe56b2ea..5a882977bc2 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,4 +1,8 @@ -import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; +import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_LABELS } from './constants'; + +export const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES; + +export const isLabelsWidget = (widget) => widget.type === WIDGET_TYPE_LABELS; export const findHierarchyWidgets = (widgets) => widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); diff --git a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue index 9b81218b6e4..69107c7df12 100644 --- a/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue +++ b/app/assets/javascripts/work_items_hierarchy/components/hierarchy.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon, GlBadge } from '@gitlab/ui'; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 483c4dc226b..47701d0490a 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -5,7 +5,6 @@ @import './pages/hierarchy'; @import './pages/issues'; @import './pages/labels'; -@import './pages/merge_requests'; @import './pages/note_form'; @import './pages/notes'; @import './pages/pipelines'; diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 08a956bf90f..2030f2c7095 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -114,6 +114,18 @@ max-width: 100%; } + > ul { + list-style-type: disc; + + ul { + list-style-type: circle; + + ul { + list-style-type: square; + } + } + } + ul[data-type='taskList'] { list-style: none; padding: 0; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 56ec61ffd84..28c0c071dc0 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -256,6 +256,10 @@ gl-emoji { margin-top: -1px; margin-bottom: -1px; + + img { + top: 0; + } } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 8059164782f..8a64b0999b6 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -126,23 +126,12 @@ } @mixin btn-with-margin { - margin-left: $btn-side-margin; + @include gl-ml-3; float: left; &.inline { float: none; } - - &.btn-sm { - margin-left: $btn-sm-side-margin; - } -} - -@mixin btn-svg { - height: $gl-padding; - width: $gl-padding; - top: 0; - vertical-align: text-top; } .btn { @@ -348,14 +337,6 @@ } } -// The .btn-svg class is available for legacy icon buttons to -// preserve a 34px height and have 16x16 icons at the same time. -// Once a button is migrated (to the current 32px height) -// please remove this class from the new button. -.btn-svg svg { - @include btn-svg; -} - // All disabled buttons, regardless of color, type, etc .btn.disabled, .btn[disabled], diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 7b35659e90a..4bf109a0bff 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -646,12 +646,12 @@ table.code { .diff-comments-more-count, .diff-notes-collapse, -.diff-codequality-collapse { +.inline-findings-collapse { @include avatar-counter(50%); } .diff-notes-collapse, -.diff-codequality-collapse { +.inline-findings-collapse { border: 0; border-radius: 50%; padding: 0; @@ -735,7 +735,7 @@ table.code { } .diff-notes-collapse, - .diff-codequality-collapse { + .inline-findings-collapse { position: absolute; left: -12px; } @@ -845,7 +845,7 @@ table.code { } .diff-notes-collapse, - .diff-codequality-collapse, + .inline-findings-collapse, .note, .discussion-reply-holder { display: none; @@ -929,3 +929,13 @@ table.code { border-bottom: 0; } } + +.tooltip { + &.coverage { + left: -3px !important; + } + + &.no-coverage { + left: -2px !important; + } +} diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 358f599e0e9..9b22e4cebb2 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -3,10 +3,14 @@ gl-emoji { display: inline-flex; vertical-align: baseline; font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 1.2em; + line-height: 1; img { width: 1.2em; height: 1.2em; + position: relative; + top: 0.25em; } } @@ -45,10 +49,18 @@ gl-emoji { .emoji-picker-category-tab { border-bottom-color: transparent; + + &:hover { + @include gl-text-gray-900; + + &:not(.emoji-picker-category-active) { + @include gl-border-b-gray-200; + } + } } .emoji-picker-category-active { - border-bottom-color: var(--gl-theme-accent, $theme-indigo-500); + border-bottom-color: $blue-500; } .emoji-picker .gl-dropdown-inner > :last-child { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 2e88b45d646..613e504c771 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -263,6 +263,11 @@ span.idiff { } } +.file-validation { + // we use $gray-light variable instead of utility class, because it's value is dynamic per color theme + background-color: $gray-light; +} + .blob-content-holder .file-actions { @include media-breakpoint-down(sm) { .btn { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index f5ed85e8845..b9fbcfb642c 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -123,7 +123,9 @@ $search-input-field-x-min-width: 200px; padding: 0; @include media-breakpoint-down(xs) { - flex: 1 1 auto; + .legacy-top-bar & { + flex: 1 1 auto; + } } .nav { @@ -193,8 +195,10 @@ $search-input-field-x-min-width: 200px; padding: 6px 8px; height: 32px; - @include media-breakpoint-down(xs) { - padding: 0; + .legacy-top-bar & { + @include media-breakpoint-down(xs) { + padding: 0; + } } &.header-user-dropdown-toggle { @@ -322,7 +326,7 @@ $search-input-field-x-min-width: 200px; left: var(--application-bar-left); position: fixed; right: var(--application-bar-right); - top: $calc-system-headers-height; + top: $calc-application-bars-height; width: auto; z-index: $top-bar-z-index; @@ -427,7 +431,7 @@ $search-input-field-x-min-width: 200px; } @include media-breakpoint-down(xs) { - .navbar-gitlab .container-fluid { + .navbar-gitlab.legacy-top-bar .container-fluid { font-size: 18px; .navbar-nav { @@ -622,3 +626,27 @@ $search-input-field-x-min-width: 200px; } } } + +header.navbar-gitlab.super-sidebar-logged-out { + background-color: $brand-charcoal !important; + + li.nav-item > a { + @include gl-text-white; + @include gl-font-weight-normal; + + &:hover, + &:focus { + background-color: $brand-gray-04; + text-decoration: none; + } + + &:focus, + &:active { + box-shadow: inset 0 0 0 $gl-border-size-1 $white; + } + + &:active { + background-color: $brand-gray-03; + } + } +} diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index f8f54567ef2..b953ff3024b 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -91,7 +91,7 @@ } .md-preview-holder { - min-height: 173px; + min-height: 177px; padding: 10px 0; overflow-x: auto; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 529f6acaf04..edebe9c95ad 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -326,11 +326,6 @@ color: $gray-500; fill: $gray-500; - svg { - @include btn-svg; - margin: 0; - } - .award-control-icon-positive, .award-control-icon-super-positive { position: absolute; diff --git a/app/assets/stylesheets/framework/new_card.scss b/app/assets/stylesheets/framework/new_card.scss index ef8f5cc1d1b..1432e7a174c 100644 --- a/app/assets/stylesheets/framework/new_card.scss +++ b/app/assets/stylesheets/framework/new_card.scss @@ -92,3 +92,64 @@ @include gl-rounded-base; } } + +.gl-new-card-body { + // Table adjustments + @mixin new-card-table-adjustments { + tbody > tr { + &:first-of-type > td[data-label], + &:first-of-type > td:first-of-type:last-of-type { + @include gl-border-t-0; + } + + &:last-of-type td:not(:last-of-type) { + @include gl-border-b-1; + } + + > td[data-label] { + @include gl-border-left-0; + @include gl-border-l-none; + @include gl-border-right-0; + @include gl-border-r-none; + } + + > th { + @include gl-border-t-1; + @include gl-border-b-0; + } + + &::after { + @include gl-bg-white; + } + + &:last-child::after { + @include gl-display-none; + } + } + } + + table.b-table-stacked-sm, + table.b-table-stacked-md { + @include gl-mb-0; + + tr:first-of-type th { + @include gl-border-t-0; + } + + tr:last-of-type td { + @include gl-border-b-0; + } + } + + table.gl-table.b-table.b-table-stacked-sm { + @include gl-media-breakpoint-down(sm) { + @include new-card-table-adjustments; + } + } + + table.gl-table.b-table.b-table-stacked-md { + @include gl-media-breakpoint-down(md) { + @include new-card-table-adjustments; + } + } +} diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 5ba0b1d0828..f77a919ef0f 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -39,7 +39,7 @@ a.active { color: $black; font-weight: $gl-font-weight-bold; - box-shadow: inset 0 -2px 0 0 var(--gl-theme-accent, $theme-indigo-500); + box-shadow: inset 0 -2px 0 0 $blue-500; .badge.badge-pill { color: $black; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 12801b272e8..8610c41b43f 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -159,33 +159,12 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .nav-item-link { - button, - .draggable-icon { - opacity: 0; - } - - .draggable-icon { - cursor: grab; - } - - &:hover { - button, - .draggable-icon { - opacity: 1; - } - } - &:hover, &:focus-within { .nav-item-badge { opacity: 0; } } - - &:focus button, - button:focus { - opacity: 1; - } } #trial-status-sidebar-widget:hover { @@ -294,8 +273,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .search-scope-help { - top: 0.625rem; - right: 2.5rem; + top: 1rem; + right: 3rem; } .gl-search-box-by-type-input-borderless { @@ -304,5 +283,31 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; .global-search-results { max-height: 30rem; + + .gl-new-dropdown-item { + @include gl-px-3; + } + + // Target groups + [id*='gl-disclosure-dropdown-group'] { + @include gl-px-5; + } + } +} + +.show-on-focus-or-hover--context { + .show-on-focus-or-hover--target { + opacity: 0; + } + + &:hover, + &:focus { + .show-on-focus-or-hover--target { + opacity: 1; + } + } + + .show-on-focus-or-hover--target:focus { + opacity: 1; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ebaaece1281..d632689a4f6 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -473,7 +473,6 @@ $border-radius-large: 8px; $default-icon-size: 16px; $layout-link-gray: #7e7c7c; $btn-side-margin: $grid-size; -$btn-sm-side-margin: 7px; $count-arrow-border: #dce0e5; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; @@ -866,6 +865,7 @@ $security-and-compliance-carousel-image-discover-text-carousel-max-width: 650px; $security-and-compliance-carousel-image-discover-text-carousel-caption-height: 100%; $security-and-compliance-carousel-image-discover-text-carousel-caption-max-width: 500px; $security-and-compliance-carousel-control-icon-width: 10px; +$security-and-compliance-carousel-control-icon-middle-width: 20px; $security-and-compliance-carousel-control-position: -5%; $security-and-compliance-carousel-inner-width: 90%; $security-and-compliance-carousel-indicators-bottom: -20px; diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss index 5f195bc47bf..675a33617bf 100644 --- a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss +++ b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss @@ -6,7 +6,7 @@ .line_holder { .diff-line-num, .line-coverage, - .line-codequality, + .line-inline-findings, .line_content { &.new { &:not(.hll) { diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss index a8ab43909eb..39b74f10199 100644 --- a/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss +++ b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss @@ -6,7 +6,7 @@ .line_holder { .diff-line-num, .line-coverage, - .line-codequality, + .line-inline-findings, .line_content { &.old { &:not(.hll) { diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 9ad7c1b796c..8596d79ca83 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -191,7 +191,7 @@ $dark-il: #de935f; .diff-td.diff-line-num.hll, .diff-td.line-coverage.hll, - .diff-td.line-codequality.hll, + .diff-td.line-inline-findings.hll, .diff-td.line_content.hll, td.diff-line-num.hll, td.line-coverage.hll, @@ -206,11 +206,11 @@ $dark-il: #de935f; .diff-line-num.new, .line-coverage.new, - .line-codequality.new, + .line-inline-findings.new, .line_content.new, .diff-line-num.new-nomappinginraw, .line-coverage.new-nomappinginraw, - .line-codequality.new-nomappinginraw, + .line-inline-findings.new-nomappinginraw, .line_content.new-nomappinginraw { @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border); @@ -222,11 +222,11 @@ $dark-il: #de935f; .diff-line-num.old, .line-coverage.old, - .line-codequality.old, + .line-inline-findings.old, .line_content.old, .diff-line-num.old-nomappinginraw, .line-coverage.old-nomappinginraw, - .line-codequality.old-nomappinginraw, + .line-inline-findings.old-nomappinginraw, .line_content.old-nomappinginraw { @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border); diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index b1d89d3c253..1c323ad15a6 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -182,7 +182,7 @@ $monokai-gh: #75715e; .diff-td.diff-line-num.hll, .diff-td.line-coverage.hll, - .diff-td.line-codequality.hll, + .diff-td.line-inline-findings.hll, .diff-td.line_content.hll, td.diff-line-num.hll, td.line-coverage.hll, @@ -197,11 +197,11 @@ $monokai-gh: #75715e; .diff-line-num.new, .line-coverage.new, - .line-codequality.new, + .line-inline-findings.new, .line_content.new, .diff-line-num.new-nomappinginraw, .line-coverage.new-nomappinginraw, - .line-codequality.new-nomappinginraw, + .line-inline-findings.new-nomappinginraw, .line_content.new-nomappinginraw { @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); @@ -213,11 +213,11 @@ $monokai-gh: #75715e; .diff-line-num.old, .line-coverage.old, - .line-codequality.old, + .line-inline-findings.old, .line_content.old, .diff-line-num.old-nomappinginraw, .line-coverage.old-nomappinginraw, - .line-codequality.old-nomappinginraw, + .line-inline-findings.old-nomappinginraw, .line_content.old-nomappinginraw { @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 4762aae1d12..f36eaa663e5 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -81,7 +81,7 @@ } .line-coverage:not(.hll), - .line-codequality:not(.hll) { + .line-inline-findings:not(.hll) { &.old, &.new, &.new-nomappinginraw, diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 7958959bfc3..e92239c4e11 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -185,7 +185,7 @@ $solarized-dark-il: #2aa198; .diff-td.diff-line-num.hll, .diff-td.line-coverage.hll, - .diff-td.line-codequality.hll, + .diff-td.line-inline-findings.hll, .diff-td.line_content.hll, td.diff-line-num.hll, td.line-coverage.hll, @@ -208,11 +208,11 @@ $solarized-dark-il: #2aa198; .diff-line-num.new, .line-coverage.new, - .line-codequality.new, + .line-inline-findings.new, .line_content.new, .diff-line-num.new-nomappinginraw, .line-coverage.new-nomappinginraw, - .line-codequality.new-nomappinginraw, + .line-inline-findings.new-nomappinginraw, .line_content.new-nomappinginraw { @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); @@ -224,11 +224,11 @@ $solarized-dark-il: #2aa198; .diff-line-num.old, .line-coverage.old, - .line-codequality.old, + .line-inline-findings.old, .line_content.old, .diff-line-num.old-nomappinginraw, .line-coverage.old-nomappinginraw, - .line-codequality.old-nomappinginraw, + .line-inline-findings.old-nomappinginraw, .line_content.old-nomappinginraw { @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index f156077c64d..b3aa10c3ace 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -172,7 +172,7 @@ $solarized-light-il: #2aa198; .diff-td.diff-line-num.hll, .diff-td.line-coverage.hll, - .diff-td.line-codequality.hll, + .diff-td.line-inline-findings.hll, .diff-td.line_content.hll, td.diff-line-num.hll, td.line-coverage.hll, @@ -187,11 +187,11 @@ $solarized-light-il: #2aa198; .diff-line-num.new, .line-coverage.new, - .line-codequality.new, + .line-inline-findings.new, .line_content.new, .diff-line-num.new-nomappinginraw, .line-coverage.new-nomappinginraw, - .line-codequality.new-nomappinginraw, + .line-inline-findings.new-nomappinginraw, .line_content.new-nomappinginraw { @include diff-background($solarized-light-new-bg, $solarized-light-new-idiff, $solarized-light-border); @@ -212,11 +212,11 @@ $solarized-light-il: #2aa198; .diff-line-num.old, .line-coverage.old, - .line-codequality.old, + .line-inline-findings.old, .line_content.old, .diff-line-num.old-nomappinginraw, .line-coverage.old-nomappinginraw, - .line-codequality.old-nomappinginraw, + .line-inline-findings.old-nomappinginraw, .line_content.old-nomappinginraw { @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 14524e163b2..2631055706f 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -253,7 +253,7 @@ pre.code, } .line-coverage, - .line-codequality { + .line-inline-findings { &.old, &.old-nomappinginraw { background-color: $line-removed; diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss index ed15e352b7d..a904afa7337 100644 --- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss @@ -72,7 +72,7 @@ position: relative; // link to the build - .mini-pipeline-graph-dropdown-item { + .pipeline-job-item { align-items: center; clear: both; display: flex; @@ -86,11 +86,11 @@ } } - // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered - &:hover > .mini-pipeline-graph-dropdown-item, - &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item, - .mini-pipeline-graph-dropdown-item:hover, - .mini-pipeline-graph-dropdown-item:focus { + // ensure .pipeline-job-item has hover style when action-icon is hovered + &:hover > .pipeline-job-item, + &:hover > .ci-job-component > .pipeline-job-item, + .pipeline-job-item:hover, + .pipeline-job-item:focus { outline: none; text-decoration: none; background-color: var(--gray-100, $gray-50); diff --git a/app/assets/stylesheets/page_bundles/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss index 55fffad4a0e..c950f277264 100644 --- a/app/assets/stylesheets/page_bundles/editor.scss +++ b/app/assets/stylesheets/page_bundles/editor.scss @@ -125,7 +125,7 @@ @include media-breakpoint-down(sm) { .file-editor .file-buttons { flex-direction: column; - padding: 0; + padding: $gl-padding-8 0 0; .md-header-toolbar { margin: $gl-padding-8 0; @@ -166,41 +166,6 @@ width: 100%; margin: 0 0 16px; } - - .license-selector, - .gitignore-selector, - .gitlab-ci-yml-selector, - .dockerfile-selector { - display: inline-block; - vertical-align: top; - font-family: $regular_font; - margin: 0 8px 0 0; - - @media(max-width: map-get($grid-breakpoints, lg)-1) { - display: block; - width: 100%; - margin: 5px 0; - } - - .dropdown { - line-height: 21px; - } - - .dropdown-menu-toggle { - width: 200px; - vertical-align: top; - - @media (max-width: map-get($grid-breakpoints, xl)-1) { - width: auto; - } - - @media(max-width: map-get($grid-breakpoints, lg)-1) { - display: block; - width: 100%; - margin: 5px 0; - } - } - } } .popover.suggest-gitlab-ci-yml { diff --git a/app/assets/stylesheets/page_bundles/incident_management_list.scss b/app/assets/stylesheets/page_bundles/incident_management_list.scss index 30a75103c30..312d5c2b10c 100644 --- a/app/assets/stylesheets/page_bundles/incident_management_list.scss +++ b/app/assets/stylesheets/page_bundles/incident_management_list.scss @@ -5,120 +5,6 @@ background-color: var(--green-50, $green-50); } - // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui - table { - color: var(--gray-500, $gray-500); - - tbody { - tr:not(.b-table-busy-slot):not(.b-table-empty-row) { - &:hover { - @include gl-border-t-double; - - td { - @include gl-border-b-initial; - } - } - } - } - - tr { - &:focus { - @include gl-outline-none; - } - - td, - th { - @include gl-py-5; - @include gl-outline-none; - @include gl-relative; - } - - th { - @include gl-bg-transparent; - @include gl-font-weight-bold; - color: var(--gray-400, $gray-400); - - - &[aria-sort='none']:hover { - background-image: url('data:image/svg+xml, %3csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"%3e %3cpath style="fill: %23BABABA;" fill-rule="evenodd" d="M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, 10.6834 4.292875,10.2929 C4.683375,9.90237 5.316575,9.90237 5.707075,10.2929 L6.999975, 11.5858 L6.999975,2 C6.999975,1.44771 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 ,9.90237 11.316555,9.90237 11.707085,10.2929 C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/%3e %3c/svg%3e'); - } - } - } - - @include media-breakpoint-up(md) { - tr { - &:last-child { - td { - @include gl-border-0; - } - } - } - - .sortable-cell { - padding-left: calc(0.75rem + 0.65em); - } - } - } - - @include media-breakpoint-down(sm) { - table { - tr { - @include gl-border-t-0; - - .table-col { - min-height: 68px; - } - - &:hover { - background-color: var(--white, $white); - @include gl-border-none; - } - - th, - td { - @include gl-pt-6; - } - } - - &.alert-management-table { - .table-col { - &:last-child { - background-color: var(--gray-10, $gray-10); - - &::before { - content: none !important; - } - - div:not(.dropdown-title) { - width: 100% !important; - padding: 0 !important; - } - } - } - } - - .b-table-empty-row { - td { - @include gl-border-b-0; - - div { - text-align: unset !important; - } - } - } - - .b-table-busy-slot { - td { - @include gl-border-b-0; - - div { - text-align: center !important; - } - } - } - } - } - .gl-tabs-nav { @include gl-border-b-0; @@ -142,12 +28,4 @@ @include gl-w-full; } } - - .integration-list { - .b-table-empty-row { - td { - @include gl-px-0; - } - } - } } diff --git a/app/assets/stylesheets/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss index f39dee12126..b5afb8cdf4d 100644 --- a/app/assets/stylesheets/page_bundles/issues_list.scss +++ b/app/assets/stylesheets/page_bundles/issues_list.scss @@ -34,3 +34,13 @@ opacity: 0.3; pointer-events: none; } + +.work-item-labels { + .gl-token { + padding-left: $gl-spacing-scale-1; + } + + .gl-token-close { + display: none; + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_request.scss index 0a17b2c47a4..113a50c4efa 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_request.scss @@ -1,7 +1,5 @@ -/** - * MR -> show: Automerge widget - * - */ +@import 'mixins_and_variables_and_functions'; + $tabs-holder-z-index: 250; $comparison-empty-state-height: 62px; @@ -35,27 +33,6 @@ $comparison-empty-state-height: 62px; } } -.mr-state-widget { - .accept-merge-holder { - .accept-action { - .accept-merge-request { - &.ci-preparing, - &.ci-pending, - &.ci-running { - @include btn-blue; - } - - &.ci-skipped, - &.ci-failed, - &.ci-canceled, - &.ci-error { - @include btn-red; - } - } - } - } -} - .mr_source_commit, .mr_target_commit { margin-bottom: 0; @@ -192,18 +169,28 @@ $comparison-empty-state-height: 62px; .issuable-form-select-holder { display: inline-block; - width: 250px; + width: 100%; + + @include media-breakpoint-up(md) { + width: 250px; + } .dropdown-menu-toggle { width: 100%; } } +.issuable-form-label-select-holder .gl-dropdown-toggle { + @include media-breakpoint-up(md) { + width: 250px; + } +} + .table-holder { .ci-table { th { - background-color: $white; - color: $gl-text-color-secondary; + background-color: var(--white, $white); + color: var(--gl-gray-700, $gl-text-color-secondary); } } } @@ -211,8 +198,7 @@ $comparison-empty-state-height: 62px; .merge-request-tabs-holder { top: $calc-application-header-height; z-index: $tabs-holder-z-index; - background-color: $body-bg; - border-bottom: 1px solid $border-color; + border-bottom: 1px solid var(--border-color, $border-color); @include media-breakpoint-up(md) { position: sticky; @@ -239,7 +225,7 @@ $comparison-empty-state-height: 62px; margin-right: auto; .inner-page-scroll-tabs { - background-color: $white; + background-color: var(--white, $white); margin-left: -$gl-padding; padding-left: $gl-padding; } @@ -308,7 +294,7 @@ $comparison-empty-state-height: 62px; opacity: 0.65; &:hover { - color: $gray-500; + color: var(--gray-500, $gray-500); text-decoration: none; } } diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 5e20588dd70..f39247f06c2 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -1,6 +1,5 @@ @import 'mixins_and_variables_and_functions'; -$mr-widget-margin-left: 40px; $mr-widget-min-height: 69px; $tabs-holder-z-index: 250; @@ -373,12 +372,13 @@ $tabs-holder-z-index: 250; white-space: nowrap; } - @include media-breakpoint-down(md) { + /* stylelint-disable scss/at-rule-no-unknown */ + @container mr-widget-extension (max-width: 600px) { flex-direction: column; align-items: flex-start; .deployment-info { - margin-bottom: $gl-padding; + margin-bottom: $gl-padding-8; } } @@ -413,10 +413,7 @@ $tabs-holder-z-index: 250; .deploy-heading, .merge-train-position-indicator { - padding: $gl-padding-8; - @include media-breakpoint-up(md) { - padding: $gl-padding-8 $gl-padding; - } + padding: $gl-padding-8 $gl-padding; .media-body { min-width: 0; @@ -643,6 +640,13 @@ $tabs-holder-z-index: 250; text-transform: capitalize; } + .mr-pipeline-title { + // NOTE: CSS Hack to make the force the pipeline + // to the end of the line or to force it to a + // new line if there is not enough space. + flex-grow: 999; + } + .label-branch { @include gl-font-monospace; font-size: 95%; @@ -659,7 +663,7 @@ $tabs-holder-z-index: 250; > span { display: inline-block; max-width: 12.5em; - margin-bottom: -6px; + margin-bottom: -5px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -830,6 +834,12 @@ $tabs-holder-z-index: 250; .mr-widget-extension { border-top: 1px solid var(--border-color, $border-color); background-color: var(--gray-10, $gray-10); + container-name: mr-widget-extension; + container-type: inline-size; + // Adds a fix for the view app dropdown not showing up + // correctly. + @include gl-relative; + @include gl-z-index-1; &.clickable:hover { background-color: var(--gray-50, $gray-50); @@ -881,10 +891,6 @@ $tabs-holder-z-index: 250; padding-right: $gl-padding; } -.mr-widget-margin-left { - margin-left: $mr-widget-margin-left; -} - .mr-widget-section { .code-text { flex: 1; @@ -990,37 +996,6 @@ $tabs-holder-z-index: 250; .gl-dropdown-inner { max-height: none !important; } - - .md-header { - .gl-tab-nav-item { - color: var(--gl-text-color, $gl-text-color); - @include gl-py-4; - @include gl-px-3; - - &:hover { - @include gl-bg-none; - color: var(--gl-text-color, $gl-text-color); - - &:not(.gl-tab-nav-item-active) { - @include gl-inset-border-b-2-gray-200; - } - } - } - - .gl-tab-nav-item-active { - @include gl-font-weight-bold; - color: var(--gl-text-color, $gl-text-color); - @include gl-inset-border-b-2-theme-accent; - - &:active, - &:focus, - &:focus:active { - box-shadow: inset 0 -#{$gl-border-size-2} 0 0 var(--gl-theme-accent, $theme-indigo-500), - $focus-ring; - @include gl-outline-none; - } - } - } } .gl-dropdown-contents { diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index dfc86a73635..dbe82f583d1 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -1,5 +1,5 @@ @import 'mixins_and_variables_and_functions'; -@import 'framework/buttons'; +@import 'framework/mixins'; .edit-user { .emoji-menu-toggle-button { diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss index e0ee157187b..66d828ed87d 100644 --- a/app/assets/stylesheets/page_bundles/tree.scss +++ b/app/assets/stylesheets/page_bundles/tree.scss @@ -14,6 +14,8 @@ @include media-breakpoint-up(sm) { display: flex; + flex-wrap: wrap; + gap: 8px; .tree-ref-container { flex: 1; @@ -24,7 +26,11 @@ .control { float: left; - margin-left: 8px; + margin-right: 8px; + + &:last-child { + margin-right: 0; + } } } diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 013aa064c4e..e8fa93e1504 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -1,5 +1,6 @@ @import 'mixins_and_variables_and_functions'; +$work-item-field-inset-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-200, $gray-200) !important; $work-item-overview-right-sidebar-width: 340px; $work-item-sticky-header-height: 52px; @@ -8,15 +9,14 @@ $work-item-sticky-header-height: 52px; align-items: center; } -#weight-widget-input:not(:hover, :focus), -#weight-widget-input[readonly] { +.hide-unfocused-input-decoration:not(:focus, :hover), +.hide-unfocused-input-decoration:disabled { + background-color: transparent; + border-color: transparent; + background-image: none; box-shadow: none; } -#weight-widget-input[readonly] { - background-color: var(--white, $white); -} - .work-item-assignees { .assign-myself { display: none; @@ -68,19 +68,27 @@ $work-item-sticky-header-height: 52px; } .work-item-dropdown { - .gl-dropdown-toggle { - background: none !important; + // duplicate classname because we are fighting with gl-button styles + .gl-dropdown-toggle.gl-dropdown-toggle { + background: none; &:hover, &:focus { - box-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-darkest, $gray-darkest) !important; + box-shadow: $work-item-field-inset-shadow; + background-color: $input-bg; + + .gl-dark & { + // $input-bg is overridden in dark mode but that does not + // work in page bundles currently, manually override here + background-color: var(--gray-50, $input-bg); + } } &.is-not-focused:not(:hover, :focus) { box-shadow: none; .gl-button-icon { - display: none; + visibility: hidden; } } } @@ -150,6 +158,13 @@ $work-item-sticky-header-height: 52px; .work-item-overview & { max-width: 65%; } + + &.gl-form-select { + &:hover, + &:focus { + box-shadow: $work-item-field-inset-shadow; + } + } } .token-selector-menu-class { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 15d4a0fec9a..29f2d15008b 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -91,12 +91,10 @@ .prioritized-labels:not(.is-not-draggable) & { cursor: grab; - border: 1px solid transparent; &:hover, &:focus-within { - background-color: $white; - border-color: $gray-50; + background-color: $blue-50; } &:active { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 005fbc8b058..2722893d04c 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -222,6 +222,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .discussion-reply-holder { border: 1px solid $border-color; + background-color: $white; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 8cf0bebfc4e..b9cae28537d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -65,7 +65,6 @@ white-space: normal; } - .deploy-project-label, .key-created-at { svg { vertical-align: text-top; @@ -83,18 +82,6 @@ .deploy-project-list { margin-bottom: -$gl-padding-4; - - a.deploy-project-label { - margin-right: $gl-padding-4; - margin-bottom: $gl-padding-4; - color: $gl-text-color-secondary; - background-color: $gray-50; - line-height: $gl-btn-line-height; - - &:hover { - color: $blue-600; - } - } } .vs-public { @@ -493,32 +480,6 @@ } } -.protected-branches-list, -.protected-tags-list { - margin-bottom: 32px; - - .settings-message { - margin: 0; - border-radius: 0 0 1px 1px; - padding: 20px 0; - border: 0; - } - - .table-bordered { - border-radius: 1px; - - th:not(:last-child), - td:not(:last-child) { - border-right: solid 1px transparent; - } - } - - .flash-container { - padding: 0; - } -} - - .compare-revision-cards { @media (max-width: $breakpoint-lg) { .swap-button { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 728eb1fe441..0ec342b9332 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -50,25 +50,7 @@ } } -.ci-variable-table, -.deploy-freeze-table, -.ci-secure-files-table { - table { - tr { - td, - th { - padding-left: 0; - } - - // When tables are "stacked", restore td padding - @media(max-width: map-get($grid-breakpoints, lg)) { - td { - padding-left: $gl-spacing-scale-5; - } - } - } - } - +.deploy-freeze-table { @media(max-width: map-get($grid-breakpoints, lg)-1) { .truncated-container { justify-content: flex-end; @@ -76,26 +58,27 @@ } } -.settings-section { - @include gl-pt-6; - - &::after { - content: ''; - display: block; - @include gl-pb-5; - } +.settings-section::after { + content: ''; + display: block; + @include gl-mb-7; } .settings-section, -.settings-section-no-bottom + .settings-section { +.settings-section-no-bottom ~ .settings-section { @include gl-pt-0; } +// Fix for sticky header when there is no search bar. +.flash-container + .settings-section { + @include gl-pt-3; +} + .settings-section ~ .settings-section { @include gl-pt-6; } -.settings-section:not(.settings-section-no-bottom) + .settings-section { +.settings-section:not(.settings-section-no-bottom) ~ .settings-section { @include gl-border-t; } @@ -124,21 +107,25 @@ $sticky-header-z-index: 98; display: block; height: $gl-padding-8; position: sticky; - top: calc(#{$calc-application-header-height} + 40px); - box-shadow: 0 1px 1px $gray-200; + top: calc(#{$calc-application-header-height} + 36px); + box-shadow: 0 1px 0 $gray-100; } } .settings-sticky-header-inner { position: sticky; - padding: $gl-padding $gl-padding $gl-padding-12; + padding: $gl-padding-12 $gl-padding $gl-padding-8; margin: #{-$gl-padding} #{-$gl-padding} 0; background: $body-bg; } .settings-sticky-footer { bottom: 0; - padding-top: $gl-padding-8; - padding-bottom: $gl-padding-8; - box-shadow: 0 #{-$gl-padding-4} $gl-padding-12 $gl-padding-4 $body-bg; + padding: $gl-padding-8 0; + box-shadow: 0 -1px 0 $gray-100; +} + +// Header shouldn't be sticky if only one section on page +.settings-sticky-header:first-of-type:last-of-type { + position: static; } diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 84181a00f34..c3662c3e6ea 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -1,3 +1,9 @@ +@import 'framework/variables'; +@import 'framework/variables_overrides'; + +@import '@gitlab/ui/src/scss/variables'; +@import '@gitlab/ui/src/scss/utility-mixins/index'; + .md h1, .md h2, .md h3, @@ -20,6 +26,35 @@ font-weight: 600; } +.md { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + + // fix blockquote style in print + blockquote { + &::before { + position: absolute; + top: 0; + left: -4px; + content: ' '; + height: 100%; + width: 4px; + background-color: $white-dark; + } + + position: relative; + font-size: inherit; + @include gl-text-gray-700; + @include gl-py-3; + @include gl-pl-6; + @include gl-my-3; + @include gl-mx-0; + @include gl-inset-border-l-4-gray-100; + margin-left: 4px; + border: 0 !important; + } +} + header, nav, nav.navbar-collapse, @@ -40,6 +75,7 @@ ul.notes-form, .note-action-button, .right-sidebar, .flash-container, +copy-code, #js-peek { display: none !important; } diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index e1f540c0f5f..e249ecbd10b 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -145,6 +145,10 @@ } .btn-group { + position: relative; + display: inline-flex; + vertical-align: middle; + button.btn, a.btn { background-color: $white; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index c471d6183d8..36fa457f244 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -19,6 +19,12 @@ $gray-dark: darken($gray-100, 2); $gray-darker: darken($gray-200, 2); $gray-darkest: $gray-700; +// Some of the other $t-gray-a variables are used +// for borders and some other places, so we cannot override +// them. These are used only for box shadows so we can +$t-gray-a-16: rgba($gray-10, 0.16); +$t-gray-a-24: rgba($gray-10, 0.24); + $black: #fff; $black-normal: $gray-900; $white: $gray-50; diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss index 90122cec31f..06f3e13e99e 100644 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ b/app/assets/stylesheets/themes/theme_blue.scss @@ -9,5 +9,14 @@ body { $theme-blue-900, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-blue-50, + $theme-blue-200, + $theme-blue-900, + $theme-blue-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss index a6cdfb36a7c..3112aaef227 100644 --- a/app/assets/stylesheets/themes/theme_gray.scss +++ b/app/assets/stylesheets/themes/theme_gray.scss @@ -9,5 +9,14 @@ body { $gray-900, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $gray-50, + $gray-100, + $gray-900, + $gray-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss index 0300f261d64..c9ea1162206 100644 --- a/app/assets/stylesheets/themes/theme_green.scss +++ b/app/assets/stylesheets/themes/theme_green.scss @@ -9,5 +9,14 @@ body { $theme-green-900, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-green-50, + $theme-green-200, + $theme-green-900, + $theme-green-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index f841a9047cc..8f0e0781918 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -276,3 +276,63 @@ } } } + +@mixin gitlab-theme-super-sidebar( + $theme-color-lightest, + $theme-color-light, + $theme-color, + $theme-color-darkest, +) { + --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; + } + + .super-sidebar .user-bar { + background-color: $theme-color; + + .counter { + background-color: var(--transparent-white-16) !important; + } + + .brand-logo, + .btn-default-tertiary, + .counter { + color: $theme-color-lightest; + mix-blend-mode: normal; + + &:hover, + &:focus { + background-color: var(--transparent-white-24) !important; + color: $white; + } + + .gl-icon { + color: $theme-color-light; + } + } + } + + .super-sidebar hr { + mix-blend-mode: multiply; + } + + .btn-with-notification { + &:hover, + &:focus { + mix-blend-mode: multiply; + } + + .notification-dot-info { + background-color: $theme-color-darkest; + border-color: $theme-color-lightest; + + } + } + + .active-indicator { + background-color: $theme-color; + } +} diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss index 5a27a9cfdc5..78ce96667d4 100644 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ b/app/assets/stylesheets/themes/theme_indigo.scss @@ -9,5 +9,14 @@ body { $indigo-900, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-indigo-50, + $theme-indigo-200, + $theme-indigo-900, + $theme-indigo-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss index 7cb0d98802e..73fe072393f 100644 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ b/app/assets/stylesheets/themes/theme_light_blue.scss @@ -9,5 +9,14 @@ body { $theme-light-blue-700, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-light-blue-50, + $theme-light-blue-200, + $theme-light-blue-700, + $theme-light-blue-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index 797279cc37b..720a0ec58b8 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -9,5 +9,14 @@ body { $theme-light-green-700, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-green-50, + $theme-green-200, + $theme-green-700, + $theme-green-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss index 3632c5ad45a..ff12366466a 100644 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ b/app/assets/stylesheets/themes/theme_light_indigo.scss @@ -9,5 +9,14 @@ body { $indigo-700, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-indigo-50, + $theme-indigo-200, + $theme-indigo-700, + $theme-indigo-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss index 6c10d9178f1..3ae67309014 100644 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ b/app/assets/stylesheets/themes/theme_light_red.scss @@ -9,5 +9,14 @@ body { $theme-light-red-700, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-light-red-50, + $theme-light-red-200, + $theme-light-red-700, + $theme-light-red-900, + ); + } } } diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss index 140e27de6e2..82de30e8b0e 100644 --- a/app/assets/stylesheets/themes/theme_red.scss +++ b/app/assets/stylesheets/themes/theme_red.scss @@ -9,5 +9,14 @@ body { $theme-red-900, $white ); + + .page-with-super-sidebar { + @include gitlab-theme-super-sidebar( + $theme-red-50, + $theme-red-200, + $theme-red-900, + $theme-red-900, + ); + } } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index db9802eeefa..d5e9d35983a 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -65,10 +65,6 @@ min-width: 0; } -.gl-min-h-100vh { - min-height: 100vh; -} - // .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466 .gl-font-size-inherit, .font-size-inherit { font-size: inherit; } @@ -135,3 +131,20 @@ .gl-fill-red-500 { 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 new file mode 100644 index 00000000000..021bc3ccd1b --- /dev/null +++ b/app/channels/noteable/notes_channel.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Noteable + class NotesChannel < ApplicationCable::Channel + def subscribed + project = Project.find(params[:project_id]) if params[:project_id].present? + + noteable = NotesFinder.new(current_user, { + project: project, + group_id: params[:group_id], + target_type: params[:noteable_type], + target_id: params[:noteable_id] + }).target + + return reject if noteable.nil? + return reject if Feature.disabled?(:action_cable_notes, project || noteable.try(:group)) + + stream_for noteable + rescue ActiveRecord::RecordNotFound + reject + end + end +end diff --git a/app/components/projects/ml/models_index_component.html.haml b/app/components/projects/ml/models_index_component.html.haml new file mode 100644 index 00000000000..17a92c211d5 --- /dev/null +++ b/app/components/projects/ml/models_index_component.html.haml @@ -0,0 +1 @@ +#js-index-ml-models{ data: { view_model: view_model } } diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb new file mode 100644 index 00000000000..c5c20565195 --- /dev/null +++ b/app/components/projects/ml/models_index_component.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ModelsIndexComponent < ViewComponent::Base + attr_reader :models + + def initialize(models:) + @models = models + end + + private + + def view_model + Gitlab::Json.generate({ models: models_view_model }) + end + + def models_view_model + models.map(&:present).map do |m| + { + name: m.name, + version: m.latest_version_name, + path: m.latest_package_path + } + end + end + end + end +end diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 6b998c3d494..329c4e4921a 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -4,7 +4,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController feature_category :insider_threat before_action :set_status_param, only: :index, if: -> { Feature.enabled?(:abuse_reports_list) } - before_action :find_abuse_report, only: [:show, :update, :destroy] + before_action :find_abuse_report, only: [:show, :moderate_user, :update, :destroy] def index @abuse_reports = AbuseReportsFinder.new(params).execute @@ -12,8 +12,22 @@ 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::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute + response = Admin::AbuseReports::ModerateUserService.new(@abuse_report, current_user, permitted_params).execute + + if response.success? + render json: { message: response.message } + else + render json: { message: response.message }, status: :unprocessable_entity + end + end + + def moderate_user + response = Admin::AbuseReports::ModerateUserService.new(@abuse_report, current_user, permitted_params).execute + if response.success? render json: { message: response.message } else diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index f0b6d86d48d..be1edeb0d37 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -15,6 +15,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) + push_frontend_feature_flag(:ci_variable_drawer, current_user) end feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 7f85103816e..06ee178599d 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -2,7 +2,7 @@ module Admin class BroadcastMessagesController < ApplicationController - include BroadcastMessagesHelper + include Admin::BroadcastMessagesHelper before_action :find_broadcast_message, only: [:edit, :update, :destroy] before_action :find_broadcast_messages, only: [:index, :create] @@ -11,13 +11,13 @@ module Admin urgency :low def index - @broadcast_message = BroadcastMessage.new + @broadcast_message = System::BroadcastMessage.new end def edit; end def create - @broadcast_message = BroadcastMessage.new(broadcast_message_params) + @broadcast_message = System::BroadcastMessage.new(broadcast_message_params) success = @broadcast_message.save respond_to do |format| @@ -69,18 +69,18 @@ module Admin end def preview - @broadcast_message = BroadcastMessage.new(broadcast_message_params) + @broadcast_message = System::BroadcastMessage.new(broadcast_message_params) render plain: render_broadcast_message(@broadcast_message), status: :ok end protected def find_broadcast_message - @broadcast_message = BroadcastMessage.find(params[:id]) + @broadcast_message = System::BroadcastMessage.find(params[:id]) end def find_broadcast_messages - @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord + @broadcast_messages = System::BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord end def broadcast_message_params diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index 0745ba328c6..15c4103a781 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -23,6 +23,8 @@ class Admin::IdentitiesController < Admin::ApplicationController def index @identities = @user.identities + @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 def edit diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb index dae3337d19b..1f25dad3428 100644 --- a/app/controllers/admin/impersonation_tokens_controller.rb +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -8,6 +8,8 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController def index set_index_vars + @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 def create diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 4747f3c5dea..10d060b8161 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -41,14 +41,20 @@ class Admin::LabelsController < Admin::ApplicationController end def destroy - @label.destroy - @labels = Label.templates - respond_to do |format| - format.html do - redirect_to admin_labels_path, status: :found, notice: _('Label was removed') + if @label.destroy + format.html do + redirect_to admin_labels_path, status: :found, + notice: format(_('%{label_name} was removed'), label_name: @label.name) + end + format.js { head :ok } + else + format.html do + redirect_to admin_labels_path, status: :found, + alert: @label.errors.full_messages.to_sentence + end + format.js { head :unprocessable_entity } end - format.js { head :ok } end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b75ca2649c3..f05b03c2787 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -7,6 +7,7 @@ class Admin::UsersController < Admin::ApplicationController before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate before_action :ensure_destroy_prerequisites_met, only: [:destroy] + before_action :set_shared_view_parameters, only: [:show, :projects, :keys] feature_category :user_management @@ -24,10 +25,7 @@ class Admin::UsersController < Admin::ApplicationController @users = @users.without_count if paginate_without_count? end - def show - @can_impersonate = can_impersonate_user - @impersonation_error_text = @can_impersonate ? nil : impersonation_error_text - end + def show; end # rubocop: disable CodeReuse/ActiveRecord def projects @@ -48,7 +46,7 @@ class Admin::UsersController < Admin::ApplicationController end def impersonate - if can_impersonate_user + if helpers.can_impersonate_user(user, impersonation_in_progress?) session[:impersonator_id] = current_user.id warden.set_user(user, scope: :user) @@ -60,7 +58,7 @@ class Admin::UsersController < Admin::ApplicationController redirect_to root_path else - flash[:alert] = impersonation_error_text + flash[:alert] = helpers.impersonation_error_text(user, impersonation_in_progress?) redirect_to admin_user_path(user) end @@ -384,28 +382,17 @@ class Admin::UsersController < Admin::ApplicationController Gitlab::AppLogger.info(format(_("User %{current_user_username} has started impersonating %{username}"), current_user_username: current_user.username, username: user.username)) end - def can_impersonate_user - can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress? - end - - def impersonation_error_text - if impersonation_in_progress? - _("You are already impersonating another user") - elsif user.blocked? - _("You cannot impersonate a blocked user") - elsif user.password_expired? - _("You cannot impersonate a user with an expired password") - elsif user.internal? - _("You cannot impersonate an internal user") - else - _("You cannot impersonate a user who cannot log in") - end - end - # method overriden in EE def unlock_user update_user(&:unlock_access!) end + + private + + def set_shared_view_parameters + @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 end Admin::UsersController.prepend_mod_with('Admin::UsersController') diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 9fd86e6a7e0..41a3ee3e1c8 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -52,6 +52,14 @@ module AuthenticatesWithTwoFactor elsif user && user.valid_password?(user_params[:password]) prompt_for_two_factor(user) end + rescue ActiveRecord::RecordInvalid => e + # We expect User to always be valid. + # Otherwise, raise internal server error instead of unprocessable entity to improve observability/alerting + if e.record.is_a?(User) + raise e.message + else + raise e + end end private diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 8068913eea2..539feb3cf1c 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -77,7 +77,7 @@ module EnforcesTwoFactorAuthentication end def two_factor_verifier - @two_factor_verifier ||= Gitlab::Auth::TwoFactorAuthVerifier.new(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables + @two_factor_verifier ||= Gitlab::Auth::TwoFactorAuthVerifier.new(current_user, request) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def mfa_help_page_url diff --git a/app/controllers/concerns/google_syndication_csp.rb b/app/controllers/concerns/google_syndication_csp.rb new file mode 100644 index 00000000000..c55debe448b --- /dev/null +++ b/app/controllers/concerns/google_syndication_csp.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module GoogleSyndicationCSP + extend ActiveSupport::Concern + + ALLOWED_SRC = ['*.google.com/pagead/landing', 'pagead2.googlesyndication.com/pagead/landing'].freeze + + included do + content_security_policy do |policy| + next unless helpers.google_tag_manager_enabled? || policy.directives.present? + + connect_src_values = Array.wrap( + policy.directives['connect-src'] || policy.directives['default-src'] + ) + + connect_src_values.concat(ALLOWED_SRC) if helpers.google_tag_manager_enabled? + + policy.connect_src(*connect_src_values.uniq) + end + end +end diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 53dd06ce638..e344e0dcd8c 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -43,6 +43,7 @@ module Integrations :external_wiki_url, :google_iap_service_account_json, :google_iap_audience_client_id, + :google_play_protected_refs, :group_confidential_mention_events, :group_mention_events, :incident_events, @@ -102,10 +103,14 @@ module Integrations param_values = return_value[:integration] if param_values.is_a?(ActionController::Parameters) - if %w[update test].include?(action_name) && integration.chat? && - param_values['webhook'] == BaseChatNotification::SECRET_MASK + if %w[update test].include?(action_name) && integration.chat? + param_values.delete('webhook') if param_values['webhook'] == BaseChatNotification::SECRET_MASK - param_values.delete('webhook') + if integration.try(:mask_configurable_channels?) + integration.event_channel_names.each do |channel| + param_values.delete(channel) if param_values[channel] == BaseChatNotification::SECRET_MASK + end + end end integration.secret_fields.each do |param| diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index a326fa308ad..1b49cffd408 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -256,6 +256,7 @@ module IssuableActions :milestone_id, :state_event, :subscription_event, + :confidential, assignee_ids: [], add_label_ids: [], remove_label_ids: [] diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb index 06a4ee873f8..fafd426da7a 100644 --- a/app/controllers/concerns/kas_cookie.rb +++ b/app/controllers/concerns/kas_cookie.rb @@ -8,11 +8,10 @@ module KasCookie next unless ::Gitlab::Kas::UserAccess.enabled? next unless Settings.gitlab.content_security_policy['enabled'] - kas_url = ::Gitlab::Kas.tunnel_url next if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception - kas_url += '/' unless kas_url.end_with?('/') - p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url) + p.connect_src(*Array.wrap(p.directives['connect-src']), kas_ws_url.sub(%r{/?$}, '/')) + p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url.sub(%r{/?$}, '/')) end end @@ -26,4 +25,14 @@ module KasCookie cookies[::Gitlab::Kas::COOKIE_KEY] = cookie_data end + + private + + def kas_url + ::Gitlab::Kas.tunnel_url + end + + def kas_ws_url + ::Gitlab::Kas.tunnel_ws_url + end end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 7b2cf131fce..93cf1d15086 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -11,6 +11,7 @@ module NotesActions included do before_action :set_polling_interval_header, only: [:index] + before_action :require_last_fetched_at_header!, only: [:index] before_action :require_noteable!, only: [:index, :create] before_action :authorize_admin_note!, only: [:update, :destroy] before_action :note_project, only: [:create] @@ -262,6 +263,12 @@ module NotesActions render_404 unless noteable end + def require_last_fetched_at_header! + return if request.headers['X-Last-Fetched-At'].present? + + render json: { message: 'X-Last-Fetched-At header is required' }, status: :bad_request + end + def last_fetched_at microseconds = request.headers['X-Last-Fetched-At'].to_i diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb index 986f3f17847..5112ebb3b5d 100644 --- a/app/controllers/concerns/onboarding/status.rb +++ b/app/controllers/concerns/onboarding/status.rb @@ -2,10 +2,17 @@ module Onboarding class Status - def initialize(user) + def self.tracking_label + { free: 'free_registration' } + end + + def initialize(params, session, user) + @params = params + @session = session @user = user end + # overridden in EE def continue_full_onboarding? false end @@ -39,3 +46,5 @@ module Onboarding end end end + +Onboarding::Status.prepend_mod_with('Onboarding::Status') diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index 5424354b92c..e148f5d063a 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -12,6 +12,19 @@ module ProductAnalyticsTracking route_events_to(destinations, name, action, label, &block) end end + + def track_internal_event(*controller_actions, name:, conditions: nil) + custom_conditions = [:trackable_html_request?, *conditions] + + after_action only: controller_actions, if: custom_conditions do + Gitlab::InternalEvents.track_event( + name, + user: current_user, + project: tracking_project_source, + namespace: tracking_namespace_source + ) + end + end end private diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb index 13378800ea9..6affd7bb4cc 100644 --- a/app/controllers/concerns/verifies_with_email.rb +++ b/app/controllers/concerns/verifies_with_email.rb @@ -12,6 +12,7 @@ module VerifiesWithEmail skip_before_action :required_signup_info, only: :successful_verification end + # rubocop:disable Metrics/PerceivedComplexity def verify_with_email return unless user = find_user || find_verification_user @@ -34,18 +35,42 @@ module VerifiesWithEmail # - their account has been locked because of too many failed login attempts, or # - they have logged in before, but never from the current ip address reason = 'sign in from untrusted IP address' unless user.access_locked? - send_verification_instructions(user, reason: reason) + send_verification_instructions(user, reason: reason) unless send_rate_limited?(user) prompt_for_email_verification(user) end end end end + # rubocop:enable Metrics/PerceivedComplexity def resend_verification_code return unless user = find_verification_user - send_verification_instructions(user) - prompt_for_email_verification(user) + if send_rate_limited?(user) + message = format( + s_("IdentityVerification|You've reached the maximum amount of resends. Wait %{interval} and try again."), + interval: rate_limit_interval(:email_verification_code_send) + ) + render json: { status: :failure, message: message } + else + send_verification_instructions(user) + render json: { status: :success } + end + end + + def update_email + return unless user = find_verification_user + + log_verification(user, :email_update_requested) + result = Users::EmailVerification::UpdateEmailService.new(user: user).execute(email: email_params[:email]) + + if result[:status] == :success + send_verification_instructions(user) + else + handle_verification_failure(user, result[:reason], result[:message]) + end + + render json: result end def successful_verification @@ -67,19 +92,7 @@ module VerifiesWithEmail User.find_by_id(session[:verification_user_id]) end - # After successful verification and calling sign_in, devise redirects the - # user to this path. Override it to show the successful verified page. - def after_sign_in_path_for(resource) - if action_name == 'create' && session[:verification_user_id] == resource.id - return users_successful_verification_path - end - - super - end - def send_verification_instructions(user, reason: nil) - return if send_rate_limited?(user) - service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token, user: user) raw_token, encrypted_token = service.execute user.unlock_token = encrypted_token @@ -90,7 +103,8 @@ module VerifiesWithEmail def send_verification_instructions_email(user, token) return unless user.can?(:receive_notifications) - Notify.verification_instructions_email(user.email, token: token).deliver_later + email = verification_email(user) + Notify.verification_instructions_email(email, token: token).deliver_later log_verification(user, :instructions_sent) end @@ -101,21 +115,23 @@ module VerifiesWithEmail if result[:status] == :success handle_verification_success(user) + render json: { status: :success, redirect_path: users_successful_verification_path } else handle_verification_failure(user, result[:reason], result[:message]) + render json: result end end def render_sign_in_rate_limited message = format( s_('IdentityVerification|Maximum login attempts exceeded. Wait %{interval} and try again.'), - interval: user_sign_in_interval + interval: rate_limit_interval(:user_sign_in) ) redirect_to new_user_session_path, alert: message end - def user_sign_in_interval - interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:user_sign_in][:interval] + def rate_limit_interval(rate_limit) + interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[rate_limit][:interval] distance_of_time_in_words(interval_in_seconds) end @@ -126,15 +142,19 @@ module VerifiesWithEmail def handle_verification_failure(user, reason, message) user.errors.add(:base, message) log_verification(user, :failed_attempt, reason) - - prompt_for_email_verification(user) end def handle_verification_success(user) + user.confirm if unconfirmed_verification_email?(user) + user.email_reset_offered_at = Time.current if user.email_reset_offered_at.nil? user.unlock_access! log_verification(user, :successful) sign_in(user) + + log_audit_event(current_user, user, with: authentication_method) + log_user_activity(user) + verify_known_sign_in end def trusted_ip_address?(user) @@ -146,6 +166,7 @@ module VerifiesWithEmail def prompt_for_email_verification(user) session[:verification_user_id] = user.id self.resource = user + add_gon_variables # Necessary to set the sprite_icons path, since we skip the ApplicationController before_filters render 'devise/sessions/email_verification' end @@ -154,6 +175,10 @@ module VerifiesWithEmail params.require(:user).permit(:verification_token) end + def email_params + params.require(:user).permit(:email) + end + def log_verification(user, event, reason = nil) Gitlab::AppLogger.info( message: 'Email Verification', diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index e94138c4d9b..f7c7ee62c1a 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -5,6 +5,7 @@ class ConfirmationsController < Devise::ConfirmationsController include GitlabRecaptcha include OneTrustCSP include GoogleAnalyticsCSP + include GoogleSyndicationCSP skip_before_action :required_signup_info prepend_before_action :check_recaptcha, only: :create diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 5c0c2b4adf2..29bc48f93e9 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -38,6 +38,8 @@ class GraphqlController < ApplicationController before_action :track_jetbrains_usage before_action :track_jetbrains_bundled_usage before_action :track_gitlab_cli_usage + before_action :track_visual_studio_usage + before_action :track_neovim_plugin_usage before_action :disable_query_limiting before_action :limit_query_size @@ -59,7 +61,7 @@ class GraphqlController < ApplicationController urgency :low, [:execute] def execute - result = if Feature.enabled?(:cache_introspection_query) && introspection_query? + result = if introspection_query? execute_introspection_query else multiplex? ? execute_multiplex : execute_query @@ -184,6 +186,16 @@ class GraphqlController < ApplicationController .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) end + def track_visual_studio_usage + Gitlab::UsageDataCounters::VisualStudioExtensionActivityUniqueCounter + .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) + end + + def track_neovim_plugin_usage + Gitlab::UsageDataCounters::NeovimPluginActivityUniqueCounter + .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) + end + def track_gitlab_cli_usage Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user) diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 5440908aee7..59343ec8b08 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -37,18 +37,6 @@ class Groups::ApplicationController < ApplicationController end end - def authorize_admin_group_runners! - unless can?(current_user, :admin_group_runners, group) - render_404 - end - end - - def authorize_read_group_runners! - unless can?(current_user, :read_group_runners, group) - render_404 - end - end - def authorize_create_deploy_token! unless can?(current_user, :create_deploy_token, group) render_404 diff --git a/app/controllers/groups/dependency_proxy/application_controller.rb b/app/controllers/groups/dependency_proxy/application_controller.rb index 300a82eed78..12c3679cf2a 100644 --- a/app/controllers/groups/dependency_proxy/application_controller.rb +++ b/app/controllers/groups/dependency_proxy/application_controller.rb @@ -24,7 +24,7 @@ module Groups case user_or_deploy_token when User @authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :user, []) - sign_in(user_or_deploy_token) + sign_in(user_or_deploy_token) unless user_or_deploy_token.project_bot? when DeployToken @authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :deploy_token, []) end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 2d821676677..57bca5ebc52 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -64,8 +64,13 @@ class Groups::LabelsController < Groups::ApplicationController end def destroy - @label.destroy - redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently" + if @label.destroy + redirect_to group_labels_path(@group), status: :found, + notice: format(_('%{label_name} was removed'), label_name: @label.name) + else + redirect_to group_labels_path(@group), status: :found, + alert: @label.errors.full_messages.to_sentence + end end protected diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 2dd0e36b65f..b3539da8429 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -49,7 +49,7 @@ class Groups::RunnersController < Groups::ApplicationController end def authorize_update_runner! - return if can?(current_user, :admin_group_runners, group) && can?(current_user, :update_runner, runner) + return if can?(current_user, :update_runner, runner) render_404 end diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index 169caabf9d8..f50cdd2b1de 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -14,8 +14,8 @@ module Groups feature_category :continuous_integration before_action do - push_frontend_feature_flag(:ci_group_env_scope_graphql, group) push_frontend_feature_flag(:ci_variables_pages, current_user) + push_frontend_feature_flag(:ci_variable_drawer, current_user) end urgency :low @@ -49,7 +49,6 @@ module Groups def define_variables define_ci_variables - define_view_variables end def define_ci_variables @@ -59,10 +58,6 @@ module Groups .map { |variable| variable.present(current_user: current_user) } end - def define_view_variables - @content_class = 'limit-container-width' unless fluid_layout - end - def authorize_admin_group! return render_404 unless can?(current_user, :admin_group, group) end diff --git a/app/controllers/groups/work_items_controller.rb b/app/controllers/groups/work_items_controller.rb new file mode 100644 index 00000000000..d1e15c81471 --- /dev/null +++ b/app/controllers/groups/work_items_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Groups + class WorkItemsController < Groups::ApplicationController + feature_category :team_planning + + def index + 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 ec16be8f85e..344de886a93 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -37,6 +37,7 @@ 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 @@ -218,8 +219,8 @@ class GroupsController < Groups::ApplicationController return super unless html_request? @has_issues = IssuesFinder.new(current_user, group_id: group.id, include_subgroups: true).execute - .non_archived - .exists? + .non_archived + .exists? @has_projects = group_projects.exists? @@ -293,6 +294,7 @@ class GroupsController < Groups::ApplicationController :project_creation_level, :subgroup_creation_level, :default_branch_protection, + { default_branch_protection_defaults: [:allow_force_push, { allowed_to_merge: [:access_level], allowed_to_push: [:access_level] }] }, :default_branch_name, :allow_mfa_for_subgroups, :resource_access_token_creation_allowed, @@ -309,13 +311,13 @@ class GroupsController < Groups::ApplicationController options = { include_subgroups: true } projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user) - .execute - .includes(:namespace) + .execute + .includes(:namespace) @events = EventCollection - .new(projects, offset: params[:offset].to_i, filter: event_filter, groups: groups) - .to_a - .map(&:present) + .new(projects, offset: params[:offset].to_i, filter: event_filter, groups: groups) + .to_a + .map(&:present) Events::RenderService .new(current_user) diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 12210afd44a..28732d58484 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -13,10 +13,6 @@ class Import::GithubController < Import::BaseController before_action :provider_auth, only: [:status, :realtime_changes, :create] before_action :expire_etag_cache, only: [:status, :create] - before_action only: [:status] do - push_frontend_feature_flag(:import_details_page) - end - rescue_from Octokit::Unauthorized, with: :provider_unauthorized rescue_from Octokit::TooManyRequests, with: :provider_rate_limit rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded @@ -73,9 +69,7 @@ class Import::GithubController < Import::BaseController end end - def details - render_404 unless Feature.enabled?(:import_details_page) - end + def details; end def create result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider_name) diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index 2c498820a1e..3c50d54fa10 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -8,7 +8,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController skip_before_action :verify_atlassian_jwt! def show - result = { + render json: { name: Atlassian::JiraConnect.app_name, description: 'Integrate commits, branches and merge requests from GitLab into Jira', key: Atlassian::JiraConnect.app_key, @@ -36,15 +36,10 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController gdpr: true } } - - result[:links][:feedback] = URI.join(HOME_URL, FEEDBACK_URL) if Feature.enabled?(:jira_for_cloud_app_feedback_link) - - render json: result end private - FEEDBACK_URL = '/gitlab-org/gitlab/-/issues/413652' HOME_URL = 'https://gitlab.com' DOC_URL = 'https://docs.gitlab.com/ee/integration/jira/' diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index eda72400f17..72b3516ae3f 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -130,6 +130,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController link_identity(identity_linker) set_remember_me(current_user) + store_idp_two_factor_status(build_auth_user(auth_module::User).bypass_two_factor?) + if identity_linker.changed? redirect_identity_linked elsif identity_linker.failed? @@ -159,7 +161,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end def build_auth_user(auth_user_class) - auth_user_class.new(oauth) + strong_memoize_with(:build_auth_user, auth_user_class) do + auth_user_class.new(oauth) + end end def sign_in_user_flow(auth_user_class) @@ -179,12 +183,16 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController if user.two_factor_enabled? && !auth_user.bypass_two_factor? prompt_for_two_factor(user) + store_idp_two_factor_status(false) else if user.deactivated? user.activate flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') end + # session variable for storing bypass two-factor request from IDP + store_idp_two_factor_status(true) + accept_pending_invitations(user: user) if new_user persist_accepted_terms_if_required(user) if new_user @@ -323,6 +331,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def sign_in_and_redirect_or_verify_identity(user, _, _) sign_in_and_redirect(user, event: :authentication) end + + def store_idp_two_factor_status(bypass_2fa) + if Feature.enabled?(:by_pass_two_factor_for_current_session) + session[:provider_2FA] = true if bypass_2fa + else + session.delete(:provider_2FA) + end + end end OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController') diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb index 43cc7014f62..568cfe6399d 100644 --- a/app/controllers/organizations/application_controller.rb +++ b/app/controllers/organizations/application_controller.rb @@ -2,6 +2,7 @@ module Organizations class ApplicationController < ::ApplicationController + skip_before_action :authenticate_user! before_action :organization layout 'organization' @@ -16,8 +17,10 @@ module Organizations strong_memoize_attr :organization def authorize_action!(action) - access_denied! if Feature.disabled?(:ui_for_organizations) - access_denied! unless can?(current_user, action, organization) + return if Feature.enabled?(:ui_for_organizations, current_user) && + can?(current_user, action, organization) + + access_denied! end end end diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb index 4781ef995b7..650ec97c264 100644 --- a/app/controllers/organizations/organizations_controller.rb +++ b/app/controllers/organizations/organizations_controller.rb @@ -4,7 +4,7 @@ module Organizations class OrganizationsController < ApplicationController feature_category :cell - before_action { authorize_action!(:admin_organization) } + before_action { authorize_action!(:read_organization) } def show; end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 1477f8e0aac..02f7dbf8e6f 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -45,7 +45,7 @@ class Profiles::NotificationsController < Profiles::ApplicationController projects = project_notifications.map(&:source) ActiveRecord::Associations::Preloader.new( records: projects, - associations: { namespace: [:route, :owner], group: [], creator: [] } + associations: { namespace: [:route, :owner], group: [], creator: [], project_setting: [] } ).call Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index f19113276c2..3e8555a4ed1 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -37,7 +37,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController end def preferences_param_names - preferences_param_names = [ + [ :color_scheme_id, :diffs_deletion_color, :diffs_addition_color, @@ -57,10 +57,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController :project_shortcut_buttons, :markdown_surround_selection, :markdown_automatic_lists, - :use_new_navigation + :use_new_navigation, + :enabled_following ] - preferences_param_names << :enabled_following if ::Feature.enabled?(:disable_follow_users, user) - preferences_param_names end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index b41e4d11d24..56e4b22ded2 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -48,7 +48,6 @@ class Projects::BlobController < Projects::ApplicationController urgency :low, [:create, :show, :edit, :update, :diff] before_action do - push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) @@ -275,8 +274,6 @@ class Projects::BlobController < Projects::ApplicationController @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true) @code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path) - allow_lfs_direct_download - render 'show' end @@ -321,30 +318,6 @@ class Projects::BlobController < Projects::ApplicationController current_user&.id end - def allow_lfs_direct_download - return unless directly_downloading_lfs_object? && content_security_policy_enabled? - return unless (lfs_object = @project.lfs_objects.find_by_oid(@blob.lfs_oid)) - - request.content_security_policy.directives['connect-src'] ||= [] - request.content_security_policy.directives['connect-src'] << lfs_src(lfs_object) - end - - def directly_downloading_lfs_object? - Gitlab.config.lfs.enabled && - !Gitlab.config.lfs.object_store.proxy_download && - @blob&.stored_externally? - end - - def content_security_policy_enabled? - Gitlab.config.gitlab.content_security_policy.enabled - end - - def lfs_src(lfs_object) - file = lfs_object.file - file = file.cdn_enabled_url(request.remote_ip) if file.respond_to?(:cdn_enabled_url) - file.url - end - alias_method :tracking_project_source, :project def tracking_namespace_source diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb index 37138afc719..c1d325d8998 100644 --- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb +++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb @@ -20,7 +20,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati end def render_csv(collection) - CsvBuilders::SingleBatch.new( + CsvBuilder::SingleBatch.new( collection, { date: 'date', diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 8499bf0ced7..6e7f764c5c1 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -21,3 +21,5 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController render_404 unless can_collaborate_with_project?(@project) end end + +Projects::Ci::PipelineEditorController.prepend_mod diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 59de4fbb698..34b283b87f5 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -4,8 +4,8 @@ class Projects::DiscussionsController < Projects::ApplicationController include NotesHelper include RendersNotes - before_action :check_merge_requests_available! - before_action :merge_request + before_action :check_noteable_supports_resolvable_notes! + before_action :noteable before_action :discussion, only: [:resolve, :unresolve] before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve] @@ -56,13 +56,26 @@ class Projects::DiscussionsController < Projects::ApplicationController end # rubocop: disable CodeReuse/ActiveRecord - def merge_request - @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) + def noteable + @noteable ||= noteable_finder_class.new(current_user, project_id: @project.id).find_by!(iid: params[:noteable_id]) end # rubocop: enable CodeReuse/ActiveRecord + def noteable_finder_class + case params[:noteable_type] + when 'issues' + IssuesFinder + when 'merge_requests' + MergeRequestsFinder + end + end + + def check_noteable_supports_resolvable_notes! + render_404 unless noteable_finder_class && noteable&.supports_resolvable_notes? + end + def discussion - @discussion ||= @merge_request.find_discussion(params[:id]) || render_404 + @discussion ||= @noteable.find_discussion(params[:id]) || render_404 end def authorize_resolve_discussion! diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4cc1ed092d2..127fe40b0e3 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -13,7 +13,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController end before_action only: [:index, :edit, :new] do - push_frontend_feature_flag(:kubernetes_namespace_for_environment) + push_frontend_feature_flag(:flux_resource_for_environment) end before_action :authorize_read_environment! @@ -110,10 +110,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController return render_404 unless @environment.available? stop_actions = @environment.stop_with_actions!(current_user) + job = stop_actions.first if stop_actions&.count == 1 action_or_env_url = - if stop_actions&.count == 1 - polymorphic_url([project, stop_actions.first]) + if job.instance_of?(::Ci::Build) + polymorphic_url([project, job]) + elsif job.instance_of?(::Ci::Bridge) + project_pipeline_url(project, job.pipeline_id) else project_environment_url(project, @environment) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 05be34d63e0..83947c443f4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:saved_replies, current_user) push_frontend_feature_flag(:issues_grid_view) push_frontend_feature_flag(:service_desk_ticket) + push_frontend_feature_flag(:issues_list_drawer, project) end before_action only: [:index, :show] do @@ -61,6 +62,7 @@ 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 @@ -71,6 +73,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) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 649bead0b6d..67cff16a76b 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -79,10 +79,13 @@ class Projects::LabelsController < Projects::ApplicationController end def destroy - @label.destroy - @labels = find_labels - - redirect_to project_labels_path(@project), status: :found, notice: 'Label was removed' + if @label.destroy + redirect_to project_labels_path(@project), status: :found, + notice: format(_('%{label_name} was removed'), label_name: @label.name) + else + redirect_to project_labels_path(@project), status: :found, + alert: @label.errors.full_messages.to_sentence + end end def remove_priority diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 2172c91fc76..30168558eff 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -50,6 +50,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo 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) end before_action only: [:edit] do @@ -165,7 +166,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo pipelines: PipelineSerializer .new(project: @project, current_user: @current_user) .with_pagination(request, response) - .represent(@pipelines), + .represent(@pipelines, preload: true), count: { all: @pipelines.count } diff --git a/app/controllers/projects/metrics/dashboards/builder_controller.rb b/app/controllers/projects/metrics/dashboards/builder_controller.rb deleted file mode 100644 index 02e3afcdc80..00000000000 --- a/app/controllers/projects/metrics/dashboards/builder_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module Projects - module Metrics - module Dashboards - class BuilderController < Projects::ApplicationController - before_action :authorize_metrics_dashboard! - - feature_category :metrics - urgency :low - - def panel_preview - return not_found if Feature.enabled?(:remove_monitor_metrics) - - respond_to do |format| - format.json do - if rendered_panel.success? - render json: rendered_panel.payload - else - render json: { message: rendered_panel.message }, status: :unprocessable_entity - end - end - end - end - - private - - def rendered_panel - @panel_preview ||= ::Metrics::Dashboard::PanelPreviewService.new(project, panel_yaml, environment).execute - end - - def panel_yaml - params.require(:panel_yaml) - end - - def environment - @environment ||= - if params[:environment] - project.environments.find(params[:environment]) - else - project.default_environment - end - end - end - end - end -end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 6cfbb61fbb2..02579cd4283 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -65,15 +65,7 @@ class Projects::PagesController < Projects::ApplicationController end def project_params_attributes - attributes = %i[pages_https_only] - - return attributes unless Feature.enabled?(:pages_unique_domain, @project) - - attributes + [ - project_setting_attributes: [ - :pages_unique_domain_enabled - ] - ] + [:pages_https_only, { project_setting_attributes: [:pages_unique_domain_enabled] }] end end diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb deleted file mode 100644 index 1255ec1dde2..00000000000 --- a/app/controllers/projects/performance_monitoring/dashboards_controller.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -module Projects - module PerformanceMonitoring - class DashboardsController < ::Projects::ApplicationController - include BlobHelper - - before_action :check_repository_available! - before_action :validate_required_params! - - rescue_from ActionController::ParameterMissing do |exception| - respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param }) - end - - feature_category :metrics - urgency :low - - def create - return not_found if Feature.enabled?(:remove_monitor_metrics) - - result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute - - if result[:status] == :success - respond_success(result) - else - respond_error(result) - end - end - - def update - return not_found if Feature.enabled?(:remove_monitor_metrics) - - result = ::Metrics::Dashboard::UpdateDashboardService.new(project, current_user, dashboard_params.merge(file_content_params)).execute - - if result[:status] == :success - respond_update_success(result) - else - respond_error(result) - end - end - - private - - def respond_success(result) - set_web_ide_link_notice(result.dig(:dashboard, :path)) - respond_to do |format| - format.json { render status: result.delete(:http_status), json: result } - end - end - - def respond_error(result) - respond_to do |format| - format.json { render json: { error: result[:message] }, status: result[:http_status] } - end - end - - def set_web_ide_link_notice(new_dashboard_path) - web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">" - message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" } - flash[:notice] = message.html_safe - end - - def respond_update_success(result) - set_web_ide_link_update_notice(result.dig(:dashboard, :path)) - respond_to do |format| - format.json { render status: result.delete(:http_status), json: result } - end - end - - def set_web_ide_link_update_notice(new_dashboard_path) - web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">" - message = _("Your dashboard has been updated. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" } - flash[:notice] = message.html_safe - end - - def validate_required_params! - params.require(%i[branch file_name dashboard commit_message]) - end - - def redirect_safe_branch_name - repository.find_branch(params[:branch]).name - end - - def dashboard_params - params.permit(%i[branch file_name dashboard commit_message]).to_h - end - - def file_content_params - params.permit( - file_content: [ - :dashboard, - panel_groups: [ - :group, - :priority, - panels: [ - :type, - :title, - :y_label, - :weight, - metrics: [ - :id, - :unit, - :label, - :query, - :query_range - ] - ] - ] - ] - ) - end - end - end -end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 96c9aa89953..42b6d83ee85 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -24,25 +24,13 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def create - if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, @project) - response = Ci::PipelineSchedules::CreateService.new(@project, current_user, schedule_params).execute - @schedule = response.payload - - if response.success? - redirect_to pipeline_schedules_path(@project) - else - render :new - end + response = Ci::PipelineSchedules::CreateService.new(@project, current_user, schedule_params).execute + @schedule = response.payload + + if response.success? + redirect_to pipeline_schedules_path(@project) else - @schedule = Ci::CreatePipelineScheduleService - .new(@project, current_user, schedule_params) - .execute - - if @schedule.persisted? - redirect_to pipeline_schedules_path(@project) - else - render :new - end + render :new end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 0e892ef3faa..0845fbc9713 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -14,6 +14,7 @@ module Projects before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) + push_frontend_feature_flag(:ci_variable_drawer, current_user) end helper_method :highlight_badge @@ -88,7 +89,7 @@ module Projects :build_timeout_human_readable, :public_builds, :ci_separated_caches, :auto_cancel_pending_pipelines, :ci_config_path, :auto_rollback_enabled, auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy], - ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled] + ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled, :forward_deployment_rollback_allowed] ].tap do |list| list << :max_artifacts_size if can?(current_user, :update_max_artifacts_size, project) end diff --git a/app/controllers/projects/tracing_controller.rb b/app/controllers/projects/tracing_controller.rb index d1218ebf344..45e773bf62b 100644 --- a/app/controllers/projects/tracing_controller.rb +++ b/app/controllers/projects/tracing_controller.rb @@ -10,6 +10,10 @@ module Projects def index; end + def show + @trace_id = params[:id] + end + private def check_tracing_enabled diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index b961339111b..0371fb21ac8 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController before_action :authorize_edit_tree!, only: [:create_dir] before_action do - push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 51f6158d9c0..2ad0f11dc91 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -37,11 +37,9 @@ class ProjectsController < Projects::ApplicationController before_action :check_export_rate_limit!, only: [:export, :download_export, :generate_new_export] before_action do - push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:remove_monitor_metrics, @project) push_frontend_feature_flag(:explain_code_chat, current_user) - push_frontend_feature_flag(:ci_namespace_catalog_experimental, @project) push_frontend_feature_flag(:service_desk_custom_email, @project) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) @@ -471,6 +469,7 @@ class ProjectsController < Projects::ApplicationController mr_default_target_self warn_about_potentially_unwanted_characters enforce_auth_checks_on_uploads + emails_enabled ] end @@ -483,7 +482,6 @@ class ProjectsController < Projects::ApplicationController :resolve_outdated_diff_discussions, :container_registry_enabled, :description, - :emails_disabled, :external_authorization_classification_label, :import_url, :issues_tracker, diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 76f181e3ce8..68f8248d114 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -4,6 +4,7 @@ module Registrations class WelcomeController < ApplicationController include OneTrustCSP include GoogleAnalyticsCSP + include GoogleSyndicationCSP include ::Gitlab::Utils::StrongMemoize layout 'minimal' @@ -53,11 +54,6 @@ module Registrations stored_location_for(user) || last_member_activity_path end - # overridden in EE - def complete_signup_onboarding? - onboarding_status.continue_full_onboarding? - end - def last_member_activity_path return dashboard_projects_path unless onboarding_status.last_invited_member_source.present? @@ -67,7 +63,7 @@ module Registrations def update_success_path if onboarding_status.invite_with_tasks_to_be_done? issues_dashboard_path(assignee_username: current_user.username) - elsif complete_signup_onboarding? # trials/regular registration on .com + elsif 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) @@ -94,7 +90,7 @@ module Registrations end def onboarding_status - Onboarding::Status.new(current_user) + Onboarding::Status.new(params.to_unsafe_h.deep_symbolize_keys, session, current_user) end strong_memoize_attr :onboarding_status end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 76b7d30cd51..d8064bbbe82 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -8,6 +8,7 @@ class RegistrationsController < Devise::RegistrationsController include OneTrustCSP include BizibleCSP include GoogleAnalyticsCSP + include GoogleSyndicationCSP include PreferredLanguageSwitcher include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent include SkipsAlreadySignedInMessage diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index 32119ddf89e..da243a0301e 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -64,11 +64,13 @@ module Repositories .for_oids(objects_oids) .index_by(&:oid) + guest_can_download = Guest.can?(:download_code, project) + objects.each do |object| if lfs_object = existing_oids[object[:oid]] object[:actions] = download_actions(object, lfs_object) - if Guest.can?(:download_code, project) + if guest_can_download object[:authenticated] = true end else diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb index 22f1a81b95b..80f7153cd7a 100644 --- a/app/controllers/repositories/lfs_storage_controller.rb +++ b/app/controllers/repositories/lfs_storage_controller.rb @@ -18,10 +18,7 @@ module Repositories def download lfs_object = LfsObject.find_by_oid(oid) - unless lfs_object && lfs_object.file.exists? - render_lfs_not_found - return - end + return render_lfs_not_found unless lfs_object&.file&.exists? send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" }) end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 45aefe48538..6c1d9a20570 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -35,6 +35,10 @@ 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' @@ -107,7 +111,7 @@ class SearchController < ApplicationController end def autocomplete - term = params[:term] + term = params.require(:term) @project = search_service.project @ref = params[:project_ref] if params[:project_ref].present? @@ -248,7 +252,7 @@ class SearchController < ApplicationController end def search_type - 'basic' + search_service.search_type end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a9972cbd885..66ace16400a 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -13,6 +13,7 @@ class SessionsController < Devise::SessionsController include BizibleCSP include VerifiesWithEmail include GoogleAnalyticsCSP + include GoogleSyndicationCSP include PreferredLanguageSwitcher include SkipsAlreadySignedInMessage diff --git a/app/events/package_metadata/ingested_advisory_event.rb b/app/events/package_metadata/ingested_advisory_event.rb new file mode 100644 index 00000000000..1aa0d6b0833 --- /dev/null +++ b/app/events/package_metadata/ingested_advisory_event.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PackageMetadata + class IngestedAdvisoryEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'advisory_id' => { 'type' => 'integer' } + }, + 'required' => %w[advisory_id] + } + end + end +end diff --git a/app/events/project_authorizations/authorizations_changed_event.rb b/app/events/project_authorizations/authorizations_changed_event.rb new file mode 100644 index 00000000000..24afae9d6fd --- /dev/null +++ b/app/events/project_authorizations/authorizations_changed_event.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ProjectAuthorizations + class AuthorizationsChangedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[project_id], + 'properties' => { + 'project_id' => { 'type' => 'integer' } + } + } + end + end +end diff --git a/app/events/repositories/default_branch_changed_event.rb b/app/events/repositories/default_branch_changed_event.rb new file mode 100644 index 00000000000..3519fb4be86 --- /dev/null +++ b/app/events/repositories/default_branch_changed_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Repositories + class DefaultBranchChangedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'container_id' => { 'type' => 'integer' }, + 'container_type' => { 'type' => 'string' } + }, + 'required' => %w[container_id container_type] + } + end + end +end diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb index 6a6d0413194..43cebd16d92 100644 --- a/app/finders/abuse_reports_finder.rb +++ b/app/finders/abuse_reports_finder.rb @@ -3,9 +3,13 @@ class AbuseReportsFinder attr_reader :params, :reports - DEFAULT_STATUS_FILTER = 'open' - DEFAULT_SORT = 'created_at_desc' - ALLOWED_SORT = [DEFAULT_SORT, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze + STATUS_OPEN = 'open' + + DEFAULT_SORT_STATUS_CLOSED = 'created_at_desc' + ALLOWED_SORT = [DEFAULT_SORT_STATUS_CLOSED, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze + + DEFAULT_SORT_STATUS_OPEN = 'number_of_reports_desc' + SORT_BY_COUNT = [DEFAULT_SORT_STATUS_OPEN].freeze def initialize(params = {}) @params = params @@ -14,6 +18,7 @@ class AbuseReportsFinder def execute filter_reports + aggregate_reports sort_reports reports.with_users.page(params[:page]) @@ -22,20 +27,28 @@ class AbuseReportsFinder private def filter_reports - filter_by_user_id + if Feature.disabled?(:abuse_reports_list) + filter_by_user_id + return + end + filter_by_status filter_by_user filter_by_reporter - filter_by_status 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 Feature.enabled?(:abuse_reports_list) return unless params[:status].present? status = params[:status] - status = DEFAULT_STATUS_FILTER unless status.in?(AbuseReport.statuses.keys) + status = STATUS_OPEN unless status.in?(AbuseReport.statuses.keys) case status when 'open' @@ -69,10 +82,13 @@ class AbuseReportsFinder @reports = @reports.by_reporter_id(user_id) end - def filter_by_user_id - return unless params[:user_id].present? + def sort_key + sort_key = params[:sort] - @reports = @reports.by_user_id(params[:user_id]) + return sort_key if sort_key.in?(ALLOWED_SORT + SORT_BY_COUNT) + return DEFAULT_SORT_STATUS_OPEN if status_open? + + DEFAULT_SORT_STATUS_CLOSED end def sort_reports @@ -81,13 +97,31 @@ class AbuseReportsFinder return end - sort_by = params[:sort] - sort_by = DEFAULT_SORT unless sort_by.in?(ALLOWED_SORT) + # let sub_query in aggregate_reports do the sorting if sorting by number of reports + return if sort_key.in?(SORT_BY_COUNT) - @reports = @reports.order_by(sort_by) + @reports = @reports.order_by(sort_key) end def find_user_id(username) 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) + @reports = @reports.aggregated_by_user_and_category(sort_by_count) + end + + @reports + end end diff --git a/app/finders/admin/abuse_report_labels_finder.rb b/app/finders/admin/abuse_report_labels_finder.rb new file mode 100644 index 00000000000..f8ca40f77b2 --- /dev/null +++ b/app/finders/admin/abuse_report_labels_finder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportLabelsFinder + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + return Admin::AbuseReportLabel.none unless current_user&.can_admin_all_resources? + + items = Admin::AbuseReportLabel.all + items = by_search(items) + + items.order(title: :asc) # rubocop: disable CodeReuse/ActiveRecord + end + + private + + attr_reader :current_user, :params + + def by_search(labels) + return labels unless search_term + + labels.search(search_term) + end + + def search_term + params[:search_term] + end + end +end diff --git a/app/finders/autocomplete/group_users_finder.rb b/app/finders/autocomplete/group_users_finder.rb new file mode 100644 index 00000000000..b24f3f7f032 --- /dev/null +++ b/app/finders/autocomplete/group_users_finder.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# This finder returns all users that are related to a given group because: +# 1. They are members of the group, its sub-groups, or its ancestor groups +# 2. They are members of a group that is invited to the group, its sub-groups, or its ancestors +# 3. They are members of a project that belongs to the group +# 4. They are members of a group that is invited to the group's descendant projects +# +# These users are not necessarily members of the given group and may not have access to the group +# so this should not be used for access control +module Autocomplete + class GroupUsersFinder + include Gitlab::Utils::StrongMemoize + + def initialize(group:) + @group = group + end + + def execute + members = Member + .with(group_hierarchy_cte.to_arel) # rubocop:disable CodeReuse/ActiveRecord + .with(descendant_projects_cte.to_arel) # rubocop:disable CodeReuse/ActiveRecord + .from_union(member_relations, remove_duplicates: false) + + User + .id_in(members.select(:user_id)) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420387") + end + + private + + def member_relations + [ + members_from_group_hierarchy.select(:user_id), + members_from_hierarchy_group_shares.select(:user_id), + members_from_descendant_projects.select(:user_id), + members_from_descendant_project_shares.select(:user_id) + ] + end + + def members_from_group_hierarchy + GroupMember + .with_source_id(group_hierarchy_ids) + .without_invites_and_requests + end + + def members_from_hierarchy_group_shares + invited_groups = GroupGroupLink.for_shared_groups(group_hierarchy_ids).select(:shared_with_group_id) + + GroupMember + .with_source_id(invited_groups) + .without_invites_and_requests + end + + def members_from_descendant_projects + ProjectMember + .with_source_id(descendant_project_ids) + .without_invites_and_requests + end + + def members_from_descendant_project_shares + descendant_project_invited_groups = ProjectGroupLink.for_projects(descendant_project_ids).select(:group_id) + + GroupMember + .with_source_id(descendant_project_invited_groups) + .without_invites_and_requests + end + + def group_hierarchy_cte + Gitlab::SQL::CTE.new(:group_hierarchy, @group.self_and_hierarchy.select(:id)) + end + strong_memoize_attr :group_hierarchy_cte + + def group_hierarchy_ids + Namespace.from(group_hierarchy_cte.table).select(:id) # rubocop:disable CodeReuse/ActiveRecord + end + + def descendant_projects_cte + Gitlab::SQL::CTE.new(:descendant_projects, @group.all_projects.select(:id)) + end + strong_memoize_attr :descendant_projects_cte + + def descendant_project_ids + Project.from(descendant_projects_cte.table).select(:id) # rubocop:disable CodeReuse/ActiveRecord + end + end +end diff --git a/app/finders/autocomplete/routes_finder.rb b/app/finders/autocomplete/routes_finder.rb index ecede0c1c1c..ed807d3a295 100644 --- a/app/finders/autocomplete/routes_finder.rb +++ b/app/finders/autocomplete/routes_finder.rb @@ -19,6 +19,7 @@ module Autocomplete .for_routable(routables) .sort_by_path_length .fuzzy_search(@search, [:path]) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord end @@ -30,9 +31,11 @@ module Autocomplete class NamespacesOnly < self def routables - return Namespace.without_project_namespaces if current_user.can_admin_all_resources? - - current_user.namespaces + if current_user.can_admin_all_resources? + Namespace.without_project_namespaces + else + current_user.namespaces + end.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") end end diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index 7ecf5c98ac0..e7a24cde2bd 100644 --- a/app/finders/autocomplete/users_finder.rb +++ b/app/finders/autocomplete/users_finder.rb @@ -10,21 +10,21 @@ module Autocomplete # ensure good performance. LIMIT = 20 - attr_reader :current_user, :project, :group, :search, :skip_users, + attr_reader :current_user, :project, :group, :search, :author_id, :todo_filter, :todo_state_filter, - :filter_by_current_user, :states + :filter_by_current_user, :states, :push_code def initialize(params:, current_user:, project:, group:) @current_user = current_user @project = project @group = group @search = params[:search] - @skip_users = params[:skip_users] @author_id = params[:author_id] @todo_filter = params[:todo_filter] @todo_state_filter = params[:todo_state_filter] @filter_by_current_user = params[:current_user] @states = params[:states] || ['active'] + @push_code = params[:push_code] end def execute @@ -39,6 +39,8 @@ module Autocomplete end end + items = filter_users_by_push_ability(items) + items.uniq.tap do |unique_items| preload_associations(unique_items) end @@ -65,7 +67,6 @@ module Autocomplete .non_internal .reorder_by_name .optionally_search(search, use_minimum_char_limit: use_minimum_char_limit) - .where_not_in(skip_users) .limit_to_todo_authors( user: current_user, with_todos: todo_filter, @@ -88,7 +89,7 @@ module Autocomplete if project project.authorized_users.union_with_user(author_id) elsif group - group.users_with_parents + ::Autocomplete::GroupUsersFinder.new(group: group).execute # rubocop: disable CodeReuse/Finder elsif current_user User.all else @@ -96,6 +97,12 @@ module Autocomplete end end + def filter_users_by_push_ability(items) + return items unless project && push_code.present? + + items.select { |user| user.can?(:push_code, project) } + end + # rubocop: disable CodeReuse/ActiveRecord def preload_associations(items) ActiveRecord::Associations::Preloader.new(records: items, associations: :status).call @@ -109,5 +116,3 @@ module Autocomplete end end end - -Autocomplete::UsersFinder.prepend_mod_with('Autocomplete::UsersFinder') diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index 800158dfd0a..9881cb3fc74 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -25,7 +25,7 @@ class DeploymentsFinder # performant with the other filtering/sorting parameters. # The composed query could be significantly slower when the filtering and sorting columns are different. # See https://gitlab.com/gitlab-org/gitlab/-/issues/325627 for example. - ALLOWED_SORT_VALUES = %w[id iid created_at updated_at ref finished_at].freeze + ALLOWED_SORT_VALUES = %w[id iid created_at updated_at finished_at].freeze DEFAULT_SORT_VALUE = 'id' ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze @@ -128,7 +128,6 @@ class DeploymentsFinder def build_sort_params order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE - order_by = DEFAULT_SORT_VALUE if order_by == 'ref' && Feature.enabled?(:remove_deployments_api_ref_sort) order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION { order_by => order_direction } diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 72ab30cf567..3e31c7a2bb2 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -141,7 +141,7 @@ class GroupDescendantsFinder # rubocop: disable CodeReuse/Finder def direct_child_projects - GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { only_owned: true }) + GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { exclude_shared: true }) .execute end # rubocop: enable CodeReuse/Finder diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 1025e0ebc9b..639db58b00d 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -86,11 +86,6 @@ class GroupMembersFinder < UnionFinder end def members_of_groups(groups, shared_from_groups) - if Feature.disabled?(:members_with_shared_group_access, @group.root_ancestor) - groups << shared_from_groups unless shared_from_groups.nil? - return GroupMember.non_request.of_groups(find_union(groups, Group)) - end - members = GroupMember.non_request.of_groups(find_union(groups, Group)) return members if shared_from_groups.nil? diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index db8a0f14fbc..21341797910 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -9,8 +9,10 @@ # project_ids_relation: int[] - project ids to use # group # options: -# only_owned: boolean +# exclude_shared: boolean +# When true, only projects within the group are included in the result. # only_shared: boolean +# When true, only projects arising from group-project shares are included in the result. # limit: integer # include_subgroups: boolean # include_ancestor_groups: boolean @@ -63,10 +65,10 @@ class GroupProjectsFinder < ProjectsFinder projects = if only_shared? [shared_projects] - elsif only_owned? - [owned_projects] + elsif exclude_shared? + [projects_within_group] else - [owned_projects, shared_projects] + [projects_within_group, shared_projects] end projects.map! do |project_relation| @@ -104,8 +106,8 @@ class GroupProjectsFinder < ProjectsFinder end end - def only_owned? - options.fetch(:only_owned, false) + def exclude_shared? + options.fetch(:exclude_shared, false) end def owned_projects? @@ -126,7 +128,7 @@ class GroupProjectsFinder < ProjectsFinder options.fetch(:include_ancestor_groups, false) end - def owned_projects + def projects_within_group return group.projects unless include_subgroups? || include_ancestor_groups? union_relations = [] diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 63f7616884f..074eb9add0f 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -32,14 +32,8 @@ class GroupsFinder < UnionFinder end def execute - items = all_groups.map do |item| - item = by_parent(item) - item = by_custom_attributes(item) - item = filter_group_ids(item) - item = exclude_group_ids(item) - item = by_search(item) - - item + items = all_groups.map do |groups| + filter_groups(groups) end find_union(items, Group).with_route.order_id_desc @@ -49,6 +43,14 @@ class GroupsFinder < UnionFinder attr_reader :current_user, :params + def filter_groups(groups) + groups = by_parent(groups) + groups = by_custom_attributes(groups) + groups = filter_group_ids(groups) + groups = exclude_group_ids(groups) + by_search(groups) + end + def all_groups return [owned_groups] if params[:owned] return [groups_with_min_access_level] if min_access_level? @@ -147,3 +149,5 @@ class GroupsFinder < UnionFinder groups end end + +GroupsFinder.prepend_mod_with('GroupsFinder') diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index e59c2224594..bc136848dd5 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -133,7 +133,7 @@ class IssuableFinder def projects strong_memoize(:projects) do - next [project] if project? + next Array.wrap(project) if project? projects = if current_user && params[:authorized_only].presence && !current_user_related? diff --git a/app/finders/issuables/assignee_filter.rb b/app/finders/issuables/assignee_filter.rb index c97fdffd32e..5efcd5aa23e 100644 --- a/app/finders/issuables/assignee_filter.rb +++ b/app/finders/issuables/assignee_filter.rb @@ -5,8 +5,6 @@ module Issuables def filter(issuables) filtered = by_assignee(issuables) filtered = by_assignee_union(filtered) - # Cross Joins Fails tests in bin/rspec spec/requests/api/graphql/boards/board_list_issues_query_spec.rb - filtered = filtered.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462") by_negated_assignee(filtered) end @@ -74,7 +72,7 @@ module Issuables elsif specific_params[:assignee_id].present? Array(specific_params[:assignee_id]) elsif specific_params[:assignee_username].present? - User.by_username(specific_params[:assignee_username]).select(:id) + User.by_username(specific_params[:assignee_username]).pluck_primary_key end end end diff --git a/app/finders/issuables/author_filter.rb b/app/finders/issuables/author_filter.rb index f36daae553d..7707bf51f18 100644 --- a/app/finders/issuables/author_filter.rb +++ b/app/finders/issuables/author_filter.rb @@ -15,6 +15,7 @@ module Issuables issuables.authored(params[:author_id]) elsif params[:author_username].present? issuables.authored(User.by_username(params[:author_username])) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/419221") else issuables end @@ -24,6 +25,7 @@ module Issuables return issuables unless or_filters_enabled? && or_params&.fetch(:author_username, false).present? issuables.authored(User.by_username(or_params[:author_username])) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/419221") end def by_negated_author(issuables) @@ -33,6 +35,7 @@ module Issuables issuables.not_authored(not_params[:author_id]) elsif not_params[:author_username].present? issuables.not_authored(User.by_username(not_params[:author_username])) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/419221") else issuables end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index bd81f06f93b..0ba93a76342 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -72,6 +72,7 @@ class IssuesFinder < IssuableFinder OR EXISTS (:authorizations)))', user_id: current_user.id, authorizations: current_user.authorizations_for_projects(min_access_level: CONFIDENTIAL_ACCESS_LEVEL, related_project_column: "issues.project_id")) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index b1387f2a104..1bf2e3b71e4 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -24,6 +24,7 @@ class LabelsFinder < UnionFinder items = with_title(items) items = by_subscription(items) items = by_search(items) + items = by_locked_labels(items) items = items.with_preloaded_container if @preload_parent_association sort(items) @@ -94,6 +95,12 @@ class LabelsFinder < UnionFinder labels.optionally_subscribed_by(subscriber_id) end + def by_locked_labels(items) + return items unless params[:locked_labels] + + items.with_lock_on_merge + end + def subscriber_id current_user&.id if subscribed? end diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb index ea1aa6d2e9e..ee340b79ed0 100644 --- a/app/finders/merge_request_target_project_finder.rb +++ b/app/finders/merge_request_target_project_finder.rb @@ -14,7 +14,8 @@ class MergeRequestTargetProjectFinder def execute(search: nil, include_routes: false) if source_project.fork_network items = include_routes ? projects.inc_routes : projects - by_search(items, search) + by_search(items, search).allow_cross_joins_across_databases( + url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") else Project.id_in(source_project.id) end @@ -39,3 +40,5 @@ class MergeRequestTargetProjectFinder end # rubocop: enable CodeReuse/ActiveRecord end + +MergeRequestTargetProjectFinder.prepend_mod_with("MergeRequestTargetProjectFinder") diff --git a/app/finders/metrics/dashboards/annotations_finder.rb b/app/finders/metrics/dashboards/annotations_finder.rb deleted file mode 100644 index e97704738ea..00000000000 --- a/app/finders/metrics/dashboards/annotations_finder.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboards - class AnnotationsFinder - def initialize(dashboard:, params:) - @dashboard = dashboard - @params = params - end - - def execute - if dashboard.environment - apply_filters_to(annotations_for_environment) - else - Metrics::Dashboard::Annotation.none - end - end - - private - - attr_reader :dashboard, :params - - def apply_filters_to(annotations) - annotations = annotations.after(params[:from]) if params[:from].present? - annotations = annotations.before(params[:to]) if params[:to].present? && valid_timespan_boundaries? - - by_dashboard(annotations) - end - - def annotations_for_environment - dashboard.environment.metrics_dashboard_annotations - end - - def by_dashboard(annotations) - annotations.for_dashboard(dashboard.path) - end - - def valid_timespan_boundaries? - params[:from].blank? || params[:to] >= params[:from] - end - end - end -end diff --git a/app/finders/metrics/users_starred_dashboards_finder.rb b/app/finders/metrics/users_starred_dashboards_finder.rb deleted file mode 100644 index 2ef706c1b11..00000000000 --- a/app/finders/metrics/users_starred_dashboards_finder.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Metrics - class UsersStarredDashboardsFinder - def initialize(user:, project:, params: {}) - @user = user - @project = project - @params = params - end - - def execute - return ::Metrics::UsersStarredDashboard.none unless Ability.allowed?(user, :read_metrics_user_starred_dashboard, project) - - items = starred_dashboards - items = by_project(items) - by_dashboard(items) - end - - private - - attr_reader :user, :project, :params - - def by_project(items) - items.for_project(project) - end - - def by_dashboard(items) - return items unless params[:dashboard_path] - - items.merge(starred_dashboards.for_project_dashboard(project, params[:dashboard_path])) - end - - def starred_dashboards - @starred_dashboards ||= user.metrics_users_starred_dashboards - end - end -end diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb index 23345f29198..064698d3c37 100644 --- a/app/finders/packages/nuget/package_finder.rb +++ b/app/finders/packages/nuget/package_finder.rb @@ -4,19 +4,43 @@ module Packages module Nuget class PackageFinder < ::Packages::GroupOrProjectPackageFinder MAX_PACKAGES_COUNT = 300 + FORCE_NORMALIZATION_CLIENT_VERSION = '>= 3' def execute + return ::Packages::Package.none unless @params[:package_name].present? + packages.limit_recent(@params[:limit] || MAX_PACKAGES_COUNT) end private def packages - result = base.nuget - .has_version - .with_name_like(@params[:package_name]) - result = result.with_case_insensitive_version(@params[:package_version]) if @params[:package_version].present? + result = find_by_name + find_by_version(result) + end + + def find_by_name + base + .nuget + .has_version + .with_case_insensitive_name(@params[:package_name]) + end + + def find_by_version(result) + return result if @params[:package_version].blank? + result + .with_nuget_version_or_normalized_version( + @params[:package_version], + with_normalized: Feature.enabled?(:nuget_normalized_version, @project_or_group) && + client_forces_normalized_version? + ) + end + + def client_forces_normalized_version? + return true if @params[:client_version].blank? + + VersionSorter.compare(FORCE_NORMALIZATION_CLIENT_VERSION, @params[:client_version]) <= 0 end end end diff --git a/app/finders/packages/pipelines_finder.rb b/app/finders/packages/pipelines_finder.rb new file mode 100644 index 00000000000..c814efbf176 --- /dev/null +++ b/app/finders/packages/pipelines_finder.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Packages + class PipelinesFinder + COLUMNS = %i[id iid project_id sha ref status source created_at updated_at user_id].freeze + + def initialize(pipeline_ids) + @pipeline_ids = pipeline_ids + end + + def execute + ::Ci::Pipeline + .id_in(pipeline_ids) + .select(COLUMNS) + .order_id_desc + end + + private + + attr_reader :pipeline_ids + end +end diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb index 9ef5dacb551..99c66f53de7 100644 --- a/app/finders/projects/ml/model_finder.rb +++ b/app/finders/projects/ml/model_finder.rb @@ -8,12 +8,9 @@ module Projects end def execute - @project - .packages - .installable - .ml_model - .order_name_desc_version_desc - .select_only_first_by_name + ::Ml::Model + .by_project(@project) + .including_latest_version .limit(100) # This is a temporary limit before we add pagination end end diff --git a/app/finders/repositories/tree_finder.rb b/app/finders/repositories/tree_finder.rb index 231c1de1513..2a8971d4d86 100644 --- a/app/finders/repositories/tree_finder.rb +++ b/app/finders/repositories/tree_finder.rb @@ -13,7 +13,7 @@ module Repositories def execute(gitaly_pagination: false) raise CommitMissingError unless commit_exists? - request_params = { recursive: recursive } + request_params = { recursive: recursive, rescue_not_found: rescue_not_found } request_params[:pagination_params] = pagination_params if gitaly_pagination repository.tree(commit.id, path, **request_params).sorted_entries @@ -51,6 +51,10 @@ module Repositories params[:recursive] end + def rescue_not_found + params[:rescue_not_found] + end + def pagination_params { limit: params[:per_page] || Kaminari.config.default_per_page, diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 9dd7e508c22..cb824aca33f 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -67,17 +67,30 @@ class SnippetsFinder < UnionFinder return Snippet.none if project.nil? && params[:project].present? return Snippet.none if project && !project.feature_available?(:snippets, current_user) - items = init_collection - items = by_ids(items) - items = items.with_optional_visibility(visibility_from_scope) - items = by_created_at(items) - - items.order_by(sort_param) + filter_snippets.order_by(sort_param) end private - def init_collection + def filter_snippets + if return_all_available_and_permited? + snippets = all_snippets_for_admin + else + snippets = all_snippets + snippets = by_ids(snippets) + snippets = snippets.with_optional_visibility(visibility_from_scope) + end + + by_created_at(snippets) + end + + def return_all_available_and_permited? + # Currently limited to access_levels `admin` and `auditor` + # See policies/base_policy.rb files for specifics. + params[:all_available] && current_user&.can_read_all_resources? + end + + def all_snippets if explore? snippets_for_explore elsif only_personal? @@ -121,6 +134,12 @@ class SnippetsFinder < UnionFinder prepared_union(queries) end + def all_snippets_for_admin + return Snippet.only_project_snippets if only_project? + + Snippet.all + end + def snippets_for_a_single_project Snippet.for_project_with_user(project, current_user) end @@ -182,10 +201,10 @@ class SnippetsFinder < UnionFinder end end - def by_ids(items) - return items unless params[:ids].present? + def by_ids(snippets) + return snippets unless params[:ids].present? - items.id_in(params[:ids]) + snippets.id_in(params[:ids]) end def author diff --git a/app/finders/work_items/namespace_work_items_finder.rb b/app/finders/work_items/namespace_work_items_finder.rb new file mode 100644 index 00000000000..aad99d710b6 --- /dev/null +++ b/app/finders/work_items/namespace_work_items_finder.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module WorkItems + class NamespaceWorkItemsFinder < WorkItemsFinder + 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? + + # Only admins can see hidden issues, so for non-admins, we filter out any hidden issues + issues = model_class.without_hidden + + return issues.all if params.user_can_see_all_confidential_issues? + + return issues.public_only if params.user_cannot_see_confidential_issues? + + issues.with_confidentiality_check(current_user) + end + + private + + def by_namespace(items) + if namespace.blank? || !Ability.allowed?(current_user, "read_#{namespace.to_ability_name}".to_sym, namespace) + return klass.none + end + + items.in_namespaces(namespace) + 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 + end +end diff --git a/app/graphql/mutations/alert_management/alerts/set_assignees.rb b/app/graphql/mutations/alert_management/alerts/set_assignees.rb index 500e2b868b1..a8513417c1c 100644 --- a/app/graphql/mutations/alert_management/alerts/set_assignees.rb +++ b/app/graphql/mutations/alert_management/alerts/set_assignees.rb @@ -7,14 +7,14 @@ module Mutations graphql_name 'AlertSetAssignees' argument :assignee_usernames, - [GraphQL::Types::String], - required: true, - description: 'Usernames to assign to the alert. Replaces existing assignees by default.' + [GraphQL::Types::String], + required: true, + description: 'Usernames to assign to the alert. Replaces existing assignees by default.' argument :operation_mode, - Types::MutationOperationModeEnum, - required: false, - description: 'Operation to perform. Defaults to REPLACE.' + Types::MutationOperationModeEnum, + required: false, + description: 'Operation to perform. Defaults to REPLACE.' def resolve(args) alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb index 771ace5510f..615c0f43a15 100644 --- a/app/graphql/mutations/alert_management/base.rb +++ b/app/graphql/mutations/alert_management/base.rb @@ -6,27 +6,27 @@ module Mutations include Gitlab::Utils::UsageData argument :project_path, GraphQL::Types::ID, - required: true, - description: "Project the alert to mutate is in." + required: true, + description: "Project the alert to mutate is in." argument :iid, GraphQL::Types::String, - required: true, - description: "IID of the alert to mutate." + required: true, + description: "IID of the alert to mutate." field :alert, - Types::AlertManagement::AlertType, - null: true, - description: "Alert after mutation." + Types::AlertManagement::AlertType, + null: true, + description: "Alert after mutation." field :todo, - Types::TodoType, - null: true, - description: "To-do item after mutation." + Types::TodoType, + null: true, + description: "To-do item after mutation." field :issue, - Types::IssueType, - null: true, - description: "Issue created after mutation." + Types::IssueType, + null: true, + description: "Issue created after mutation." authorize :update_alert_management_alert diff --git a/app/graphql/mutations/alert_management/http_integration/create.rb b/app/graphql/mutations/alert_management/http_integration/create.rb index f8d1a383706..fccef8cd3ad 100644 --- a/app/graphql/mutations/alert_management/http_integration/create.rb +++ b/app/graphql/mutations/alert_management/http_integration/create.rb @@ -9,16 +9,16 @@ module Mutations include FindsProject argument :project_path, GraphQL::Types::ID, - required: true, - description: 'Project to create the integration in.' + required: true, + description: 'Project to create the integration in.' argument :name, GraphQL::Types::String, - required: true, - description: 'Name of the integration.' + required: true, + description: 'Name of the integration.' argument :active, GraphQL::Types::Boolean, - required: true, - description: 'Whether the integration is receiving alerts.' + required: true, + description: 'Whether the integration is receiving alerts.' def resolve(args) project = authorized_find!(args[:project_path]) diff --git a/app/graphql/mutations/alert_management/http_integration/destroy.rb b/app/graphql/mutations/alert_management/http_integration/destroy.rb index dc5c73ecff6..9da50a4c4ce 100644 --- a/app/graphql/mutations/alert_management/http_integration/destroy.rb +++ b/app/graphql/mutations/alert_management/http_integration/destroy.rb @@ -7,8 +7,8 @@ module Mutations graphql_name 'HttpIntegrationDestroy' argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration], - required: true, - description: "ID of the integration to remove." + required: true, + description: "ID of the integration to remove." def resolve(id:) integration = authorized_find!(id: id) diff --git a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb index 2f25d315d2e..9434ac1637e 100644 --- a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb +++ b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb @@ -5,9 +5,9 @@ module Mutations module HttpIntegration class HttpIntegrationBase < BaseMutation field :integration, - Types::AlertManagement::HttpIntegrationType, - null: true, - description: "HTTP integration." + Types::AlertManagement::HttpIntegrationType, + null: true, + description: "HTTP integration." authorize :admin_operations diff --git a/app/graphql/mutations/alert_management/http_integration/reset_token.rb b/app/graphql/mutations/alert_management/http_integration/reset_token.rb index 83ad7762408..bed3cf08674 100644 --- a/app/graphql/mutations/alert_management/http_integration/reset_token.rb +++ b/app/graphql/mutations/alert_management/http_integration/reset_token.rb @@ -7,8 +7,8 @@ module Mutations graphql_name 'HttpIntegrationResetToken' argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration], - required: true, - description: "ID of the integration to mutate." + required: true, + description: "ID of the integration to mutate." def resolve(id:) integration = authorized_find!(id: id) diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb index 78424e317b8..06d0b7163b0 100644 --- a/app/graphql/mutations/alert_management/http_integration/update.rb +++ b/app/graphql/mutations/alert_management/http_integration/update.rb @@ -7,16 +7,16 @@ module Mutations graphql_name 'HttpIntegrationUpdate' argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration], - required: true, - description: "ID of the integration to mutate." + required: true, + description: "ID of the integration to mutate." argument :name, GraphQL::Types::String, - required: false, - description: "Name of the integration." + required: false, + description: "Name of the integration." argument :active, GraphQL::Types::Boolean, - required: false, - description: "Whether the integration is receiving alerts." + required: false, + description: "Whether the integration is receiving alerts." def resolve(args) integration = authorized_find!(id: args[:id]) diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb index b06a4f58df5..665ce96f0f9 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb @@ -9,16 +9,16 @@ module Mutations include FindsProject argument :project_path, GraphQL::Types::ID, - required: true, - description: 'Project to create the integration in.' + required: true, + description: 'Project to create the integration in.' argument :active, GraphQL::Types::Boolean, - required: true, - description: 'Whether the integration is receiving alerts.' + required: true, + description: 'Whether the integration is receiving alerts.' argument :api_url, GraphQL::Types::String, - required: false, - description: 'Endpoint at which Prometheus can be queried.' + required: false, + description: 'Endpoint at which Prometheus can be queried.' def resolve(args) project = authorized_find!(args[:project_path]) diff --git a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb index 29834d63f35..28729ec70cd 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb @@ -5,9 +5,9 @@ module Mutations module PrometheusIntegration class PrometheusIntegrationBase < BaseMutation field :integration, - Types::AlertManagement::PrometheusIntegrationType, - null: true, - description: "Newly created integration." + Types::AlertManagement::PrometheusIntegrationType, + null: true, + description: "Newly created integration." authorize :admin_project diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb index 71c02efdc03..15e6763b1ee 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb @@ -7,8 +7,8 @@ module Mutations graphql_name 'PrometheusIntegrationResetToken' argument :id, Types::GlobalIDType[::Integrations::Prometheus], - required: true, - description: "ID of the integration to mutate." + required: true, + description: "ID of the integration to mutate." def resolve(id:) integration = authorized_find!(id: id) diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb index 50aafdc26a6..593624aaafd 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb @@ -7,16 +7,16 @@ module Mutations graphql_name 'PrometheusIntegrationUpdate' argument :id, Types::GlobalIDType[::Integrations::Prometheus], - required: true, - description: "ID of the integration to mutate." + required: true, + description: "ID of the integration to mutate." argument :active, GraphQL::Types::Boolean, - required: false, - description: "Whether the integration is receiving alerts." + required: false, + description: "Whether the integration is receiving alerts." argument :api_url, GraphQL::Types::String, - required: false, - description: "Endpoint at which Prometheus can be queried." + required: false, + description: "Endpoint at which Prometheus can be queried." def resolve(args) integration = authorized_find!(id: args[:id]) diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb index be271a7d795..a0d06ebf221 100644 --- a/app/graphql/mutations/alert_management/update_alert_status.rb +++ b/app/graphql/mutations/alert_management/update_alert_status.rb @@ -6,8 +6,8 @@ module Mutations graphql_name 'UpdateAlertStatus' argument :status, Types::AlertManagement::StatusEnum, - required: true, - description: 'Status to set the alert.' + required: true, + description: 'Status to set the alert.' def resolve(project_path:, iid:, status:) alert = authorized_find!(project_path: project_path, iid: iid) diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb index 65065de0de4..0223c978cf9 100644 --- a/app/graphql/mutations/award_emojis/base.rb +++ b/app/graphql/mutations/award_emojis/base.rb @@ -3,7 +3,7 @@ module Mutations module AwardEmojis class Base < BaseMutation - NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.' + NOT_EMOJI_AWARDABLE = 'You cannot add emoji reactions to this resource.' authorize :award_emoji @@ -20,7 +20,7 @@ module Mutations field :award_emoji, Types::AwardEmojis::AwardEmojiType, null: true, - description: 'Award emoji after mutation.' + description: 'Emoji reactions after mutation.' private diff --git a/app/graphql/mutations/ci/pipeline_schedule/create.rb b/app/graphql/mutations/ci/pipeline_schedule/create.rb index 71a366ed342..d21ac6fd727 100644 --- a/app/graphql/mutations/ci/pipeline_schedule/create.rb +++ b/app/graphql/mutations/ci/pipeline_schedule/create.rb @@ -51,28 +51,16 @@ module Mutations params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h)) - if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, project) - response = ::Ci::PipelineSchedules::CreateService - .new(project, current_user, params) - .execute - - schedule = response.payload - - unless response.success? - return { - pipeline_schedule: nil, errors: response.errors - } - end - else - schedule = ::Ci::CreatePipelineScheduleService - .new(project, current_user, params) - .execute - - unless schedule.persisted? - return { - pipeline_schedule: nil, errors: schedule.errors.full_messages - } - end + response = ::Ci::PipelineSchedules::CreateService + .new(project, current_user, params) + .execute + + schedule = response.payload + + unless response.success? + return { + pipeline_schedule: nil, errors: response.errors + } end { diff --git a/app/graphql/mutations/ci/pipeline_trigger/base.rb b/app/graphql/mutations/ci/pipeline_trigger/base.rb new file mode 100644 index 00000000000..23be70e7754 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_trigger/base.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineTrigger + class Base < BaseMutation + authorize :admin_build + authorize :admin_trigger + + PipelineTriggerID = ::Types::GlobalIDType[::Ci::Trigger] + + argument :id, PipelineTriggerID, + required: true, + description: 'ID of the pipeline trigger token to mutate.' + end + end + end +end diff --git a/app/graphql/mutations/ci/pipeline_trigger/create.rb b/app/graphql/mutations/ci/pipeline_trigger/create.rb new file mode 100644 index 00000000000..042f9b26dd0 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_trigger/create.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineTrigger + class Create < BaseMutation + graphql_name 'PipelineTriggerCreate' + + include FindsProject + + authorize :admin_build + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path of the project that the pipeline trigger token to mutate is in.' + + argument :description, GraphQL::Types::String, + required: true, + description: 'Description of the pipeline trigger token.' + + field :pipeline_trigger, Types::Ci::PipelineTriggerType, + null: true, + description: 'Mutated pipeline trigger token.' + + def resolve(project_path:, description:) + project = authorized_find!(project_path) + + trigger = project.triggers.create(owner: current_user, description: description) + + { + pipeline_trigger: trigger, + errors: trigger.errors.full_messages + } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/pipeline_trigger/delete.rb b/app/graphql/mutations/ci/pipeline_trigger/delete.rb new file mode 100644 index 00000000000..bc18f58ec43 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_trigger/delete.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineTrigger + class Delete < Base + graphql_name 'PipelineTriggerDelete' + + def resolve(id:) + trigger = authorized_find!(id: id) + + errors = trigger.destroy ? [] : ['Could not remove the trigger'] + + { errors: errors } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/pipeline_trigger/update.rb b/app/graphql/mutations/ci/pipeline_trigger/update.rb new file mode 100644 index 00000000000..fa68593eb09 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_trigger/update.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineTrigger + class Update < Base + graphql_name 'PipelineTriggerUpdate' + + argument :description, GraphQL::Types::String, + required: true, + description: 'Description of the pipeline trigger token.' + + field :pipeline_trigger, Types::Ci::PipelineTriggerType, + null: true, + description: 'Mutated pipeline trigger token.' + + def resolve(id:, description:) + trigger = authorized_find!(id: id) + + trigger.description = description + + trigger.save + + { + pipeline_trigger: trigger, + errors: trigger.errors.full_messages + } + end + end + end + end +end diff --git a/app/graphql/mutations/concerns/mutations/validate_time_estimate.rb b/app/graphql/mutations/concerns/mutations/validate_time_estimate.rb new file mode 100644 index 00000000000..82a56fd04f3 --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/validate_time_estimate.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module ValidateTimeEstimate + private + + def validate_time_estimate(time_estimate) + return unless time_estimate + + parsed_time_estimate = Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true) + + if parsed_time_estimate.nil? + raise Gitlab::Graphql::Errors::ArgumentError, + 'timeEstimate must be formatted correctly, for example `1h 30m`' + elsif parsed_time_estimate < 0 + raise Gitlab::Graphql::Errors::ArgumentError, + 'timeEstimate must be greater than or equal to zero. ' \ + 'Remember that every new timeEstimate overwrites the previous value.' + end + end + end +end diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index f009abdba70..7aa78509bea 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -47,7 +47,7 @@ module Mutations argument :award_emoji_widget, ::Types::WorkItems::Widgets::AwardEmojiUpdateInputType, required: false, - description: 'Input for award emoji widget.' + description: 'Input for emoji reactions widget.' end end end diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb index f18ce0eba97..76a5bf2f551 100644 --- a/app/graphql/mutations/environments/create.rb +++ b/app/graphql/mutations/environments/create.rb @@ -40,6 +40,11 @@ module Mutations required: false, description: 'Kubernetes namespace of the environment.' + argument :flux_resource_path, + GraphQL::Types::String, + required: false, + description: 'Flux resource path of the environment.' + field :environment, Types::EnvironmentType, null: true, diff --git a/app/graphql/mutations/environments/update.rb b/app/graphql/mutations/environments/update.rb index 07ab22685cc..44b9398c233 100644 --- a/app/graphql/mutations/environments/update.rb +++ b/app/graphql/mutations/environments/update.rb @@ -33,6 +33,11 @@ module Mutations required: false, description: 'Kubernetes namespace of the environment.' + argument :flux_resource_path, + GraphQL::Types::String, + required: false, + description: 'Flux resource path of the environment.' + field :environment, Types::EnvironmentType, null: true, diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index 2a863893cf1..35deb9e0af8 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -6,6 +6,7 @@ module Mutations graphql_name 'UpdateIssue' include CommonMutationArguments + include ValidateTimeEstimate argument :title, GraphQL::Types::String, required: false, @@ -54,9 +55,7 @@ module Mutations raise Gitlab::Graphql::Errors::ArgumentError, 'labelIds is mutually exclusive with any of addLabelIds or removeLabelIds' end - if !time_estimate.nil? && Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true).nil? - raise Gitlab::Graphql::Errors::ArgumentError, 'timeEstimate must be formatted correctly, for example `1h 30m`' - end + validate_time_estimate(time_estimate) super end diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb index da4db7342a3..470292df86c 100644 --- a/app/graphql/mutations/merge_requests/update.rb +++ b/app/graphql/mutations/merge_requests/update.rb @@ -5,6 +5,8 @@ module Mutations class Update < Base graphql_name 'MergeRequestUpdate' + include ValidateTimeEstimate + description 'Update attributes of a merge request' argument :title, GraphQL::Types::String, @@ -45,10 +47,7 @@ module Mutations end def ready?(time_estimate: nil, **args) - if !time_estimate.nil? && Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true).nil? - raise Gitlab::Graphql::Errors::ArgumentError, - 'timeEstimate must be formatted correctly, for example `1h 30m`' - end + validate_time_estimate(time_estimate) super end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb index 296efa19bb7..59ddffe3aad 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb @@ -61,14 +61,10 @@ module Mutations end end - def resolve(args) - annotation_response = ::Metrics::Dashboard::Annotations::CreateService.new(context[:current_user], annotation_create_params(args)).execute - - annotation = annotation_response[:annotation] - + def resolve(_args) { - annotation: annotation.valid? ? annotation : nil, - errors: errors_on_object(annotation) + annotation: nil, + errors: [] } end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb index 32047cda213..61fcf8e0b13 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb @@ -13,19 +13,11 @@ module Mutations required: true, description: 'Global ID of the annotation to delete.' + # rubocop:disable Lint/UnusedMethodArgument def resolve(id:) - raise_resource_not_available_error! if Feature.enabled?(:remove_monitor_metrics) - - annotation = authorized_find!(id: id) - - result = ::Metrics::Dashboard::Annotations::DeleteService.new(context[:current_user], annotation).execute - - errors = Array.wrap(result[:message]) - - { - errors: errors - } + raise_resource_not_available_error! end + # rubocop:enable Lint/UnusedMethodArgument end end end diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index 96bee693a1e..4e71bed52c6 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -8,6 +8,8 @@ module Mutations include Mutations::ResolvesNamespace + NUGET_DUPLICATES_FF_ERROR = '`nuget_duplicates_option` feature flag is disabled.' + description <<~DESC These settings can be adjusted by the group Owner or Maintainer. [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting @@ -41,6 +43,16 @@ module Mutations required: false, description: copy_field_description(Types::Namespace::PackageSettingsType, :generic_duplicate_exception_regex) + argument :nuget_duplicates_allowed, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_duplicates_allowed) + + argument :nuget_duplicate_exception_regex, + Types::UntrustedRegexp, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_duplicate_exception_regex) + argument :maven_package_requests_forwarding, GraphQL::Types::Boolean, required: false, @@ -79,6 +91,10 @@ module Mutations def resolve(namespace_path:, **args) namespace = authorized_find!(namespace_path: namespace_path) + if nuget_duplicate_settings_present?(args) && Feature.disabled?(:nuget_duplicates_option, namespace) + raise_resource_not_available_error! NUGET_DUPLICATES_FF_ERROR + end + result = ::Namespaces::PackageSettings::UpdateService .new(container: namespace, current_user: current_user, params: args) .execute @@ -94,6 +110,10 @@ module Mutations def find_object(namespace_path:) resolve_namespace(full_path: namespace_path) end + + def nuget_duplicate_settings_present?(args) + args.key?(:nuget_duplicates_allowed) || args.key?(:nuget_duplicate_exception_regex) + end end end end diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 9f7b7b5db97..7ce508e5ef1 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -14,6 +14,7 @@ module Mutations authorize :create_work_item MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR = 'Please provide either projectPath or namespacePath argument, but not both.' + DISABLED_FF_ERROR = 'namespace_level_work_items feature flag is disabled. Only project paths allowed.' argument :confidential, GraphQL::Types::Boolean, required: false, @@ -59,6 +60,7 @@ module Mutations def resolve(project_path: nil, namespace_path: nil, **attributes) container_path = project_path || namespace_path container = authorized_find!(container_path) + check_feature_available!(container) params = global_id_compatibility_params(attributes).merge(author_id: current_user.id) type = ::WorkItems::Type.find(attributes[:work_item_type_id]) @@ -81,6 +83,12 @@ module Mutations private + def check_feature_available!(container) + return unless container.is_a?(::Group) && Feature.disabled?(:namespace_level_work_items, container) + + raise Gitlab::Graphql::Errors::ArgumentError, DISABLED_FF_ERROR + end + def global_id_compatibility_params(params) params[:work_item_type_id] = params[:work_item_type_id]&.model_id diff --git a/app/graphql/mutations/work_items/linked_items/add.rb b/app/graphql/mutations/work_items/linked_items/add.rb new file mode 100644 index 00000000000..b346b074e85 --- /dev/null +++ b/app/graphql/mutations/work_items/linked_items/add.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + module LinkedItems + class Add < Base + graphql_name 'WorkItemAddLinkedItems' + description 'Add linked items to the work item.' + + argument :link_type, ::Types::WorkItems::RelatedLinkTypeEnum, + required: false, description: 'Type of link. Defaults to `RELATED`.' + + private + + def update_links(work_item, params) + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/419555') + + gids = params.delete(:work_items_ids) + work_items = begin + GitlabSchema.parse_gids(gids, expected_type: ::WorkItem).map(&:find) + rescue ActiveRecord::RecordNotFound => e + raise Gitlab::Graphql::Errors::ArgumentError, e + end + + ::WorkItems::RelatedWorkItemLinks::CreateService + .new(work_item, current_user, { target_issuable: work_items, link_type: params[:link_type] }) + .execute + end + end + end + end +end diff --git a/app/graphql/mutations/work_items/linked_items/base.rb b/app/graphql/mutations/work_items/linked_items/base.rb new file mode 100644 index 00000000000..1d8d74b02ac --- /dev/null +++ b/app/graphql/mutations/work_items/linked_items/base.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + module LinkedItems + class Base < BaseMutation + # Limit maximum number of items that can be linked at a time to avoid overloading the DB + # See https://gitlab.com/gitlab-org/gitlab/-/issues/419555 + MAX_WORK_ITEMS = 3 + + 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.' + + field :message, GraphQL::Types::String, + null: true, description: 'Linked items update result message.' + + authorize :read_work_item + + def ready?(**args) + 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.'), + max_work_items: MAX_WORK_ITEMS + ) + end + + super + end + + def resolve(**args) + work_item = authorized_find!(id: args.delete(:id)) + raise_resource_not_available_error! unless work_item.project.linked_work_items_feature_flag_enabled? + + service_response = update_links(work_item, args) + + { + work_item: work_item, + errors: service_response[:status] == :error ? Array.wrap(service_response[:message]) : [], + message: service_response[:status] == :success ? service_response[:message] : '' + } + end + + private + + def update_links(work_item, params) + raise NotImplementedError + end + end + end + end +end diff --git a/app/graphql/mutations/work_items/subscribe.rb b/app/graphql/mutations/work_items/subscribe.rb new file mode 100644 index 00000000000..a29c3416c3d --- /dev/null +++ b/app/graphql/mutations/work_items/subscribe.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module WorkItems + class Subscribe < BaseMutation + graphql_name 'WorkItemSubscribe' + + argument :id, ::Types::GlobalIDType[::WorkItem], + required: true, + description: 'Global ID of the work item.' + + argument :subscribed, + GraphQL::Types::Boolean, + required: true, + description: 'Desired state of the subscription.' + + field :work_item, Types::WorkItemType, + null: true, + description: 'Work item after mutation.' + + authorize :update_subscription + + def resolve(args) + work_item = authorized_find!(id: args[:id]) + + update_subscription(work_item, args[:subscribed]) + + { + work_item: work_item, + errors: [] + } + end + + private + + def update_subscription(work_item, subscribed_state) + work_item.set_subscription(current_user, subscribed_state, work_item.project) + end + end + end +end diff --git a/app/graphql/queries/repository/blob_info.query.graphql b/app/graphql/queries/repository/blob_info.query.graphql index fd463436ed4..7419961a564 100644 --- a/app/graphql/queries/repository/blob_info.query.graphql +++ b/app/graphql/queries/repository/blob_info.query.graphql @@ -2,6 +2,7 @@ query getBlobInfo( $projectPath: ID! $filePath: String! $ref: String! + $refType: RefType $shouldFetchRawText: Boolean! ) { project(fullPath: $projectPath) { @@ -10,7 +11,7 @@ query getBlobInfo( repository { __typename empty - blobs(paths: [$filePath], ref: $ref) { + blobs(paths: [$filePath], ref: $ref, refType: $refType) { __typename nodes { __typename diff --git a/app/graphql/queries/repository/files.query.graphql b/app/graphql/queries/repository/files.query.graphql index a83880ce696..bd7bb8ba787 100644 --- a/app/graphql/queries/repository/files.query.graphql +++ b/app/graphql/queries/repository/files.query.graphql @@ -19,6 +19,7 @@ query getFiles( $projectPath: ID! $path: String $ref: String! + $refType: RefType $pageSize: Int! $nextPageCursor: String ) { @@ -27,7 +28,7 @@ query getFiles( __typename repository { __typename - tree(path: $path, ref: $ref) { + tree(path: $path, ref: $ref, refType: $refType) { __typename trees(first: $pageSize, after: $nextPageCursor) { __typename diff --git a/app/graphql/queries/repository/paginated_tree.query.graphql b/app/graphql/queries/repository/paginated_tree.query.graphql index e82bc6d0734..68aa90046b9 100644 --- a/app/graphql/queries/repository/paginated_tree.query.graphql +++ b/app/graphql/queries/repository/paginated_tree.query.graphql @@ -7,13 +7,19 @@ fragment TreeEntry on Entry { type } -query getPaginatedTree($projectPath: ID!, $path: String, $ref: String!, $nextPageCursor: String) { +query getPaginatedTree( + $projectPath: ID! + $path: String + $ref: String! + $nextPageCursor: String + $refType: RefType +) { project(fullPath: $projectPath) { id __typename repository { __typename - paginatedTree(path: $path, ref: $ref, after: $nextPageCursor) { + paginatedTree(path: $path, ref: $ref, refType: $refType, after: $nextPageCursor) { __typename pageInfo { __typename diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql index facbf1555fc..738fdf534cb 100644 --- a/app/graphql/queries/repository/path_last_commit.query.graphql +++ b/app/graphql/queries/repository/path_last_commit.query.graphql @@ -1,10 +1,10 @@ -query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { +query pathLastCommit($projectPath: ID!, $path: String, $ref: String!, $refType: RefType) { project(fullPath: $projectPath) { __typename id repository { __typename - paginatedTree(path: $path, ref: $ref) { + paginatedTree(path: $path, ref: $ref, refType: $refType) { __typename nodes { __typename diff --git a/app/graphql/resolvers/abuse_report_labels_resolver.rb b/app/graphql/resolvers/abuse_report_labels_resolver.rb new file mode 100644 index 00000000000..86cebe8e541 --- /dev/null +++ b/app/graphql/resolvers/abuse_report_labels_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class AbuseReportLabelsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_label + + type Types::LabelType.connection_type, null: true + + argument :search_term, GraphQL::Types::String, + required: false, + description: 'Search term to find labels with.' + + def resolve(**args) + ::Admin::AbuseReportLabelsFinder.new(context[:current_user], args).execute + end + end +end diff --git a/app/graphql/resolvers/abuse_report_resolver.rb b/app/graphql/resolvers/abuse_report_resolver.rb new file mode 100644 index 00000000000..770409601b9 --- /dev/null +++ b/app/graphql/resolvers/abuse_report_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + class AbuseReportResolver < BaseResolver + description 'Retrieve an abuse report' + + type Types::AbuseReportType, null: true + + argument :id, Types::GlobalIDType[AbuseReport], required: true, description: 'ID of the abuse report.' + + def resolve(id:) + ::AbuseReport.find_by_id(extract_abuse_report_id(id)) + end + + private + + def extract_abuse_report_id(gid) + GitlabSchema.parse_gid(gid, expected_type: ::AbuseReport).model_id + end + end +end diff --git a/app/graphql/resolvers/autocomplete_users_resolver.rb b/app/graphql/resolvers/autocomplete_users_resolver.rb new file mode 100644 index 00000000000..40c53a46311 --- /dev/null +++ b/app/graphql/resolvers/autocomplete_users_resolver.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Resolvers + class AutocompleteUsersResolver < BaseResolver + type [::Types::Users::AutocompletedUserType], null: true + + argument :search, GraphQL::Types::String, + required: false, + description: 'Query to search users by name, username, or public email.' + + def resolve(search: nil) + ::Autocomplete::UsersFinder.new( + current_user: context[:current_user], + project: project, + group: group, + params: { + search: search + } + ).execute + end + + private + + def project + object if object.is_a?(Project) + end + + def group + object if object.is_a?(Group) + end + end +end diff --git a/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb b/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb new file mode 100644 index 00000000000..3d6e3b3e75d --- /dev/null +++ b/app/graphql/resolvers/ci/pipeline_triggers_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class PipelineTriggersResolver < BaseResolver + include LooksAhead + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :admin_build + type Types::Ci::PipelineTriggerType.connection_type, null: false + + def resolve_with_lookahead + apply_lookahead(object.triggers) + end + + private + + def unconditional_includes + [:trigger_requests] + end + end + end +end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index c0a068097a7..e9e7ea9f77f 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -59,7 +59,8 @@ module ResolvesMergeRequests timelogs: [:timelogs], pipelines: [:merge_request_diffs], # used by `recent_diff_head_shas` to load pipelines committers: [merge_request_diff: [:merge_request_diff_commits]], - suggested_reviewers: [:predictions] + suggested_reviewers: [:predictions], + diff_stats: [latest_merge_request_diff: [:merge_request_diff_commits]] } end end diff --git a/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb new file mode 100644 index 00000000000..92fb9ec5cef --- /dev/null +++ b/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module WorkItems + module LookAheadPreloads + extend ActiveSupport::Concern + + prepended do + include ::LooksAhead + end + + private + + def preloads + { + work_item_type: :work_item_type, + web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] }, + widgets: { work_item_type: :enabled_widget_definitions } + } + end + + def nested_preloads + { + widgets: widget_preloads, + user_permissions: { update_work_item: :assignees }, + project: { jira_import_status: { project: :jira_imports } }, + author: { + location: { author: :user_detail }, + gitpod_enabled: { author: :user_preference } + } + } + end + + def widget_preloads + { + last_edited_by: :last_edited_by, + assignees: :assignees, + parent: :work_item_parent, + children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] }, + labels: :labels, + milestone: { milestone: [:project, :group] }, + subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }], + award_emoji: { award_emoji: :awardable } + } + end + + def unconditional_includes + [ + { + project: [:project_feature, :group] + }, + :author + ] + end + end +end + +WorkItems::LookAheadPreloads.prepend_mod diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb index aad9bbebafb..b967460c7ff 100644 --- a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb +++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb @@ -16,11 +16,10 @@ module Resolvers alias_method :dashboard, :object - def resolve(**args) + def resolve(**_args) return if Feature.enabled?(:remove_monitor_metrics) - return [] unless dashboard - ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute + [] end end end diff --git a/app/graphql/resolvers/namespaces/work_items_resolver.rb b/app/graphql/resolvers/namespaces/work_items_resolver.rb new file mode 100644 index 00000000000..54bb8392071 --- /dev/null +++ b/app/graphql/resolvers/namespaces/work_items_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + module Namespaces + class WorkItemsResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads + + 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 + )) + + Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all do |q| + apply_lookahead(q) + end + 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 + end + strong_memoize_attr :resource_parent + 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 new file mode 100644 index 00000000000..9c71cd7c0c9 --- /dev/null +++ b/app/graphql/resolvers/work_items/linked_items_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Resolvers + module WorkItems + class LinkedItemsResolver < BaseResolver + alias_method :linked_items_widget, :object + + type Types::WorkItems::LinkedItemType.connection_type, null: true + + def resolve + related_work_items.map do |related_work_item| + { + link_id: related_work_item.issue_link_id, + link_type: related_work_item.issue_link_type, + link_created_at: related_work_item.issue_link_created_at, + link_updated_at: related_work_item.issue_link_updated_at, + work_item: related_work_item + } + end + end + + private + + def related_work_items + return [] unless work_item.project.linked_work_items_feature_flag_enabled? + + work_item.related_issues(current_user, preload: { project: [:project_feature, :group] }) + end + + def work_item + linked_items_widget.work_item + end + strong_memoize_attr :work_item + end + end +end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 14eec4f696a..d4f73361e05 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -2,8 +2,8 @@ module Resolvers class WorkItemsResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads include SearchArguments - include LooksAhead include ::WorkItems::SharedFilterArguments argument :iid, @@ -28,48 +28,6 @@ module Resolvers private - def preloads - { - work_item_type: :work_item_type, - web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] }, - widgets: { work_item_type: :enabled_widget_definitions } - } - end - - def nested_preloads - { - widgets: widget_preloads, - user_permissions: { update_work_item: :assignees }, - project: { jira_import_status: { project: :jira_imports } }, - author: { - location: { author: :user_detail }, - gitpod_enabled: { author: :user_preference } - } - } - end - - def widget_preloads - { - last_edited_by: :last_edited_by, - assignees: :assignees, - parent: :work_item_parent, - children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] }, - labels: :labels, - milestone: { milestone: [:project, :group] }, - subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }], - award_emoji: { award_emoji: :awardable } - } - end - - def unconditional_includes - [ - { - project: [:project_feature, :group] - }, - :author - ] - end - def prepare_finder_params(args) params = super(args) params[:iids] ||= [params.delete(:iid)].compact if params[:iid] @@ -88,4 +46,4 @@ module Resolvers end end -Resolvers::WorkItemsResolver.prepend_mod_with('Resolvers::WorkItemsResolver') +Resolvers::WorkItemsResolver.prepend_mod diff --git a/app/graphql/types/abuse_report_type.rb b/app/graphql/types/abuse_report_type.rb new file mode 100644 index 00000000000..012e709cdb5 --- /dev/null +++ b/app/graphql/types/abuse_report_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class AbuseReportType < BaseObject + graphql_name 'AbuseReport' + description 'An abuse report' + authorize :read_abuse_report + + field :labels, ::Types::LabelType.connection_type, + null: true, description: 'Labels of the abuse report.' + end +end diff --git a/app/graphql/types/access_levels/deploy_key_type.rb b/app/graphql/types/access_levels/deploy_key_type.rb new file mode 100644 index 00000000000..e4e90619891 --- /dev/null +++ b/app/graphql/types/access_levels/deploy_key_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module AccessLevels + class DeployKeyType < BaseObject + graphql_name 'AccessLevelDeployKey' + description 'Representation of a GitLab deploy key.' + + authorize :read_deploy_key + + field :id, + type: GraphQL::Types::ID, + null: false, + description: 'ID of the deploy key.' + + field :title, + type: GraphQL::Types::String, + null: false, + description: 'Title of the deploy key.' + + field :expires_at, + type: Types::DateType, + null: true, + description: 'Expiration date of the deploy key.' + + field :user, + type: Types::AccessLevels::UserType, + null: false, + description: 'User assigned to the deploy key.' + end + end +end diff --git a/app/graphql/types/access_levels/user_type.rb b/app/graphql/types/access_levels/user_type.rb new file mode 100644 index 00000000000..82aba673250 --- /dev/null +++ b/app/graphql/types/access_levels/user_type.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Types + module AccessLevels + class UserType < BaseObject + graphql_name 'AccessLevelUser' + description 'Representation of a GitLab user.' + + authorize :read_user + + present_using UserPresenter + + field :id, + type: GraphQL::Types::ID, + null: false, + description: 'ID of the user.' + + field :username, + type: GraphQL::Types::String, + null: false, + description: 'Username of the user.' + + field :name, + type: GraphQL::Types::String, + null: false, + resolver_method: :redacted_name, + description: <<~DESC + Human-readable name of the user. + Returns `****` if the user is a project bot and the requester does not have permission to view the project. + DESC + + field :public_email, + type: GraphQL::Types::String, + null: true, + description: "User's public email." + + field :avatar_url, + type: GraphQL::Types::String, + null: true, + description: "URL of the user's avatar." + + field :web_url, + type: GraphQL::Types::String, + null: false, + description: 'Web URL of the user.' + + field :web_path, + type: GraphQL::Types::String, + null: false, + description: 'Web path of the user.' + + def redacted_name + object.redacted_name(context[:current_user]) + end + + def avatar_url + object.avatar_url(only_path: false) + end + end + end +end diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb index ec558981465..d733bf39a51 100644 --- a/app/graphql/types/achievements/achievement_type.rb +++ b/app/graphql/types/achievements/achievement_type.rb @@ -5,6 +5,8 @@ module Types class AchievementType < BaseObject graphql_name 'Achievement' + connection_type_class Types::CountableConnectionType + authorize :read_achievement field :id, diff --git a/app/graphql/types/achievements/user_achievement_type.rb b/app/graphql/types/achievements/user_achievement_type.rb index bf161d2f1e5..7cdcb66576c 100644 --- a/app/graphql/types/achievements/user_achievement_type.rb +++ b/app/graphql/types/achievements/user_achievement_type.rb @@ -5,6 +5,8 @@ module Types class UserAchievementType < BaseObject graphql_name 'UserAchievement' + connection_type_class Types::CountableConnectionType + authorize :read_user_achievement field :id, diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index c17406b3e56..e85d0213613 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -8,8 +8,8 @@ module Types present_using ::AlertManagement::AlertPresenter - implements(Types::Notes::NoteableInterface) - implements(Types::TodoableInterface) + implements Types::Notes::NoteableInterface + implements Types::TodoableInterface authorize :read_alert_management_alert @@ -111,6 +111,12 @@ module Types null: true, description: 'Assignees of the alert.' + field :metrics_dashboard_url, + GraphQL::Types::String, + null: true, + description: 'URL for metrics embed for the alert.', + deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0', + milestone: '16.0' } field :runbook, GraphQL::Types::String, null: true, @@ -136,6 +142,10 @@ module Types method: :details_url, null: false, description: 'URL of the alert.' + + def metrics_dashboard_url + nil + end end end end diff --git a/app/graphql/types/alert_management/http_integration_type.rb b/app/graphql/types/alert_management/http_integration_type.rb index bba9cb1bbfc..7c026be86a3 100644 --- a/app/graphql/types/alert_management/http_integration_type.rb +++ b/app/graphql/types/alert_management/http_integration_type.rb @@ -6,7 +6,7 @@ module Types graphql_name 'AlertManagementHttpIntegration' description 'An endpoint and credentials used to accept alerts for a project' - implements(Types::AlertManagement::IntegrationType) + implements Types::AlertManagement::IntegrationType authorize :admin_operations diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb index 9a2ef78eca7..0f61eeaa177 100644 --- a/app/graphql/types/alert_management/prometheus_integration_type.rb +++ b/app/graphql/types/alert_management/prometheus_integration_type.rb @@ -8,7 +8,7 @@ module Types include ::Gitlab::Routing - implements(Types::AlertManagement::IntegrationType) + implements Types::AlertManagement::IntegrationType authorize :admin_project diff --git a/app/graphql/types/branch_protections/push_access_level_type.rb b/app/graphql/types/branch_protections/push_access_level_type.rb index c5e21fad88d..2a66f1a4ec5 100644 --- a/app/graphql/types/branch_protections/push_access_level_type.rb +++ b/app/graphql/types/branch_protections/push_access_level_type.rb @@ -6,6 +6,11 @@ module Types graphql_name 'PushAccessLevel' description 'Defines which user roles, users, or groups can push to a protected branch.' accepts ::ProtectedBranch::PushAccessLevel + + field :deploy_key, + Types::AccessLevels::DeployKeyType, + null: true, + description: 'Deploy key assigned to the access level.' end end end diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index f7ef94f58c0..45ecbf5c084 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -7,32 +7,37 @@ module Types authorize :admin_project - field :job_token_scope_enabled, - GraphQL::Types::Boolean, - null: true, - description: 'Indicates CI/CD job tokens generated in this project ' \ - 'have restricted access to other projects.', - method: :job_token_scope_enabled? - field :inbound_job_token_scope_enabled, - GraphQL::Types::Boolean, - null: true, - description: 'Indicates CI/CD job tokens generated in other projects ' \ - 'have restricted access to this project.', - method: :inbound_job_token_scope_enabled? - - field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, - description: 'Whether to keep the latest builds artifacts.', - method: :keep_latest_artifacts_available? - field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true, - description: 'Whether merge pipelines are enabled.', - method: :merge_pipelines_enabled? - field :merge_trains_enabled, GraphQL::Types::Boolean, null: true, - description: 'Whether merge trains are enabled.', - method: :merge_trains_enabled? - - field :project, Types::ProjectType, null: true, - description: 'Project the CI/CD settings belong to.' + GraphQL::Types::Boolean, + null: true, + description: 'Indicates CI/CD job tokens generated in other projects ' \ + 'have restricted access to this project.', + method: :inbound_job_token_scope_enabled? + field :job_token_scope_enabled, + GraphQL::Types::Boolean, + null: true, + description: 'Indicates CI/CD job tokens generated in this project ' \ + 'have restricted access to other projects.', + method: :job_token_scope_enabled? + field :keep_latest_artifact, + GraphQL::Types::Boolean, + null: true, + description: 'Whether to keep the latest builds artifacts.', + method: :keep_latest_artifacts_available? + field :merge_pipelines_enabled, + GraphQL::Types::Boolean, + null: true, + description: 'Whether merge pipelines are enabled.', + method: :merge_pipelines_enabled? + field :merge_trains_enabled, + GraphQL::Types::Boolean, + null: true, + description: 'Whether merge trains are enabled.', + method: :merge_trains_enabled? + field :project, + Types::ProjectType, + null: true, + description: 'Project the CI/CD settings belong to.' end end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index 8bc50e974bb..e18770c2708 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -39,17 +39,15 @@ module Types end def action - if object.has_action? - { - button_title: object.action_button_title, - icon: object.action_icon, - method: object.action_method, - path: object.action_path, - title: object.action_title - } - else - nil - end + return unless object.has_action? + + { + button_title: object.action_button_title, + icon: object.action_icon, + method: object.action_method, + path: object.action_path, + title: object.action_title + } end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/ci/group_environment_scope_type.rb b/app/graphql/types/ci/group_environment_scope_type.rb index 3a3a5a3f59f..0dd0ad963ac 100644 --- a/app/graphql/types/ci/group_environment_scope_type.rb +++ b/app/graphql/types/ci/group_environment_scope_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'CiGroupEnvironmentScope' description 'Ci/CD environment scope for a group.' - connection_type_class(Types::Ci::GroupEnvironmentScopeConnectionType) + connection_type_class Types::Ci::GroupEnvironmentScopeConnectionType field :name, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb index 7e2afba0d53..7be9b3df0b8 100644 --- a/app/graphql/types/ci/group_variable_type.rb +++ b/app/graphql/types/ci/group_variable_type.rb @@ -7,8 +7,8 @@ module Types graphql_name 'CiGroupVariable' description 'CI/CD variables for a group.' - connection_type_class(Types::Ci::GroupVariableConnectionType) - implements(VariableInterface) + connection_type_class Types::Ci::GroupVariableConnectionType + implements VariableInterface field :environment_scope, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb index 7ffc52deb73..e3230556769 100644 --- a/app/graphql/types/ci/instance_variable_type.rb +++ b/app/graphql/types/ci/instance_variable_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'CiInstanceVariable' description 'CI/CD variables for a GitLab instance.' - implements(VariableInterface) + implements VariableInterface field :id, GraphQL::Types::ID, null: false, diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 02b10f3e4bd..22eb32993c5 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -9,7 +9,7 @@ module Types present_using ::Ci::BuildPresenter - connection_type_class(Types::LimitedCountableConnectionType) + connection_type_class Types::LimitedCountableConnectionType expose_permissions Types::PermissionTypes::Ci::Job diff --git a/app/graphql/types/ci/manual_variable_type.rb b/app/graphql/types/ci/manual_variable_type.rb index ed92a6645b4..dcdaa3a6b19 100644 --- a/app/graphql/types/ci/manual_variable_type.rb +++ b/app/graphql/types/ci/manual_variable_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'CiManualVariable' description 'CI/CD variables given to a manual job.' - implements(VariableInterface) + implements VariableInterface field :environment_scope, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/ci/pipeline_schedule_type.rb b/app/graphql/types/ci/pipeline_schedule_type.rb index 904fa3f1c72..71a1f28ea38 100644 --- a/app/graphql/types/ci/pipeline_schedule_type.rb +++ b/app/graphql/types/ci/pipeline_schedule_type.rb @@ -7,7 +7,7 @@ module Types description 'Represents a pipeline schedule' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType expose_permissions Types::PermissionTypes::Ci::PipelineSchedules diff --git a/app/graphql/types/ci/pipeline_schedule_variable_type.rb b/app/graphql/types/ci/pipeline_schedule_variable_type.rb index 1cb407bc2e4..f9c18d6f7df 100644 --- a/app/graphql/types/ci/pipeline_schedule_variable_type.rb +++ b/app/graphql/types/ci/pipeline_schedule_variable_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_pipeline_schedule_variables - implements(VariableInterface) + implements VariableInterface end end end diff --git a/app/graphql/types/ci/pipeline_trigger_type.rb b/app/graphql/types/ci/pipeline_trigger_type.rb new file mode 100644 index 00000000000..81345c14ba0 --- /dev/null +++ b/app/graphql/types/ci/pipeline_trigger_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module Ci + class PipelineTriggerType < BaseObject + graphql_name 'PipelineTrigger' + + present_using ::Ci::TriggerPresenter + connection_type_class Types::CountableConnectionType + + authorize :admin_build + + field :can_access_project, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if the pipeline trigger token has access to the project.', + method: :can_access_project? + + field :description, GraphQL::Types::String, + null: true, + description: 'Description of the pipeline trigger token.' + + field :has_token_exposed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if the token is exposed.', + method: :has_token_exposed? + + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the pipeline trigger token.' + + field :last_used, Types::TimeType, + null: true, + description: 'Timestamp of the last usage of the pipeline trigger token.' + + field :owner, Types::UserType, + null: false, + description: 'Owner of the pipeline trigger token.' + + field :token, GraphQL::Types::String, + null: false, + description: 'Value of the pipeline trigger token.' + end + end +end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 19d261853a7..ba638d4bc47 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -5,7 +5,7 @@ module Types class PipelineType < BaseObject graphql_name 'Pipeline' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType authorize :read_pipeline present_using ::Ci::PipelinePresenter diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb index a9679000511..cd069be3320 100644 --- a/app/graphql/types/ci/project_variable_type.rb +++ b/app/graphql/types/ci/project_variable_type.rb @@ -7,8 +7,8 @@ module Types graphql_name 'CiProjectVariable' description 'CI/CD variables for a project.' - connection_type_class(Types::Ci::ProjectVariableConnectionType) - implements(VariableInterface) + connection_type_class Types::Ci::ProjectVariableConnectionType + implements VariableInterface field :environment_scope, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/ci/recent_failures_type.rb b/app/graphql/types/ci/recent_failures_type.rb index 0892cb2735c..37850c62658 100644 --- a/app/graphql/types/ci/recent_failures_type.rb +++ b/app/graphql/types/ci/recent_failures_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'RecentFailures' description 'Recent failure history of a test case.' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :count, GraphQL::Types::Int, null: true, description: 'Number of times the test case has failed in the past 14 days.' diff --git a/app/graphql/types/ci/runner_manager_type.rb b/app/graphql/types/ci/runner_manager_type.rb index 9c89b6537ea..9311836cf27 100644 --- a/app/graphql/types/ci/runner_manager_type.rb +++ b/app/graphql/types/ci/runner_manager_type.rb @@ -5,7 +5,7 @@ module Types class RunnerManagerType < BaseObject graphql_name 'CiRunnerManager' - connection_type_class(::Types::CountableConnectionType) + connection_type_class ::Types::CountableConnectionType authorize :read_runner_manager @@ -26,6 +26,11 @@ module Types description: 'ID of the runner manager.' field :ip_address, GraphQL::Types::String, null: true, description: 'IP address of the runner manager.' + field :job_execution_status, + Types::Ci::RunnerJobExecutionStatusEnum, + null: true, + description: 'Job execution status of the runner manager.', + alpha: { milestone: '16.3' } field :platform_name, GraphQL::Types::String, null: true, description: 'Platform provided by the runner manager.', method: :platform @@ -44,6 +49,16 @@ module Types def executor_name ::Ci::Runner::EXECUTOR_TYPE_TO_NAMES[runner_manager.executor_type&.to_sym] end + + def job_execution_status + BatchLoader::GraphQL.for(runner_manager.id).batch(key: :running_builds_exist) do |runner_manager_ids, loader| + statuses = ::Ci::RunnerManager.id_in(runner_manager_ids).with_running_builds.index_by(&:id) + + runner_manager_ids.each do |runner_manager_id| + loader.call(runner_manager_id, statuses[runner_manager_id] ? :running : :idle) + end + end + end end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 2baf64ca663..c9f92c05975 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -6,7 +6,7 @@ module Types graphql_name 'CiRunner' edge_type_class(RunnerWebUrlEdge) - connection_type_class(RunnerCountableConnectionType) + connection_type_class RunnerCountableConnectionType authorize :read_runner present_using ::Ci::RunnerPresenter @@ -59,7 +59,9 @@ module Types deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, description: 'IP address of the runner.' field :job_count, GraphQL::Types::Int, null: true, - description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist).", + description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to " \ + "indicate that more items exist).\n`jobCount` is an optimized version of `jobs { count }`, " \ + "and can be requested for multiple runners on the same request.", resolver: ::Resolvers::Ci::RunnerJobCountResolver field :job_execution_status, Types::Ci::RunnerJobExecutionStatusEnum, @@ -76,7 +78,6 @@ module Types description: 'Runner\'s maintenance notes.' field :managers, ::Types::Ci::RunnerManagerType.connection_type, null: true, description: 'Machines associated with the runner configuration.', - method: :runner_managers, alpha: { milestone: '15.10' } field :maximum_timeout, GraphQL::Types::Int, null: true, description: 'Maximum timeout (in seconds) for jobs processed by the runner.' @@ -173,6 +174,18 @@ module Types end end + def managers + BatchLoader::GraphQL.for(runner.id).batch(key: :runner_managers) do |runner_ids, loader| + runner_managers_by_runner_id = + ::Ci::RunnerManager.for_runner(runner_ids).order_id_desc.group_by(&:runner_id) + + runner_ids.each do |runner_id| + runner_managers = Array.wrap(runner_managers_by_runner_id[runner_id]) + loader.call(runner_id, runner_managers) + end + end + end + def job_execution_status BatchLoader::GraphQL.for(runner.id).batch(key: :running_builds_exist) do |runner_ids, loader| statuses = ::Ci::Runner.id_in(runner_ids).with_running_builds.index_by(&:id) diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb index f88923215eb..78c70fbcc7c 100644 --- a/app/graphql/types/ci/test_case_type.rb +++ b/app/graphql/types/ci/test_case_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'TestCase' description 'Test case in pipeline test report.' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :status, Types::Ci::TestCaseStatusEnum, diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb index 8801501c8d4..a98c47c6a30 100644 --- a/app/graphql/types/ci/test_suite_summary_type.rb +++ b/app/graphql/types/ci/test_suite_summary_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'TestSuiteSummary' description 'Test suite summary in a pipeline test report.' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :name, GraphQL::Types::String, null: true, description: 'Name of the test suite.' diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb index 8845338ed6d..a5cc6282abc 100644 --- a/app/graphql/types/ci/test_suite_type.rb +++ b/app/graphql/types/ci/test_suite_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'TestSuite' description 'Test suite in a pipeline test report.' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :name, GraphQL::Types::String, null: true, description: 'Name of the test suite.' diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb index 1d0ec7c4959..8c7dfa9c10a 100644 --- a/app/graphql/types/clusters/agent_activity_event_type.rb +++ b/app/graphql/types/clusters/agent_activity_event_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_cluster_agent - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :recorded_at, Types::TimeType, diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb index 720ee2f685b..260c634e12e 100644 --- a/app/graphql/types/clusters/agent_token_type.rb +++ b/app/graphql/types/clusters/agent_token_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_cluster_agent - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :cluster_agent, Types::Clusters::AgentType, diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb index 317a1aab628..c0989796141 100644 --- a/app/graphql/types/clusters/agent_type.rb +++ b/app/graphql/types/clusters/agent_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_cluster_agent - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :created_at, Types::TimeType, diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index 5dd862c7388..9f83e955f4c 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -8,7 +8,7 @@ module Types present_using CommitPresenter - implements(Types::TodoableInterface) + implements Types::TodoableInterface field :id, type: GraphQL::Types::ID, null: false, description: 'ID (global ID) of the commit.' @@ -34,6 +34,9 @@ module Types field :authored_date, type: Types::TimeType, null: true, description: 'Timestamp of when the commit was authored.' + field :committed_date, type: Types::TimeType, null: true, + description: 'Timestamp of when the commit was committed.' + field :web_url, type: GraphQL::Types::String, null: false, description: 'Web URL of the commit.' @@ -55,10 +58,24 @@ module Types field :author_name, type: GraphQL::Types::String, null: true, description: 'Commit authors name.' + field :committer_email, type: GraphQL::Types::String, null: true, + description: "Email of the committer." + + field :committer_name, type: GraphQL::Types::String, null: true, + description: "Name of the committer." + # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true, description: 'Author of the commit.' + field :diffs, [Types::DiffType], null: true, calls_gitaly: true, + description: 'Diffs contained within the commit. ' \ + 'This field can only be resolved for 10 diffs in any single request.' do + # Limited to 10 calls per GraphQL request as calling `diffs` multiple times will + # lead to N+1 queries to Gitaly. + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 10 + end + field :pipelines, null: true, description: 'Pipelines of the commit ordered latest first.', @@ -68,6 +85,10 @@ module Types markdown_field :full_title_html, null: true markdown_field :description_html, null: true + def diffs + object.diffs.diffs + end + def author_gravatar GravatarService.new.execute(object.author_email, 40) end diff --git a/app/graphql/types/custom_emoji_type.rb b/app/graphql/types/custom_emoji_type.rb index 379a0c44d67..b02cd56e6df 100644 --- a/app/graphql/types/custom_emoji_type.rb +++ b/app/graphql/types/custom_emoji_type.rb @@ -7,6 +7,10 @@ module Types authorize :read_custom_emoji + connection_type_class(Types::CountableConnectionType) + + expose_permissions Types::PermissionTypes::CustomEmoji + field :id, ::Types::GlobalIDType[::CustomEmoji], null: false, description: 'ID of the emoji.' @@ -23,5 +27,9 @@ module Types field :external, GraphQL::Types::Boolean, null: false, description: 'Whether the emoji is an external link.' + + field :created_at, Types::TimeType, + null: false, + description: 'Timestamp of when the custom emoji was created.' end end diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb index 6d895cc81cf..c49633275fb 100644 --- a/app/graphql/types/deployment_type.rb +++ b/app/graphql/types/deployment_type.rb @@ -54,8 +54,7 @@ module Types field :job, Types::Ci::JobType, - description: 'Pipeline job of the deployment.', - method: :build + description: 'Pipeline job of the deployment.' field :triggerer, Types::UserType, diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb index be5edd17643..d253ca8bfea 100644 --- a/app/graphql/types/design_management/design_type.rb +++ b/app/graphql/types/design_management/design_type.rb @@ -10,10 +10,10 @@ module Types alias_method :design, :object - implements(Types::Notes::NoteableInterface) - implements(Types::DesignManagement::DesignFields) - implements(Types::CurrentUserTodos) - implements(Types::TodoableInterface) + implements Types::Notes::NoteableInterface + implements Types::DesignManagement::DesignFields + implements Types::CurrentUserTodos + implements Types::TodoableInterface field :description, GraphQL::Types::String, diff --git a/app/graphql/types/diff_type.rb b/app/graphql/types/diff_type.rb new file mode 100644 index 00000000000..1c67c8c645a --- /dev/null +++ b/app/graphql/types/diff_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class DiffType < BaseObject + graphql_name 'Diff' + + field :a_mode, GraphQL::Types::String, null: true, + description: 'Old file mode of the file.' + field :b_mode, GraphQL::Types::String, null: true, + description: 'New file mode of the file.' + field :deleted_file, GraphQL::Types::String, null: true, + description: 'Indicates if the file has been removed. ' + field :diff, GraphQL::Types::String, null: true, + description: 'Diff representation of the changes made to the file.' + field :new_file, GraphQL::Types::String, null: true, + description: 'Indicates if the file has just been added. ' + field :new_path, GraphQL::Types::String, null: true, + description: 'New path of the file.' + field :old_path, GraphQL::Types::String, null: true, + description: 'Old path of the file.' + field :renamed_file, GraphQL::Types::String, null: true, + description: 'Indicates if the file has been renamed.' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index aee09e5a143..63f2b247e01 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -36,6 +36,9 @@ module Types field :kubernetes_namespace, GraphQL::Types::String, null: true, description: 'Kubernetes namespace of the environment.' + field :flux_resource_path, GraphQL::Types::String, null: true, + description: 'Flux resource path of the environment.' + field :created_at, Types::TimeType, description: 'When the environment was created.' diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 5fd6ee948d3..258cf1539fb 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -87,6 +87,7 @@ module Types Types::Ci::GroupEnvironmentScopeType.connection_type, description: 'Environment scopes of the group.', null: true, + authorize: :admin_group, resolver: Resolvers::GroupEnvironmentScopesResolver field :milestones, @@ -261,6 +262,17 @@ module Types resolver: Resolvers::DataTransfer::GroupDataTransferResolver, description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' + field :work_items, + null: true, + description: 'Work items that belong to the namespace.', + alpha: { milestone: '16.3' }, + resolver: ::Resolvers::Namespaces::WorkItemsResolver + + field :autocomplete_users, + null: true, + resolver: Resolvers::AutocompleteUsersResolver, + description: 'Search users for autocompletion' + def label(title:) BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 488e4d10cbc..4b7118d75a5 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -4,11 +4,11 @@ module Types class IssueType < BaseObject graphql_name 'Issue' - connection_type_class(Types::IssueConnectionType) + connection_type_class Types::IssueConnectionType - implements(Types::Notes::NoteableInterface) - implements(Types::CurrentUserTodos) - implements(Types::TodoableInterface) + implements Types::Notes::NoteableInterface + implements Types::CurrentUserTodos + implements Types::TodoableInterface authorize :read_issue @@ -92,7 +92,13 @@ module Types field :emails_disabled, GraphQL::Types::Boolean, null: false, method: :project_emails_disabled?, - description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.' + description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.', + deprecated: { reason: 'Use `emails_enabled`', milestone: '16.3' } + + field :emails_enabled, GraphQL::Types::Boolean, null: false, + method: :project_emails_enabled?, + description: 'Indicates if a project has email notifications disabled: `false` if email notifications are disabled.' + field :human_time_estimate, GraphQL::Types::String, null: true, description: 'Human-readable time estimate of the issue.' field :human_total_time_spent, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 05b703e60af..4848ee30950 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -4,7 +4,7 @@ module Types class LabelType < BaseObject graphql_name 'Label' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType authorize :read_label diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb index bcf18b836de..e03b79dfeb8 100644 --- a/app/graphql/types/merge_request_state_enum.rb +++ b/app/graphql/types/merge_request_state_enum.rb @@ -5,6 +5,7 @@ module Types graphql_name 'MergeRequestState' description 'State of a GitLab merge request' - value 'merged', description: "Merge request has been merged." + value 'merged', description: 'Merge request has been merged.' + value 'opened', description: 'Opened merge request.' end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 99c719f1402..3fe8a05b311 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -4,11 +4,11 @@ module Types class MergeRequestType < BaseObject graphql_name 'MergeRequest' - connection_type_class(Types::MergeRequestConnectionType) + connection_type_class Types::MergeRequestConnectionType - implements(Types::Notes::NoteableInterface) - implements(Types::CurrentUserTodos) - implements(Types::TodoableInterface) + implements Types::Notes::NoteableInterface + implements Types::CurrentUserTodos + implements Types::TodoableInterface authorize :read_merge_request @@ -192,6 +192,11 @@ module Types field :total_time_spent, GraphQL::Types::Int, null: false, description: 'Total time reported as spent on the merge request.' + field :approved, GraphQL::Types::Boolean, + method: :approved?, + null: false, calls_gitaly: true, + description: 'Indicates if the merge request has all the required approvals.' + field :approved_by, Types::UserType.connection_type, null: true, description: 'Users who approved the merge request.', method: :approved_by_users field :auto_merge_strategy, GraphQL::Types::String, null: true, @@ -221,7 +226,7 @@ module Types field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type, null: true, - description: 'List of award emojis associated with the merge request.' + description: 'List of emoji reactions associated with the merge request.' field :prepared_at, Types::TimeType, null: true, description: 'Timestamp of when the merge request was prepared.' diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 16c46d172f3..957fd10690f 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -143,6 +143,9 @@ module Types mount_mutation Mutations::Ci::PipelineSchedule::Play mount_mutation Mutations::Ci::PipelineSchedule::Create mount_mutation Mutations::Ci::PipelineSchedule::Update + mount_mutation Mutations::Ci::PipelineTrigger::Create, alpha: { milestone: '16.3' } + mount_mutation Mutations::Ci::PipelineTrigger::Update, alpha: { milestone: '16.3' } + mount_mutation Mutations::Ci::PipelineTrigger::Delete, alpha: { milestone: '16.3' } mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate mount_mutation Mutations::Ci::Job::ArtifactsDestroy mount_mutation Mutations::Ci::Job::Play @@ -177,12 +180,14 @@ module Types mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::Export, alpha: { milestone: '15.10' } mount_mutation Mutations::WorkItems::Convert, alpha: { milestone: '15.11' } + mount_mutation Mutations::WorkItems::LinkedItems::Add, alpha: { milestone: '16.3' } mount_mutation Mutations::SavedReplies::Create mount_mutation Mutations::SavedReplies::Update mount_mutation Mutations::Pages::MarkOnboardingComplete mount_mutation Mutations::SavedReplies::Destroy mount_mutation Mutations::Uploads::Delete mount_mutation Mutations::Users::SetNamespaceCommitEmail + mount_mutation Mutations::WorkItems::Subscribe, alpha: { milestone: '16.3' } end end diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index 84becba8001..61240243b1f 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -20,6 +20,14 @@ module Types field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' + field :nuget_duplicate_exception_regex, Types::UntrustedRegexp, + null: true, + description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. ' \ + 'Error is raised if `nuget_duplicates_option` feature flag is disabled.' + field :nuget_duplicates_allowed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether duplicate NuGet packages are allowed for this namespace. ' \ + 'Error is raised if `nuget_duplicates_option` feature flag is disabled.' field :maven_package_requests_forwarding, GraphQL::Types::Boolean, null: true, diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb index 5e40c8008a9..7afb1f392d3 100644 --- a/app/graphql/types/notes/discussion_type.rb +++ b/app/graphql/types/notes/discussion_type.rb @@ -9,7 +9,7 @@ module Types authorize :read_note - implements(Types::ResolvableInterface) + implements Types::ResolvableInterface field :created_at, Types::TimeType, null: false, description: "Timestamp of the discussion's creation." diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index eb1963f976a..e7e032c67c6 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -9,7 +9,7 @@ module Types expose_permissions Types::PermissionTypes::Note - implements(Types::ResolvableInterface) + implements Types::ResolvableInterface field :max_access_level_of_author, GraphQL::Types::String, null: true, @@ -43,7 +43,7 @@ module Types field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type, null: true, - description: 'List of award emojis associated with the note.' + description: 'List of emoji reactions associated with the note.' field :confidential, GraphQL::Types::Boolean, null: true, diff --git a/app/graphql/types/notes/position_type_enum.rb b/app/graphql/types/notes/position_type_enum.rb index 157b0b4b884..b585d531192 100644 --- a/app/graphql/types/notes/position_type_enum.rb +++ b/app/graphql/types/notes/position_type_enum.rb @@ -8,6 +8,7 @@ module Types value 'text', description: "Text file." value 'image', description: "An image." + value 'file', description: "Unknown file type." end end end diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb index 8dd2a4467d6..cc41169bcda 100644 --- a/app/graphql/types/packages/package_base_type.rb +++ b/app/graphql/types/packages/package_base_type.rb @@ -6,7 +6,7 @@ module Types graphql_name 'PackageBase' description 'Represents a package in the Package Registry' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType authorize :read_package diff --git a/app/graphql/types/permission_types/group.rb b/app/graphql/types/permission_types/group.rb index 6a1031e2532..fbd1140babc 100644 --- a/app/graphql/types/permission_types/group.rb +++ b/app/graphql/types/permission_types/group.rb @@ -5,7 +5,7 @@ module Types class Group < BasePermissionType graphql_name 'GroupPermissions' - abilities :read_group, :create_projects + abilities :read_group, :create_projects, :create_custom_emoji end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 992663b4d98..2738d4da6c2 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -4,618 +4,625 @@ module Types class ProjectType < BaseObject graphql_name 'Project' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType authorize :read_project expose_permissions Types::PermissionTypes::Project field :id, GraphQL::Types::ID, - null: false, - description: 'ID of the project.' + null: false, + description: 'ID of the project.' field :ci_config_path_or_default, GraphQL::Types::String, - null: false, - description: 'Path of the CI configuration file.' + null: false, + description: 'Path of the CI configuration file.' field :ci_config_variables, [Types::Ci::ConfigVariableType], - null: true, - calls_gitaly: true, - authorize: :create_pipeline, - alpha: { milestone: '15.3' }, - description: 'CI/CD config variable.' do - argument :ref, GraphQL::Types::String, - required: true, - description: 'Ref.' - end + null: true, + calls_gitaly: true, + authorize: :create_pipeline, + alpha: { milestone: '15.3' }, + description: 'CI/CD config variable.' do + argument :ref, GraphQL::Types::String, + required: true, + description: 'Ref.' + end field :full_path, GraphQL::Types::ID, - null: false, - description: 'Full path of the project.' + null: false, + description: 'Full path of the project.' field :path, GraphQL::Types::String, - null: false, - description: 'Path of the project.' + null: false, + description: 'Path of the project.' field :incident_management_timeline_event_tags, [Types::IncidentManagement::TimelineEventTagType], - null: true, - description: 'Timeline event tags for the project.' + null: true, + description: 'Timeline event tags for the project.' field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, - null: true, - calls_gitaly: true, - description: 'SAST CI configuration for the project.' + null: true, + calls_gitaly: true, + description: 'SAST CI configuration for the project.' field :name, GraphQL::Types::String, - null: false, - description: 'Name of the project (without namespace).' + null: false, + description: 'Name of the project (without namespace).' field :name_with_namespace, GraphQL::Types::String, - null: false, - description: 'Full name of the project with its namespace.' + null: false, + description: 'Full name of the project with its namespace.' field :description, GraphQL::Types::String, - null: true, - description: 'Short description of the project.' + null: true, + description: 'Short description of the project.' field :tag_list, GraphQL::Types::String, - null: true, - deprecated: { reason: 'Use `topics`', milestone: '13.12' }, - description: 'List of project topics (not Git tags).', - method: :topic_list + null: true, + deprecated: { reason: 'Use `topics`', milestone: '13.12' }, + description: 'List of project topics (not Git tags).', + method: :topic_list field :topics, [GraphQL::Types::String], - null: true, - description: 'List of project topics.', - method: :topic_list + null: true, + description: 'List of project topics.', + method: :topic_list field :http_url_to_repo, GraphQL::Types::String, - null: true, - description: 'URL to connect to the project via HTTPS.' + null: true, + description: 'URL to connect to the project via HTTPS.' field :ssh_url_to_repo, GraphQL::Types::String, - null: true, - description: 'URL to connect to the project via SSH.' + null: true, + description: 'URL to connect to the project via SSH.' field :web_url, GraphQL::Types::String, - null: true, - description: 'Web URL of the project.' + null: true, + description: 'Web URL of the project.' field :forks_count, GraphQL::Types::Int, - null: false, - calls_gitaly: true, # 4 times - description: 'Number of times the project has been forked.' + null: false, + calls_gitaly: true, # 4 times + description: 'Number of times the project has been forked.' field :star_count, GraphQL::Types::Int, - null: false, - description: 'Number of times the project has been starred.' + null: false, + description: 'Number of times the project has been starred.' field :created_at, Types::TimeType, - null: true, - description: 'Timestamp of the project creation.' + null: true, + description: 'Timestamp of the project creation.' field :last_activity_at, Types::TimeType, - null: true, - description: 'Timestamp of the project last activity.' + null: true, + description: 'Timestamp of the project last activity.' field :archived, GraphQL::Types::Boolean, - null: true, - description: 'Indicates the archived status of the project.' + null: true, + description: 'Indicates the archived status of the project.' field :visibility, GraphQL::Types::String, - null: true, - description: 'Visibility of the project.' + null: true, + description: 'Visibility of the project.' field :lfs_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if the project has Large File Storage (LFS) enabled.' + null: true, + description: 'Indicates if the project has Large File Storage (LFS) enabled.' field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if no merge commits should be created and all merges should instead be ' \ - 'fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + null: true, + description: 'Indicates if no merge commits should be created and all merges should instead be ' \ + 'fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' field :shared_runners_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if shared runners are enabled for the project.' + null: true, + description: 'Indicates if shared runners are enabled for the project.' field :service_desk_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if the project has Service Desk enabled.' + null: true, + description: 'Indicates if the project has Service Desk enabled.' field :service_desk_address, GraphQL::Types::String, - null: true, - description: 'E-mail address of the Service Desk.' + null: true, + description: 'E-mail address of the Service Desk.' field :avatar_url, GraphQL::Types::String, - null: true, - calls_gitaly: true, - description: 'URL to avatar image file of the project.' + null: true, + calls_gitaly: true, + description: 'URL to avatar image file of the project.' field :jobs_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' + null: true, + description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' field :is_catalog_resource, GraphQL::Types::Boolean, - alpha: { milestone: '15.11' }, - null: true, - description: 'Indicates if a project is a catalog resource.' + alpha: { milestone: '15.11' }, + null: true, + description: 'Indicates if a project is a catalog resource.' field :public_jobs, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if there is public access to pipelines and job details of the project, ' \ - 'including output logs and artifacts.', - method: :public_builds + null: true, + description: 'Indicates if there is public access to pipelines and job details of the project, ' \ + 'including output logs and artifacts.', + method: :public_builds field :open_issues_count, GraphQL::Types::Int, - null: true, - description: 'Number of open issues for the project.' + null: true, + description: 'Number of open issues for the project.' field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, - null: true, - description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of ' \ - 'the project can also be merged with skipped jobs.' + null: true, + description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of ' \ + 'the project can also be merged with skipped jobs.' field :autoclose_referenced_issues, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if issues referenced by merge requests and commits within the default branch ' \ - 'are closed automatically.' + null: true, + description: 'Indicates if issues referenced by merge requests and commits within the default branch ' \ + 'are closed automatically.' field :import_status, GraphQL::Types::String, - null: true, - description: 'Status of import background job of the project.' + null: true, + description: 'Status of import background job of the project.' field :jira_import_status, GraphQL::Types::String, - null: true, - description: 'Status of Jira import background job of the project.' + null: true, + description: 'Status of Jira import background job of the project.' field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' + null: true, + description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if merge requests of the project can only be merged with successful jobs.' + null: true, + description: 'Indicates if merge requests of the project can only be merged with successful jobs.' field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if a link to create or view a merge request should display after a push to Git ' \ - 'repositories of the project from the command line.' + null: true, + description: 'Indicates if a link to create or view a merge request should display after a push to Git ' \ + 'repositories of the project from the command line.' field :remove_source_branch_after_merge, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if `Delete source branch` option should be enabled by default for all ' \ - 'new merge requests of the project.' + null: true, + description: 'Indicates if `Delete source branch` option should be enabled by default for all ' \ + 'new merge requests of the project.' field :request_access_enabled, GraphQL::Types::Boolean, - null: true, - description: 'Indicates if users can request member access to the project.' + null: true, + description: 'Indicates if users can request member access to the project.' field :squash_read_only, GraphQL::Types::Boolean, - null: false, - description: 'Indicates if `squashReadOnly` is enabled.', - method: :squash_readonly? + null: false, + description: 'Indicates if `squashReadOnly` is enabled.', + method: :squash_readonly? field :suggestion_commit_message, GraphQL::Types::String, - null: true, - description: 'Commit message used to apply merge request suggestions.' + null: true, + description: 'Commit message used to apply merge request suggestions.' # No, the quotes are not a typo. Used to get around circular dependencies. # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675 field :group, 'Types::GroupType', - null: true, - description: 'Group of the project.' + null: true, + description: 'Group of the project.' field :namespace, Types::NamespaceType, - null: true, - description: 'Namespace of the project.' + null: true, + description: 'Namespace of the project.' field :statistics, Types::ProjectStatisticsType, - null: true, - description: 'Statistics of the project.' + null: true, + description: 'Statistics of the project.' field :statistics_details_paths, Types::ProjectStatisticsRedirectType, - null: true, - description: 'Redirects for Statistics of the project.', - calls_gitaly: true + null: true, + description: 'Redirects for Statistics of the project.', + calls_gitaly: true field :repository, Types::RepositoryType, - null: true, - description: 'Git repository of the project.' + null: true, + description: 'Git repository of the project.' field :merge_requests, - Types::MergeRequestType.connection_type, - null: true, - description: 'Merge requests of the project.', - extras: [:lookahead], - resolver: Resolvers::ProjectMergeRequestsResolver + Types::MergeRequestType.connection_type, + null: true, + description: 'Merge requests of the project.', + extras: [:lookahead], + resolver: Resolvers::ProjectMergeRequestsResolver field :merge_request, - Types::MergeRequestType, - null: true, - description: 'A single merge request of the project.', - resolver: Resolvers::MergeRequestsResolver.single + Types::MergeRequestType, + null: true, + description: 'A single merge request of the project.', + resolver: Resolvers::MergeRequestsResolver.single field :issues, - Types::IssueType.connection_type, - null: true, - description: 'Issues of the project.', - resolver: Resolvers::ProjectIssuesResolver + Types::IssueType.connection_type, + null: true, + description: 'Issues of the project.', + resolver: Resolvers::ProjectIssuesResolver field :work_items, - Types::WorkItemType.connection_type, - null: true, - alpha: { milestone: '15.1' }, - description: 'Work items of the project.', - extras: [:lookahead], - resolver: Resolvers::WorkItemsResolver + Types::WorkItemType.connection_type, + null: true, + alpha: { milestone: '15.1' }, + description: 'Work items of the project.', + extras: [:lookahead], + resolver: Resolvers::WorkItemsResolver field :issue_status_counts, - Types::IssueStatusCountsType, - null: true, - description: 'Counts of issues by status for the project.', - resolver: Resolvers::IssueStatusCountsResolver + Types::IssueStatusCountsType, + null: true, + description: 'Counts of issues by status for the project.', + resolver: Resolvers::IssueStatusCountsResolver field :milestones, Types::MilestoneType.connection_type, - null: true, - description: 'Milestones of the project.', - resolver: Resolvers::ProjectMilestonesResolver + null: true, + description: 'Milestones of the project.', + resolver: Resolvers::ProjectMilestonesResolver field :project_members, - description: 'Members of the project.', - resolver: Resolvers::ProjectMembersResolver + description: 'Members of the project.', + resolver: Resolvers::ProjectMembersResolver field :environments, - Types::EnvironmentType.connection_type, - null: true, - description: 'Environments of the project. ' \ - 'This field can only be resolved for one project in any single request.', - resolver: Resolvers::EnvironmentsResolver do - extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 - end + Types::EnvironmentType.connection_type, + null: true, + description: 'Environments of the project. ' \ + 'This field can only be resolved for one project in any single request.', + resolver: Resolvers::EnvironmentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end field :environment, - Types::EnvironmentType, - null: true, - description: 'A single environment of the project.', - resolver: Resolvers::EnvironmentsResolver.single + Types::EnvironmentType, + null: true, + description: 'A single environment of the project.', + resolver: Resolvers::EnvironmentsResolver.single field :nested_environments, - Types::NestedEnvironmentType.connection_type, - null: true, - calls_gitaly: true, - description: 'Environments for this project with nested folders, ' \ - 'can only be resolved for one project in any single request', - resolver: Resolvers::Environments::NestedEnvironmentsResolver do - extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 - end + Types::NestedEnvironmentType.connection_type, + null: true, + calls_gitaly: true, + description: 'Environments for this project with nested folders, ' \ + 'can only be resolved for one project in any single request', + resolver: Resolvers::Environments::NestedEnvironmentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end field :deployment, - Types::DeploymentType, - null: true, - description: 'Details of the deployment of the project.', - resolver: Resolvers::DeploymentResolver.single + Types::DeploymentType, + null: true, + description: 'Details of the deployment of the project.', + resolver: Resolvers::DeploymentResolver.single field :issue, - Types::IssueType, - null: true, - description: 'A single issue of the project.', - resolver: Resolvers::ProjectIssuesResolver.single + Types::IssueType, + null: true, + description: 'A single issue of the project.', + resolver: Resolvers::ProjectIssuesResolver.single field :packages, - description: 'Packages of the project.', - resolver: Resolvers::ProjectPackagesResolver + description: 'Packages of the project.', + resolver: Resolvers::ProjectPackagesResolver field :packages_cleanup_policy, - Types::Packages::Cleanup::PolicyType, - null: true, - description: 'Packages cleanup policy for the project.' + Types::Packages::Cleanup::PolicyType, + null: true, + description: 'Packages cleanup policy for the project.' field :jobs, - type: Types::Ci::JobType.connection_type, - null: true, - authorize: :read_build, - description: 'Jobs of a project. This field can only be resolved for one project in any single request.', - resolver: Resolvers::ProjectJobsResolver + type: Types::Ci::JobType.connection_type, + null: true, + authorize: :read_build, + description: 'Jobs of a project. This field can only be resolved for one project in any single request.', + resolver: Resolvers::ProjectJobsResolver field :job, - type: Types::Ci::JobType, - null: true, - authorize: :read_build, - description: 'One job belonging to the project, selected by ID.' do - argument :id, Types::GlobalIDType[::CommitStatus], - required: true, - description: 'ID of the job.' - end + type: Types::Ci::JobType, + null: true, + authorize: :read_build, + description: 'One job belonging to the project, selected by ID.' do + argument :id, Types::GlobalIDType[::CommitStatus], + required: true, + description: 'ID of the job.' + end field :pipelines, - null: true, - description: 'Build pipelines of the project.', - extras: [:lookahead], - resolver: Resolvers::ProjectPipelinesResolver + null: true, + description: 'Build pipelines of the project.', + extras: [:lookahead], + resolver: Resolvers::ProjectPipelinesResolver field :pipeline_schedules, - type: Types::Ci::PipelineScheduleType.connection_type, - null: true, - description: 'Pipeline schedules of the project. This field can only be resolved for one project per request.', - resolver: Resolvers::ProjectPipelineSchedulesResolver + type: Types::Ci::PipelineScheduleType.connection_type, + null: true, + description: 'Pipeline schedules of the project. This field can only be resolved for one project per request.', + resolver: Resolvers::ProjectPipelineSchedulesResolver + + field :pipeline_triggers, + Types::Ci::PipelineTriggerType.connection_type, + null: true, + description: 'List of pipeline trigger tokens.', + resolver: Resolvers::Ci::PipelineTriggersResolver, + alpha: { milestone: '16.3' } field :pipeline, Types::Ci::PipelineType, - null: true, - description: 'Build pipeline of the project.', - extras: [:lookahead], - resolver: Resolvers::ProjectPipelineResolver + null: true, + description: 'Build pipeline of the project.', + extras: [:lookahead], + resolver: Resolvers::ProjectPipelineResolver field :pipeline_counts, Types::Ci::PipelineCountsType, - null: true, - description: 'Build pipeline counts of the project.', - resolver: Resolvers::Ci::ProjectPipelineCountsResolver + null: true, + description: 'Build pipeline counts of the project.', + resolver: Resolvers::Ci::ProjectPipelineCountsResolver field :ci_variables, Types::Ci::ProjectVariableType.connection_type, - null: true, - description: "List of the project's CI/CD variables.", - authorize: :admin_build, - resolver: Resolvers::Ci::VariablesResolver + null: true, + description: "List of the project's CI/CD variables.", + authorize: :admin_build, + resolver: Resolvers::Ci::VariablesResolver field :inherited_ci_variables, Types::Ci::InheritedCiVariableType.connection_type, - null: true, - description: "List of CI/CD variables the project inherited from its parent group and ancestors.", - authorize: :admin_build, - resolver: Resolvers::Ci::InheritedVariablesResolver + null: true, + description: "List of CI/CD variables the project inherited from its parent group and ancestors.", + authorize: :admin_build, + resolver: Resolvers::Ci::InheritedVariablesResolver field :ci_cd_settings, Types::Ci::CiCdSettingType, - null: true, - description: 'CI/CD settings for the project.' + null: true, + description: 'CI/CD settings for the project.' field :sentry_detailed_error, Types::ErrorTracking::SentryDetailedErrorType, - null: true, - description: 'Detailed version of a Sentry error on the project.', - resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + null: true, + description: 'Detailed version of a Sentry error on the project.', + resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver field :grafana_integration, Types::GrafanaIntegrationType, - null: true, - description: 'Grafana integration details for the project.', - resolver: Resolvers::Projects::GrafanaIntegrationResolver + null: true, + description: 'Grafana integration details for the project.', + resolver: Resolvers::Projects::GrafanaIntegrationResolver field :snippets, Types::SnippetType.connection_type, - null: true, - description: 'Snippets of the project.', - resolver: Resolvers::Projects::SnippetsResolver + null: true, + description: 'Snippets of the project.', + resolver: Resolvers::Projects::SnippetsResolver field :sentry_errors, Types::ErrorTracking::SentryErrorCollectionType, - null: true, - description: 'Paginated collection of Sentry errors on the project.', - resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver + null: true, + description: 'Paginated collection of Sentry errors on the project.', + resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver field :boards, Types::BoardType.connection_type, - null: true, - description: 'Boards of the project.', - max_page_size: 2000, - resolver: Resolvers::BoardsResolver + null: true, + description: 'Boards of the project.', + max_page_size: 2000, + resolver: Resolvers::BoardsResolver field :recent_issue_boards, Types::BoardType.connection_type, - null: true, - description: 'List of recently visited boards of the project. Maximum size is 4.', - resolver: Resolvers::RecentBoardsResolver + null: true, + description: 'List of recently visited boards of the project. Maximum size is 4.', + resolver: Resolvers::RecentBoardsResolver field :board, Types::BoardType, - null: true, - description: 'A single board of the project.', - resolver: Resolvers::BoardResolver + null: true, + description: 'A single board of the project.', + resolver: Resolvers::BoardResolver field :jira_imports, Types::JiraImportType.connection_type, - null: true, - description: 'Jira imports into the project.' + null: true, + description: 'Jira imports into the project.' field :services, Types::Projects::ServiceType.connection_type, - null: true, - deprecated: { - reason: 'This will be renamed to `Project.integrations`', - milestone: '15.9' - }, - description: 'Project services.', - resolver: Resolvers::Projects::ServicesResolver + null: true, + deprecated: { + reason: 'This will be renamed to `Project.integrations`', + milestone: '15.9' + }, + description: 'Project services.', + resolver: Resolvers::Projects::ServicesResolver field :alert_management_alerts, Types::AlertManagement::AlertType.connection_type, - null: true, - description: 'Alert Management alerts of the project.', - extras: [:lookahead], - resolver: Resolvers::AlertManagement::AlertResolver + null: true, + description: 'Alert Management alerts of the project.', + extras: [:lookahead], + resolver: Resolvers::AlertManagement::AlertResolver field :alert_management_alert, Types::AlertManagement::AlertType, - null: true, - description: 'A single Alert Management alert of the project.', - resolver: Resolvers::AlertManagement::AlertResolver.single + null: true, + description: 'A single Alert Management alert of the project.', + resolver: Resolvers::AlertManagement::AlertResolver.single field :alert_management_alert_status_counts, Types::AlertManagement::AlertStatusCountsType, - null: true, - description: 'Counts of alerts by status for the project.', - resolver: Resolvers::AlertManagement::AlertStatusCountsResolver + null: true, + description: 'Counts of alerts by status for the project.', + resolver: Resolvers::AlertManagement::AlertStatusCountsResolver field :alert_management_integrations, Types::AlertManagement::IntegrationType.connection_type, - null: true, - description: 'Integrations which can receive alerts for the project.', - resolver: Resolvers::AlertManagement::IntegrationsResolver + null: true, + description: 'Integrations which can receive alerts for the project.', + resolver: Resolvers::AlertManagement::IntegrationsResolver field :alert_management_http_integrations, Types::AlertManagement::HttpIntegrationType.connection_type, - null: true, - description: 'HTTP Integrations which can receive alerts for the project.', - resolver: Resolvers::AlertManagement::HttpIntegrationsResolver + null: true, + description: 'HTTP Integrations which can receive alerts for the project.', + resolver: Resolvers::AlertManagement::HttpIntegrationsResolver field :incident_management_timeline_events, Types::IncidentManagement::TimelineEventType.connection_type, - null: true, - description: 'Incident Management Timeline events associated with the incident.', - extras: [:lookahead], - resolver: Resolvers::IncidentManagement::TimelineEventsResolver + null: true, + description: 'Incident Management Timeline events associated with the incident.', + extras: [:lookahead], + resolver: Resolvers::IncidentManagement::TimelineEventsResolver field :incident_management_timeline_event, Types::IncidentManagement::TimelineEventType, - null: true, - description: 'Incident Management Timeline event associated with the incident.', - resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single + null: true, + description: 'Incident Management Timeline event associated with the incident.', + resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single field :releases, Types::ReleaseType.connection_type, - null: true, - description: 'Releases of the project.', - resolver: Resolvers::ReleasesResolver + null: true, + description: 'Releases of the project.', + resolver: Resolvers::ReleasesResolver field :release, Types::ReleaseType, - null: true, - description: 'A single release of the project.', - resolver: Resolvers::ReleasesResolver.single, - authorize: :read_release + null: true, + description: 'A single release of the project.', + resolver: Resolvers::ReleasesResolver.single, + authorize: :read_release field :container_expiration_policy, Types::ContainerExpirationPolicyType, - null: true, - description: 'Container expiration policy of the project.' + null: true, + description: 'Container expiration policy of the project.' field :container_repositories, Types::ContainerRepositoryType.connection_type, - null: true, - description: 'Container repositories of the project.', - resolver: Resolvers::ContainerRepositoriesResolver + null: true, + description: 'Container repositories of the project.', + resolver: Resolvers::ContainerRepositoriesResolver field :container_repositories_count, GraphQL::Types::Int, - null: false, - description: 'Number of container repositories in the project.' + null: false, + description: 'Number of container repositories in the project.' field :label, Types::LabelType, - null: true, - description: 'Label available on this project.' do - argument :title, GraphQL::Types::String, - required: true, - description: 'Title of the label.' - end + null: true, + description: 'Label available on this project.' do + argument :title, GraphQL::Types::String, + required: true, + description: 'Title of the label.' + end field :terraform_state, Types::Terraform::StateType, - null: true, - description: 'Find a single Terraform state by name.', - resolver: Resolvers::Terraform::StatesResolver.single + null: true, + description: 'Find a single Terraform state by name.', + resolver: Resolvers::Terraform::StatesResolver.single field :terraform_states, Types::Terraform::StateType.connection_type, - null: true, - description: 'Terraform states associated with the project.', - resolver: Resolvers::Terraform::StatesResolver + null: true, + description: 'Terraform states associated with the project.', + resolver: Resolvers::Terraform::StatesResolver field :pipeline_analytics, Types::Ci::AnalyticsType, - null: true, - description: 'Pipeline analytics.', - resolver: Resolvers::ProjectPipelineStatisticsResolver + null: true, + description: 'Pipeline analytics.', + resolver: Resolvers::ProjectPipelineStatisticsResolver field :ci_template, Types::Ci::TemplateType, - null: true, - description: 'Find a single CI/CD template by name.', - resolver: Resolvers::Ci::TemplateResolver + null: true, + description: 'Find a single CI/CD template by name.', + resolver: Resolvers::Ci::TemplateResolver field :ci_job_token_scope, Types::Ci::JobTokenScopeType, - null: true, - description: 'The CI Job Tokens scope of access.', - resolver: Resolvers::Ci::JobTokenScopeResolver + null: true, + description: 'The CI Job Tokens scope of access.', + resolver: Resolvers::Ci::JobTokenScopeResolver field :timelogs, Types::TimelogType.connection_type, - null: true, - description: 'Time logged on issues and merge requests in the project.', - extras: [:lookahead], - complexity: 5, - resolver: ::Resolvers::TimelogResolver + null: true, + description: 'Time logged on issues and merge requests in the project.', + extras: [:lookahead], + complexity: 5, + resolver: ::Resolvers::TimelogResolver field :agent_configurations, ::Types::Kas::AgentConfigurationType.connection_type, - null: true, - description: 'Agent configurations defined by the project', - resolver: ::Resolvers::Kas::AgentConfigurationsResolver + null: true, + description: 'Agent configurations defined by the project', + resolver: ::Resolvers::Kas::AgentConfigurationsResolver field :cluster_agent, ::Types::Clusters::AgentType, - null: true, - description: 'Find a single cluster agent by name.', - resolver: ::Resolvers::Clusters::AgentsResolver.single + null: true, + description: 'Find a single cluster agent by name.', + resolver: ::Resolvers::Clusters::AgentsResolver.single field :cluster_agents, ::Types::Clusters::AgentType.connection_type, - extras: [:lookahead], - null: true, - description: 'Cluster agents associated with the project.', - resolver: ::Resolvers::Clusters::AgentsResolver + extras: [:lookahead], + null: true, + description: 'Cluster agents associated with the project.', + resolver: ::Resolvers::Clusters::AgentsResolver field :ci_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::CiAccessType.connection_type, - null: true, - description: 'Authorized cluster agents for the project through ci_access keyword.', - resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver, - authorize: :read_cluster_agent + null: true, + description: 'Authorized cluster agents for the project through ci_access keyword.', + resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver, + authorize: :read_cluster_agent field :user_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::UserAccessType.connection_type, - null: true, - description: 'Authorized cluster agents for the project through user_access keyword.', - resolver: ::Resolvers::Clusters::Agents::Authorizations::UserAccessResolver, - authorize: :read_cluster_agent + null: true, + description: 'Authorized cluster agents for the project through user_access keyword.', + resolver: ::Resolvers::Clusters::Agents::Authorizations::UserAccessResolver, + authorize: :read_cluster_agent field :merge_commit_template, GraphQL::Types::String, - null: true, - description: 'Template used to create merge commit message in merge requests.' + null: true, + description: 'Template used to create merge commit message in merge requests.' field :squash_commit_template, GraphQL::Types::String, - null: true, - description: 'Template used to create squash commit message in merge requests.' + null: true, + description: 'Template used to create squash commit message in merge requests.' field :labels, Types::LabelType.connection_type, - null: true, - description: 'Labels available on this project.', - resolver: Resolvers::LabelsResolver + null: true, + description: 'Labels available on this project.', + resolver: Resolvers::LabelsResolver field :work_item_types, Types::WorkItems::TypeType.connection_type, - resolver: Resolvers::WorkItems::TypesResolver, - description: 'Work item types available to the project.' + resolver: Resolvers::WorkItems::TypesResolver, + description: 'Work item types available to the project.' field :timelog_categories, Types::TimeTracking::TimelogCategoryType.connection_type, - null: true, - description: "Timelog categories for the project.", - alpha: { milestone: '15.3' } + null: true, + description: "Timelog categories for the project.", + alpha: { milestone: '15.3' } field :fork_targets, Types::NamespaceType.connection_type, - resolver: Resolvers::Projects::ForkTargetsResolver, - description: 'Namespaces in which the current user can fork the project into.' + resolver: Resolvers::Projects::ForkTargetsResolver, + description: 'Namespaces in which the current user can fork the project into.' field :fork_details, Types::Projects::ForkDetailsType, - calls_gitaly: true, - alpha: { milestone: '15.7' }, - authorize: :read_code, - resolver: Resolvers::Projects::ForkDetailsResolver, - description: 'Details of the fork project compared to its upstream project.' + calls_gitaly: true, + alpha: { milestone: '15.7' }, + authorize: :read_code, + resolver: Resolvers::Projects::ForkDetailsResolver, + description: 'Details of the fork project compared to its upstream project.' field :branch_rules, - Types::Projects::BranchRuleType.connection_type, - null: true, - description: "Branch rules configured for the project.", - resolver: Resolvers::Projects::BranchRulesResolver + Types::Projects::BranchRuleType.connection_type, + null: true, + description: "Branch rules configured for the project.", + resolver: Resolvers::Projects::BranchRulesResolver field :languages, [Types::Projects::RepositoryLanguageType], - null: true, - description: "Programming languages used in the project.", - calls_gitaly: true + null: true, + description: "Programming languages used in the project.", + calls_gitaly: true field :runners, Types::Ci::RunnerType.connection_type, - null: true, - resolver: ::Resolvers::Ci::ProjectRunnersResolver, - description: "Find runners visible to the current user." + null: true, + resolver: ::Resolvers::Ci::ProjectRunnersResolver, + description: "Find runners visible to the current user." field :data_transfer, Types::DataTransfer::ProjectDataTransferType, - null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682 - resolver: Resolvers::DataTransfer::ProjectDataTransferResolver, - description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' + null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682 + resolver: Resolvers::DataTransfer::ProjectDataTransferResolver, + description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' field :visible_forks, Types::ProjectType.connection_type, - null: true, - alpha: { milestone: '15.10' }, - description: "Visible forks of the project." do - argument :minimum_access_level, - type: ::Types::AccessLevelEnum, - required: false, - description: 'Minimum access level.' - end + null: true, + alpha: { milestone: '15.10' }, + description: "Visible forks of the project." do + argument :minimum_access_level, + type: ::Types::AccessLevelEnum, + required: false, + description: 'Minimum access level.' + end field :flow_metrics, - ::Types::Analytics::CycleAnalytics::FlowMetrics[:project], - null: true, - description: 'Flow metrics for value stream analytics.', - method: :project_namespace, - authorize: :read_cycle_analytics, - alpha: { milestone: '15.10' } + ::Types::Analytics::CycleAnalytics::FlowMetrics[:project], + null: true, + description: 'Flow metrics for value stream analytics.', + method: :project_namespace, + authorize: :read_cycle_analytics, + alpha: { milestone: '15.10' } field :commit_references, ::Types::CommitReferencesType, null: true, @@ -623,6 +630,11 @@ module Types alpha: { milestone: '16.0' }, description: "Get tag names containing a given commit." + field :autocomplete_users, + null: true, + resolver: Resolvers::AutocompleteUsersResolver, + description: 'Search users for autocompletion' + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end @@ -644,7 +656,7 @@ module Types container_registry: 'Container Registry is' }.each do |feature, name_string| field "#{feature}_enabled", GraphQL::Types::Boolean, null: true, - description: "Indicates if #{name_string} enabled for the current user" + description: "Indicates if #{name_string} enabled for the current user" define_method "#{feature}_enabled" do object.feature_available?(feature, context[:current_user]) @@ -707,7 +719,7 @@ module Types if project.repository.empty? raise Gitlab::Graphql::Errors::MutationError, - Gitlab::Utils::ErrorMessage.to_user_facing(_(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe) + _(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe end ::Security::CiConfiguration::SastParserService.new(object).configuration @@ -754,11 +766,11 @@ module Types def add_file_docs_link ActionController::Base.helpers.link_to _('add at least one file to the repository'), - Rails.application.routes.url_helpers.help_page_url( - 'user/project/repository/index.md', - anchor: 'add-files-to-a-repository'), - target: '_blank', - rel: 'noopener noreferrer' + Rails.application.routes.url_helpers.help_page_url( + 'user/project/repository/index.md', + anchor: 'add-files-to-a-repository'), + target: '_blank', + rel: 'noopener noreferrer' end end end diff --git a/app/graphql/types/projects/services/base_service_type.rb b/app/graphql/types/projects/services/base_service_type.rb index 9a48aafa5a8..c77dc5c8539 100644 --- a/app/graphql/types/projects/services/base_service_type.rb +++ b/app/graphql/types/projects/services/base_service_type.rb @@ -7,7 +7,7 @@ module Types class BaseServiceType < BaseObject graphql_name 'BaseService' - implements(Types::Projects::ServiceType) + implements Types::Projects::ServiceType authorize :admin_project end diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb index ac274d7f890..a774d381e2b 100644 --- a/app/graphql/types/projects/services/jira_service_type.rb +++ b/app/graphql/types/projects/services/jira_service_type.rb @@ -7,7 +7,7 @@ module Types class JiraServiceType < BaseObject graphql_name 'JiraService' - implements(Types::Projects::ServiceType) + implements Types::Projects::ServiceType authorize :admin_project diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index b26e447f622..38b8973034d 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -171,6 +171,18 @@ module Types description: 'Definitions for all audit events available on the instance.', resolver: Resolvers::AuditEvents::AuditEventDefinitionsResolver + field :abuse_report, ::Types::AbuseReportType, + null: true, + alpha: { milestone: '16.3' }, + description: 'Find an abuse report.', + resolver: Resolvers::AbuseReportResolver + + field :abuse_report_labels, ::Types::LabelType.connection_type, + null: true, + alpha: { milestone: '16.3' }, + description: 'Abuse report labels.', + resolver: Resolvers::AbuseReportLabelsResolver + def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index 8516256b433..0bf723bcb1b 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -5,7 +5,7 @@ module Types graphql_name 'Release' description 'Represents a release' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType authorize :read_release diff --git a/app/graphql/types/saved_reply_type.rb b/app/graphql/types/saved_reply_type.rb index 8c9f3d19810..74b3796ef8a 100644 --- a/app/graphql/types/saved_reply_type.rb +++ b/app/graphql/types/saved_reply_type.rb @@ -4,7 +4,7 @@ module Types class SavedReplyType < BaseObject graphql_name 'SavedReply' - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType authorize :read_saved_replies diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 5ee0500b1e0..6e6d0edbe15 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -5,7 +5,7 @@ module Types graphql_name 'Snippet' description 'Represents a snippet entry' - implements(Types::Notes::NoteableInterface) + implements Types::Notes::NoteableInterface present_using SnippetPresenter diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb index bb4a0a64de8..2d1993225d1 100644 --- a/app/graphql/types/snippets/blob_type.rb +++ b/app/graphql/types/snippets/blob_type.rb @@ -8,7 +8,7 @@ module Types description 'Represents the snippet blob' present_using SnippetBlobPresenter - connection_type_class(Types::Snippets::BlobConnectionType) + connection_type_class Types::Snippets::BlobConnectionType field :rich_data, GraphQL::Types::String, description: 'Blob highlighted data.', diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb index be17fc41c2c..0870194a934 100644 --- a/app/graphql/types/terraform/state_type.rb +++ b/app/graphql/types/terraform/state_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_terraform_state - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType field :id, GraphQL::Types::ID, null: false, diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb index 88baca028ef..2adf2847221 100644 --- a/app/graphql/types/timelog_type.rb +++ b/app/graphql/types/timelog_type.rb @@ -4,7 +4,7 @@ module Types class TimelogType < BaseObject graphql_name 'Timelog' - connection_type_class(Types::TimeTracking::TimelogConnectionType) + connection_type_class Types::TimeTracking::TimelogConnectionType authorize :read_issuable diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb index fda96796c0f..45b83ea1d64 100644 --- a/app/graphql/types/todo_action_enum.rb +++ b/app/graphql/types/todo_action_enum.rb @@ -12,5 +12,6 @@ module Types value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.' value 'review_requested', value: 9, description: 'Review was requested from the user.' value 'member_access_requested', value: 10, description: 'Group or project access requested from the user.' + value 'review_submitted', value: 11, description: 'Merge request authored by the user received a review.' end end diff --git a/app/graphql/types/users/autocompleted_user_type.rb b/app/graphql/types/users/autocompleted_user_type.rb new file mode 100644 index 00000000000..8a70f398954 --- /dev/null +++ b/app/graphql/types/users/autocompleted_user_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module Users + class AutocompletedUserType < ::Types::UserType + graphql_name 'AutocompletedUser' + + authorize :read_user + + field :merge_request_interaction, Types::UserMergeRequestInteractionType, + null: true, + description: 'Merge request state related to the user.' do + argument :id, ::Types::GlobalIDType[::MergeRequest], required: true, + description: 'Global ID of the merge request.' + end + + def merge_request_interaction(id: nil) + Gitlab::Graphql::Lazy.with_value(GitlabSchema.object_from_id(id, expected_class: ::MergeRequest)) do |mr| + ::Users::MergeRequestInteraction.new(user: object.user, merge_request: mr) if mr + end + end + end + end +end diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index 1e58781dbb9..05798ba3d2f 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -4,7 +4,7 @@ module Types class WorkItemType < BaseObject graphql_name 'WorkItem' - implements(Types::TodoableInterface) + implements Types::TodoableInterface authorize :read_work_item diff --git a/app/graphql/types/work_items/linked_item_type.rb b/app/graphql/types/work_items/linked_item_type.rb new file mode 100644 index 00000000000..a4dbeed7480 --- /dev/null +++ b/app/graphql/types/work_items/linked_item_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module WorkItems + # rubocop:disable Graphql/AuthorizeTypes + class LinkedItemType < BaseObject + graphql_name 'LinkedWorkItemType' + + field :link_created_at, Types::TimeType, + description: 'Timestamp the link was created.', null: false + field :link_id, ::Types::GlobalIDType[::WorkItems::RelatedWorkItemLink], + description: 'Global ID of the link.', null: false + field :link_type, GraphQL::Types::String, + description: 'Type of link.', null: false + field :link_updated_at, Types::TimeType, + description: 'Timestamp the link was updated.', null: false + field :work_item, Types::WorkItemType, + description: 'Linked work item.', null: false + end + # rubocop:enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/work_items/related_link_type_enum.rb b/app/graphql/types/work_items/related_link_type_enum.rb new file mode 100644 index 00000000000..d4bbc7cc404 --- /dev/null +++ b/app/graphql/types/work_items/related_link_type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class RelatedLinkTypeEnum < BaseEnum + graphql_name 'WorkItemRelatedLinkType' + description 'Values for work item link types' + + value 'RELATED', 'Related type.', value: 'relates_to' + end + end +end + +Types::WorkItems::RelatedLinkTypeEnum.prepend_mod_with('Types::WorkItems::RelatedLinkTypeEnum') diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index 53ea901ea10..9f4dbdd1038 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -21,7 +21,8 @@ module Types ::Types::WorkItems::Widgets::NotesType, ::Types::WorkItems::Widgets::NotificationsType, ::Types::WorkItems::Widgets::CurrentUserTodosType, - ::Types::WorkItems::Widgets::AwardEmojiType + ::Types::WorkItems::Widgets::AwardEmojiType, + ::Types::WorkItems::Widgets::LinkedItemsType ].freeze def self.ce_orphan_types @@ -53,6 +54,8 @@ module Types ::Types::WorkItems::Widgets::CurrentUserTodosType when ::WorkItems::Widgets::AwardEmoji ::Types::WorkItems::Widgets::AwardEmojiType + when ::WorkItems::Widgets::LinkedItems + ::Types::WorkItems::Widgets::LinkedItemsType else raise "Unknown GraphQL type for widget #{object}" end diff --git a/app/graphql/types/work_items/widgets/award_emoji_type.rb b/app/graphql/types/work_items/widgets/award_emoji_type.rb index 421bb8f0e98..eee04696df2 100644 --- a/app/graphql/types/work_items/widgets/award_emoji_type.rb +++ b/app/graphql/types/work_items/widgets/award_emoji_type.rb @@ -8,14 +8,14 @@ module Types # rubocop:disable Graphql/AuthorizeTypes class AwardEmojiType < BaseObject graphql_name 'WorkItemWidgetAwardEmoji' - description 'Represents the award emoji widget' + description 'Represents the emoji reactions widget' implements Types::WorkItems::WidgetInterface field :award_emoji, ::Types::AwardEmojis::AwardEmojiType.connection_type, null: true, - description: 'Award emoji on the work item.' + description: 'Emoji reactions on the work item.' field :downvotes, GraphQL::Types::Int, null: false, diff --git a/app/graphql/types/work_items/widgets/linked_items_type.rb b/app/graphql/types/work_items/widgets/linked_items_type.rb new file mode 100644 index 00000000000..fa51742b9c1 --- /dev/null +++ b/app/graphql/types/work_items/widgets/linked_items_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # rubocop:disable Graphql/AuthorizeTypes + class LinkedItemsType < BaseObject + graphql_name 'WorkItemWidgetLinkedItems' + description 'Represents the linked items widget' + + implements Types::WorkItems::WidgetInterface + + 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`' \ + 'if `linked_work_items` feature flag is disabled.', + resolver: Resolvers::WorkItems::LinkedItemsResolver + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end + +Types::WorkItems::Widgets::LinkedItemsType.prepend_mod diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb index 9ea07ba4e6e..1741d6a953a 100644 --- a/app/helpers/admin/application_settings/settings_helper.rb +++ b/app/helpers/admin/application_settings/settings_helper.rb @@ -15,66 +15,6 @@ module Admin def project_missing_pipeline_yaml?(project) project.repository&.gitlab_ci_yml.blank? end - - def code_suggestions_description - link_start = code_suggestions_link_start(code_suggestions_docs_url) - - # rubocop:disable Layout/LineLength - # rubocop:disable Style/FormatString - s_('CodeSuggestionsSM|Enable Code Suggestions for users of this instance. %{link_start}What are Code Suggestions?%{link_end}') - .html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - # rubocop:enable Style/FormatString - # rubocop:enable Layout/LineLength - end - - def code_suggestions_token_explanation - link_start = code_suggestions_link_start(code_suggestions_pat_docs_url) - - # rubocop:disable Layout/LineLength - # rubocop:disable Style/FormatString - s_('CodeSuggestionsSM|On GitLab.com, create a token. This token is required to use Code Suggestions on your self-managed instance. %{link_start}How do I create a token?%{link_end}') - .html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - # rubocop:enable Style/FormatString - # rubocop:enable Layout/LineLength - end - - def code_suggestions_agreement - terms_link_start = code_suggestions_link_start(code_suggestions_agreement_url) - ai_docs_link_start = code_suggestions_link_start(code_suggestions_ai_docs_url) - - # rubocop:disable Layout/LineLength - # rubocop:disable Style/FormatString - s_('CodeSuggestionsSM|By enabling this feature, you agree to the %{terms_link_start}GitLab Testing Agreement%{link_end} and acknowledge that GitLab will send data from the instance, including personal data, to our %{ai_docs_link_start}AI providers%{link_end} to provide this feature.') - .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe } - # rubocop:enable Style/FormatString - # rubocop:enable Layout/LineLength - end - - private - - # rubocop:disable Gitlab/DocUrl - # We want to link SaaS docs for flexibility for every URL related to Code Suggestions on Self Managed. - # We expect to update docs often during the Beta and we want to point user to the most up to date information. - def code_suggestions_docs_url - 'https://docs.gitlab.com/ee/user/project/repository/code_suggestions.html' - end - - def code_suggestions_agreement_url - 'https://about.gitlab.com/handbook/legal/testing-agreement/' - end - - def code_suggestions_ai_docs_url - 'https://docs.gitlab.com/ee/user/ai_features.html#third-party-services' - end - - def code_suggestions_pat_docs_url - 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token' - end - # rubocop:enable Gitlab/DocUrl - - def code_suggestions_link_start(url) - "<a href=\"#{url}\" target=\"_blank\" rel=\"noopener noreferrer\">".html_safe - end end end end diff --git a/app/helpers/admin/broadcast_messages_helper.rb b/app/helpers/admin/broadcast_messages_helper.rb new file mode 100644 index 00000000000..e087361d52e --- /dev/null +++ b/app/helpers/admin/broadcast_messages_helper.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Admin + module BroadcastMessagesHelper + include Gitlab::Utils::StrongMemoize + + def current_broadcast_banner_messages + System::BroadcastMessage.current_banner_messages( + current_path: request.path, + user_access_level: current_user_access_level_for_project_or_group + ).select do |message| + cookies["hide_broadcast_message_#{message.id}"].blank? + end + end + + def current_broadcast_notification_message + not_hidden_messages = System::BroadcastMessage.current_notification_messages( + current_path: request.path, + user_access_level: current_user_access_level_for_project_or_group + ).select do |message| + cookies["hide_broadcast_message_#{message.id}"].blank? + end + not_hidden_messages.last + end + + def broadcast_message(message, opts = {}) + return unless message.present? + + render "shared/broadcast_message", { message: message, **opts } + end + + def broadcast_message_status(broadcast_message) + if broadcast_message.active? + 'Active' + elsif broadcast_message.ended? + 'Expired' + else + 'Pending' + end + end + + def render_broadcast_message(broadcast_message) + if broadcast_message.notification? + Banzai.render_field_and_post_process(broadcast_message, :message, { + current_user: current_user, + skip_project_check: true, + broadcast_message_placeholders: true + }).html_safe + else + Banzai.render_field(broadcast_message, :message).html_safe + end + end + + def target_access_level_options + System::BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level| + [Gitlab::Access.human_access(access_level), access_level] + end + end + + def target_access_levels_display(access_levels) + access_levels.map do |access_level| + Gitlab::Access.human_access(access_level) + end.join(', ') + end + + def admin_broadcast_messages_data(broadcast_messages) + broadcast_messages.map do |message| + { + id: message.id, + status: broadcast_message_status(message), + message: message.message, + theme: message.theme, + broadcast_type: message.broadcast_type, + dismissable: message.dismissable, + starts_at: message.starts_at.iso8601, + ends_at: message.ends_at.iso8601, + target_roles: target_access_levels_display(message.target_access_levels), + target_path: message.target_path, + type: message.broadcast_type.capitalize, + edit_path: edit_admin_broadcast_message_path(message), + delete_path: "#{admin_broadcast_message_path(message)}.js" + } + end.to_json + end + + def broadcast_message_data(broadcast_message) + { + id: broadcast_message.id, + message: broadcast_message.message, + broadcast_type: broadcast_message.broadcast_type, + theme: broadcast_message.theme, + dismissable: broadcast_message.dismissable.to_s, + target_access_levels: broadcast_message.target_access_levels, + messages_path: admin_broadcast_messages_path, + preview_path: preview_admin_broadcast_messages_path, + target_path: broadcast_message.target_path, + starts_at: broadcast_message.starts_at.iso8601, + ends_at: broadcast_message.ends_at.iso8601, + target_access_level_options: target_access_level_options.to_json, + show_in_cli: broadcast_message.show_in_cli.to_s + } + end + + private + + def current_user_access_level_for_project_or_group + return unless current_user.present? + + strong_memoize(:current_user_access_level_for_project_or_group) do + case controller + when Projects::ApplicationController + next unless @project + + @project.team.max_member_access(current_user.id) + when Groups::ApplicationController + next unless @group + + @group.max_member_access_for_user(current_user) + end + end + end + end +end diff --git a/app/helpers/admin/deploy_key_helper.rb b/app/helpers/admin/deploy_key_helper.rb index caf3757a68e..8b23c3e1e13 100644 --- a/app/helpers/admin/deploy_key_helper.rb +++ b/app/helpers/admin/deploy_key_helper.rb @@ -7,7 +7,7 @@ module Admin edit_path: edit_admin_deploy_key_path(':id'), delete_path: admin_deploy_key_path(':id'), create_path: new_admin_deploy_key_path, - empty_state_svg_path: image_path('illustrations/empty-state/empty-deploy-keys-lg.svg') + empty_state_svg_path: image_path('illustrations/empty-state/empty-access-token-md.svg') } end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ce338a8afdc..2bf239979f7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -317,7 +317,7 @@ module ApplicationHelper class_names << 'with-performance-bar' if performance_bar_enabled? class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar class_names << system_message_class - class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com? + class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com? && !show_super_sidebar? class_names end @@ -466,6 +466,25 @@ module ApplicationHelper form_with(**args.merge({ builder: ::Gitlab::FormBuilders::GitlabUiFormBuilder }), &block) end + def hidden_resource_icon(resource, css_class: nil) + issuable_title = _('This %{issuable} is hidden because its author has been banned') + + case resource + when Issue + title = format(issuable_title, issuable: _('issue')) + when MergeRequest + title = format(issuable_title, issuable: _('merge request')) + when Project + title = _('This project is hidden because its creator has been banned') + end + + return unless title + + content_tag(:span, class: 'has-tooltip', title: title) do + sprite_icon('spam', css_class: ['gl-vertical-align-text-bottom', css_class].compact_blank.join(' ')) + end + end + private def browser_id diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index aa2466372e1..a45425474b5 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -218,7 +218,6 @@ module ApplicationSettingsHelper :admin_mode, :after_sign_out_path, :after_sign_up_text, - :ai_access_token, :akismet_api_key, :akismet_enabled, :allow_local_requests_from_hooks_and_services, @@ -242,6 +241,7 @@ module ApplicationSettingsHelper :default_artifacts_expire_in, :default_branch_name, :default_branch_protection, + :default_branch_protection_defaults, :default_ci_config_path, :default_group_visibility, :default_preferred_language, @@ -310,7 +310,6 @@ module ApplicationSettingsHelper :inactive_projects_delete_after_months, :inactive_projects_min_size_mb, :inactive_projects_send_warning_email_after_months, - :instance_level_code_suggestions_enabled, :invisible_captcha_enabled, :jira_connect_application_key, :jira_connect_public_key_storage_enabled, @@ -319,6 +318,8 @@ module ApplicationSettingsHelper :max_attachment_size, :max_export_size, :max_import_size, + :max_import_remote_file_size, + :max_decompressed_archive_size, :max_pages_size, :max_pages_custom_domains_per_project, :max_terraform_state_size_bytes, @@ -457,6 +458,7 @@ module ApplicationSettingsHelper :wiki_asciidoc_allow_uri_includes, :container_registry_delete_tags_service_timeout, :rate_limiting_response_text, + :package_registry_allow_anyone_to_pull_option, :package_registry_cleanup_policies_worker_capacity, :container_registry_expiration_policies_worker_capacity, :container_registry_cleanup_tags_service_max_list_size, @@ -491,6 +493,7 @@ module ApplicationSettingsHelper :invitation_flow_enforcement, :can_create_group, :bulk_import_enabled, + :bulk_import_max_download_file_size, :allow_runner_registration_token, :user_defaults_to_private_profile, :deactivation_email_additional_text, @@ -498,7 +501,9 @@ module ApplicationSettingsHelper :gitlab_dedicated_instance, :ci_max_includes, :allow_account_deletion, - :gitlab_shell_operation_limit + :gitlab_shell_operation_limit, + :namespace_aggregation_schedule_lease_duration_in_seconds, + :ci_max_total_yaml_size_bytes ].tap do |settings| next if Gitlab.com? diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb deleted file mode 100644 index a62ffa144f1..00000000000 --- a/app/helpers/broadcast_messages_helper.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -module BroadcastMessagesHelper - include Gitlab::Utils::StrongMemoize - - def current_broadcast_banner_messages - BroadcastMessage.current_banner_messages( - current_path: request.path, - user_access_level: current_user_access_level_for_project_or_group - ).select do |message| - cookies["hide_broadcast_message_#{message.id}"].blank? - end - end - - def current_broadcast_notification_message - not_hidden_messages = BroadcastMessage.current_notification_messages( - current_path: request.path, - user_access_level: current_user_access_level_for_project_or_group - ).select do |message| - cookies["hide_broadcast_message_#{message.id}"].blank? - end - not_hidden_messages.last - end - - def broadcast_message(message, opts = {}) - return unless message.present? - - render "shared/broadcast_message", { message: message, **opts } - end - - def broadcast_message_status(broadcast_message) - if broadcast_message.active? - 'Active' - elsif broadcast_message.ended? - 'Expired' - else - 'Pending' - end - end - - def render_broadcast_message(broadcast_message) - if broadcast_message.notification? - Banzai.render_field_and_post_process(broadcast_message, :message, { - current_user: current_user, - skip_project_check: true, - broadcast_message_placeholders: true - }).html_safe - else - Banzai.render_field(broadcast_message, :message).html_safe - end - end - - def target_access_level_options - BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS.map do |access_level| - [Gitlab::Access.human_access(access_level), access_level] - end - end - - def target_access_levels_display(access_levels) - access_levels.map do |access_level| - Gitlab::Access.human_access(access_level) - end.join(', ') - end - - def admin_broadcast_messages_data(broadcast_messages) - broadcast_messages.map do |message| - { - id: message.id, - status: broadcast_message_status(message), - message: message.message, - theme: message.theme, - broadcast_type: message.broadcast_type, - dismissable: message.dismissable, - starts_at: message.starts_at.iso8601, - ends_at: message.ends_at.iso8601, - target_roles: target_access_levels_display(message.target_access_levels), - target_path: message.target_path, - type: message.broadcast_type.capitalize, - edit_path: edit_admin_broadcast_message_path(message), - delete_path: "#{admin_broadcast_message_path(message)}.js" - } - end.to_json - end - - def broadcast_message_data(broadcast_message) - { - id: broadcast_message.id, - message: broadcast_message.message, - broadcast_type: broadcast_message.broadcast_type, - theme: broadcast_message.theme, - dismissable: broadcast_message.dismissable.to_s, - target_access_levels: broadcast_message.target_access_levels, - messages_path: admin_broadcast_messages_path, - preview_path: preview_admin_broadcast_messages_path, - target_path: broadcast_message.target_path, - starts_at: broadcast_message.starts_at.iso8601, - ends_at: broadcast_message.ends_at.iso8601, - target_access_level_options: target_access_level_options.to_json, - show_in_cli: broadcast_message.show_in_cli.to_s - } - end - - private - - def current_user_access_level_for_project_or_group - return unless current_user.present? - - strong_memoize(:current_user_access_level_for_project_or_group) do - case controller - when Projects::ApplicationController - next unless @project - - @project.team.max_member_access(current_user.id) - when Groups::ApplicationController - next unless @group - - @group.max_member_access_for_user(current_user) - end - end - end -end diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index b1481f668bb..7cc554bbeeb 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -71,21 +71,35 @@ module Ci new_runner_path: new_admin_runner_path, registration_token: Gitlab::CurrentSettings.runners_registration_token, online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i, - stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i + stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i, + tag_suggestions_path: tag_list_admin_runners_path(format: :json) } end def group_shared_runners_settings_data(group) - { + data = { group_id: group.id, group_name: group.name, group_is_empty: (group.projects.empty? && group.children.empty?).to_s, shared_runners_setting: group.shared_runners_setting, - parent_shared_runners_setting: group.parent&.shared_runners_setting, + runner_enabled_value: Namespace::SR_ENABLED, runner_disabled_value: Namespace::SR_DISABLED_AND_UNOVERRIDABLE, - runner_allow_override_value: Namespace::SR_DISABLED_AND_OVERRIDABLE + runner_allow_override_value: Namespace::SR_DISABLED_AND_OVERRIDABLE, + + parent_shared_runners_setting: group.parent&.shared_runners_setting, + parent_name: nil, + parent_settings_path: nil } + + if group.parent && can?(current_user, :admin_group, group.parent) + data.merge!({ + parent_name: group.parent.name, + parent_settings_path: group_settings_ci_cd_path(group.parent, anchor: 'runners-settings') + }) + end + + data end def group_runners_data_attributes(group) @@ -99,11 +113,22 @@ module Ci end def toggle_shared_runners_settings_data(project) - { + data = { is_enabled: project.shared_runners_enabled?.to_s, is_disabled_and_unoverridable: (project.group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE).to_s, - update_path: toggle_shared_runners_project_runners_path(project) + update_path: toggle_shared_runners_project_runners_path(project), + group_name: nil, + group_settings_path: nil } + + if project.group && can?(current_user, :admin_group, project.group) + data.merge!({ + group_name: project.group.name, + group_settings_path: group_settings_ci_cd_path(project.group, anchor: 'runners-settings') + }) + end + + data end end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index ee86553d75d..42871dcc56f 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -111,7 +111,7 @@ module CommitsHelper tooltip = _("Browse Directory") end - link_to url, class: "btn gl-button btn-default btn-icon has-tooltip", title: tooltip, data: { container: "body" } do + render Pajamas::ButtonComponent.new(href: url, button_options: { title: tooltip, class: 'has-tooltip btn-icon', data: { container: 'body' } }) do sprite_icon('folder-open') end end @@ -143,6 +143,16 @@ module CommitsHelper end end + def local_committed_date(commit, user) + server_timezone = Time.zone + user_timezone = user.timezone if user + user_timezone = ActiveSupport::TimeZone.new(user_timezone) if user_timezone + + timezone = user_timezone || server_timezone + + commit.committed_date.in_time_zone(timezone).to_date + end + def cherry_pick_projects_data(project) [project, project.forked_from_project].compact.map do |project| { @@ -188,12 +198,11 @@ module CommitsHelper entity = mode == 'raw' ? 'rawButton' : 'renderedButton' title = "Display #{mode} diff" - link_to( - "##{mode}-diff-#{file_hash}", - class: "btn gl-button btn-default btn-file-option has-tooltip btn-show-#{mode}-diff", - title: title, - data: { file_hash: file_hash, diff_toggle_entity: entity } - ) do + render Pajamas::ButtonComponent.new( + href: "##{mode}-diff-#{file_hash}", + button_options: { title: title, + class: "btn-file-option has-tooltip btn-show-#{mode}-diff", + data: { file_hash: file_hash, diff_toggle_entity: entity } }) do sprite_icon(icon) end end @@ -242,7 +251,7 @@ module CommitsHelper path = project_blob_path(project, tree_join(commit_sha, diff_new_path)) title = replaced ? _('View replaced file @ ') : _('View file @ ') - link_to(path, class: 'btn gl-button btn-default gl-ml-3') do + render Pajamas::ButtonComponent.new(href: path, button_options: { class: 'gl-ml-3' }) do raw(title) + content_tag(:span, truncate_sha(commit_sha), class: 'commit-sha') end end @@ -253,7 +262,7 @@ module CommitsHelper external_url = environment.external_url_for(diff_new_path, commit_sha) return unless external_url - link_to(external_url, class: 'btn gl-button btn-default btn-file-option has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do + render Pajamas::ButtonComponent.new(href: external_url, target: '_blank', button_options: { rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' } }) do sprite_icon('external-link') end end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 475ba3dcba8..ce18bedd25f 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true module DropdownsHelper + def dropdown_data_attr(options: {}) + output = content_tag(:div, "", id: "js-template-selectors-menu", data: options[:data]) + output.html_safe + end + # rubocop:disable Metrics/CyclomaticComplexity def dropdown_tag(toggle_text, options: {}, &block) content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 7213bd074fc..af0f1bd6808 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -2,6 +2,7 @@ module EmailsHelper include AppearancesHelper + include SafeFormatHelper # Google Actions # https://developers.google.com/gmail/markup/reference/go-to-action @@ -236,6 +237,44 @@ module EmailsHelper end end + def member_about_to_expire_text(member_source, days_to_expire, format: nil) + days_formatted = pluralize(days_to_expire, 'day') + + case member_source + when Project + url = project_url(member_source) + when Group + url = group_url(member_source) + end + + case format + when :html + link_to = generate_link(member_source.human_name, url).html_safe + safe_format(_("Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."), link_to: link_to, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted) + else + _("Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}.") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted } + end + end + + def member_about_to_expire_link(member, member_source, format: nil) + project_or_group = member_source.human_name + + case member_source + when Project + url = project_project_members_url(member_source, search: member.user.username) + when Group + url = group_group_members_url(member_source, search: member.user.username) + end + + case format + when :html + link_to = generate_link("#{member_source.class.name.downcase} membership", url).html_safe + safe_format(_('For additional information, review your %{link_to} or contact your %{project_or_group} owner.'), link_to: link_to, project_or_group: project_or_group) + else + _('For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner.') % { project_or_group: project_or_group, url: url } + end + end + def group_membership_expiration_changed_text(member, group) if member.expires? days = (member.expires_at - Date.today).to_i diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 3360a5256af..cd768ba8a7b 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -54,7 +54,6 @@ module EnvironmentsHelper { 'settings_path' => edit_project_settings_integration_path(project, 'prometheus'), 'clusters_path' => project_clusters_path(project), - 'dashboards_endpoint' => project_performance_monitoring_dashboards_path(project, format: :json), 'default_branch' => project.default_branch, 'project_path' => project_path(project), 'tags_path' => project_tags_path(project), @@ -82,8 +81,7 @@ module EnvironmentsHelper { 'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json), 'operations_settings_path' => project_settings_operations_path(project), - 'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s, - 'panel_preview_endpoint' => project_metrics_dashboards_builder_path(project, format: :json) + 'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s } end diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 4b5fadf3397..645a08bfcc0 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -162,7 +162,7 @@ module IntegrationsHelper end def integrations_help_page_path - help_page_path('user/admin_area/settings/project_integration_management') + help_page_path('administration/settings/project_integration_management') end def project_jira_issues_integration? @@ -179,7 +179,8 @@ module IntegrationsHelper 'incident' => _('Incident'), 'test_case' => _('Test case'), 'requirement' => _('Requirement'), - 'task' => _('Task') + 'task' => _('Task'), + 'ticket' => _('Service Desk Ticket') } issue_type_i18n_map[issue_type] || issue_type diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index e921e9bae4d..c83545fa7a7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -251,9 +251,16 @@ module IssuablesHelper 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, @@ -261,6 +268,16 @@ module IssuablesHelper 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) @@ -366,16 +383,6 @@ module IssuablesHelper end end - def hidden_issuable_icon(issuable) - title = format( - _('This %{issuable} is hidden because its author has been banned'), - issuable: issuable.is_a?(Issue) ? _('issue') : _('merge request') - ) - content_tag(:span, class: 'has-tooltip', title: title) do - sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom') - end - end - def issuable_type_selector_data(issuable) { selected_type: issuable.issue_type, diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index d9b9b27d16c..ed655b562c2 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -55,7 +55,7 @@ module IssuesHelper def hidden_issue_icon(issue) return unless issue_hidden?(issue) - hidden_issuable_icon(issue) + hidden_resource_icon(issue) end def award_user_list(awards, current_user, limit: 10) @@ -195,7 +195,8 @@ module IssuesHelper is_signed_in: current_user.present?.to_s, jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), rss_path: url_for(safe_params.merge(rss_url_options)), - sign_in_path: new_user_session_path + sign_in_path: new_user_session_path, + has_issue_date_filter_feature: Feature.enabled?(:issue_date_filter, namespace).to_s } end @@ -220,7 +221,9 @@ module IssuesHelper quick_actions_help_path: help_page_path('user/project/quick_actions'), releases_path: project_releases_path(project, format: :json), reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), - show_new_issue_link: show_new_issue_link?(project).to_s + show_new_issue_link: show_new_issue_link?(project).to_s, + report_abuse_path: add_category_abuse_reports_path, + register_path: new_user_registration_path(redirect_to_referer: 'yes') ) end diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index 5cf68db0611..2309dfc2a2b 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -5,7 +5,7 @@ module JiraConnectHelper skip_groups = subscriptions.map(&:namespace_id) { - groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }), + groups_path: api_v4_groups_path(params: { skip_groups: skip_groups }), subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json, subscriptions_path: jira_connect_subscriptions_path(format: :json), gitlab_user_path: current_user ? user_path(current_user) : nil, diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index c4967a42a45..79bab0969d1 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -46,11 +46,11 @@ module LabelsHelper end end - def render_label(label, link: nil, tooltip: true, dataset: nil, small: false) + def render_label(label, link: nil, tooltip: true, dataset: nil, small: false, tooltip_shows_title: false) html = render_colored_label(label) if link - title = label_tooltip_title(label) if tooltip + title = label_tooltip_title(label, tooltip_shows_title: tooltip_shows_title) if tooltip html = render_label_link(html, link: link, title: title, dataset: dataset) end @@ -74,8 +74,8 @@ module LabelsHelper %(<span class="#{wrapper_classes.join(' ')}">#{label_html}</span>).html_safe end - def label_tooltip_title(label) - Sanitize.clean(label.description) + def label_tooltip_title(label, tooltip_shows_title: false) + Sanitize.clean(tooltip_shows_title ? label.title : label.description) end def suggested_colors diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 91fce6d6820..1a44f3554b0 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -110,8 +110,8 @@ module MarkupHelper prepare_asciidoc_context(file_name, context) html = Markup::RenderingService - .new(text, file_name: file_name, context: context, postprocess_context: postprocess_context) - .execute + .new(text, file_name: file_name, context: context, postprocess_context: postprocess_context) + .execute Hamlit::RailsHelpers.preserve(html) end @@ -124,8 +124,8 @@ module MarkupHelper prepare_asciidoc_context(wiki_page.path, context) html = Markup::RenderingService - .new(text, file_name: wiki_page.path, context: context, postprocess_context: postprocess_context) - .execute + .new(text, file_name: wiki_page.path, context: context, postprocess_context: postprocess_context) + .execute Hamlit::RailsHelpers.preserve(html) end @@ -192,15 +192,21 @@ module MarkupHelper def markdown_toolbar_button(options = {}) data = options[:data].merge({ container: 'body' }) - css_classes = %w[gl-button btn btn-default-tertiary btn-icon btn-sm js-md has-tooltip] << options[:css_class].to_s - content_tag :button, - type: 'button', - class: css_classes.join(' '), - data: data, - title: options[:title], - aria: { label: options[:title] } do - sprite_icon(options[:icon]) - end + css_classes = %w[js-md has-tooltip] << options[:css_class].to_s + + render Pajamas::ButtonComponent.new( + category: :tertiary, + size: :small, + icon: options[:icon], + button_options: { + class: css_classes.join(' '), + data: data, + title: options[:title], + aria: { + label: options[:title] + } + } + ) end def render_markdown_field(object, field, context = {}) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index af1c85532c3..32a183d6cd8 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -303,12 +303,16 @@ module MergeRequestsHelper def hidden_merge_request_icon(merge_request) return unless merge_request.hidden? - hidden_issuable_icon(merge_request) + hidden_resource_icon(merge_request) end def tab_count_display(merge_request, count) merge_request.preparing? ? "-" : count end + + def review_bar_data(_merge_request, _user) + { new_comment_template_path: profile_comment_templates_path } + end end MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper') diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index 06deaeb5e9e..158aa5e0944 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -15,6 +15,11 @@ module MirrorHelper html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } end + + def mirrored_repositories_count + count = @project.mirror == true ? 1 : 0 + count + @project.remote_mirrors.to_a.count(&:enabled) + end end MirrorHelper.prepend_mod_with('MirrorHelper') diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index c7864c1d45f..4cbd5029ac9 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -90,7 +90,7 @@ module NavHelper # The new sidebar is not enabled for anonymous use # Once we enable the new sidebar by default, this # should return true - return false unless user + return Feature.enabled?(:super_sidebar_logged_out) unless user # Users who got the special `super_sidebar_nav_enrolled` enabled, # see the new nav as long as they don't explicitly opt-out via the toggle diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 3e8872dc199..af8da86b391 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -178,6 +178,10 @@ module NotesHelper def notes_data(issuable) data = { + noteableType: @noteable.class.underscore, + noteableId: @noteable.id, + projectId: @project&.id, + groupId: @group&.id, discussionsPath: discussions_path(issuable), registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 31fcc77925b..fefc19d7c1a 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -16,7 +16,7 @@ module PackagesHelper end def package_registry_project_url(project_id, registry_type = :maven) - project_api_path = expose_path(api_v4_projects_path(id: project_id)) + project_api_path = api_v4_projects_path(id: project_id) package_registry_project_path = "#{project_api_path}/packages/#{registry_type}" expose_url(package_registry_project_path) end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 26463003f8d..05605394d57 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -73,6 +73,21 @@ module ProfilesHelper def prevent_delete_account? false end + + def user_profile_data(user) + { + profile_path: profile_path, + profile_avatar_path: profile_avatar_path, + avatar_url: avatar_icon_for_user(user, current_user: current_user), + has_avatar: user.avatar?.to_s, + gravatar_enabled: gravatar_enabled?.to_s, + gravatar_link: { hostname: Gitlab.config.gravatar.host, url: "https://#{Gitlab.config.gravatar.host}" }.to_json, + brand_profile_image_guidelines: current_appearance&.profile_image_guidelines? ? brand_profile_image_guidelines : '', + cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css'), + user_path: user_path(current_user), + **user_status_properties(user) + } + end end ProfilesHelper.prepend_mod diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb index f62f5eadfb4..d285cfa03c2 100644 --- a/app/helpers/projects/cluster_agents_helper.rb +++ b/app/helpers/projects/cluster_agents_helper.rb @@ -6,7 +6,7 @@ module Projects::ClusterAgentsHelper activity_empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'), agent_name: agent_name, can_admin_vulnerability: can?(current_user, :admin_vulnerability, project).to_s, - empty_state_svg_path: image_path('illustrations/operations-dashboard_empty.svg'), + empty_state_svg_path: image_path('illustrations/empty-state/empty-radar-md.svg'), project_path: project.full_path, kas_address: Gitlab::Kas.external_url, kas_version: Gitlab::Kas.version_info, diff --git a/app/helpers/projects/observability_helper.rb b/app/helpers/projects/observability_helper.rb index 24bc1928a36..4515fdb1bc3 100644 --- a/app/helpers/projects/observability_helper.rb +++ b/app/helpers/projects/observability_helper.rb @@ -9,5 +9,15 @@ module Projects 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_helper.rb b/app/helpers/projects_helper.rb index e27ee1acb22..754e1b7c2a2 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -464,14 +464,23 @@ module ProjectsHelper project.forking_enabled? && can?(user, :read_code, project) end - def fork_button_disabled_tooltip(project) + def fork_button_data_attributes(project) return unless current_user - if !current_user.can?(:fork_project, project) - s_("ProjectOverview|You don't have permission to fork this project") - elsif !current_user.can?(:create_fork) - s_('ProjectOverview|You have reached your project limit') + if current_user.already_forked?(project) && current_user.forkable_namespaces.size < 2 + user_fork_url = namespace_project_path(current_user, current_user.fork_of(project)) end + + { + forks_count: project.forks_count, + project_full_path: project.full_path, + project_forks_url: project_forks_path(project), + user_fork_url: user_fork_url, + new_fork_url: new_project_fork_path(project), + can_read_code: can?(current_user, :read_code, project).to_s, + can_fork_project: can?(current_user, :fork_project, project).to_s, + can_create_fork: can?(current_user, :create_fork).to_s + } end def import_from_bitbucket_message @@ -551,6 +560,20 @@ module ProjectsHelper project_settings_repository_path(@project, anchor: 'js-branch-rules') end + def visibility_level_content(project, css_class: nil, icon_css_class: nil) + if project.created_and_owned_by_banned_user? && Feature.enabled?(:hide_projects_of_banned_users) + return hidden_resource_icon(project, css_class: css_class) + end + + title = visibility_icon_description(project) + container_class = ['has-tooltip', css_class].compact.join(' ') + data = { container: 'body', placement: 'top' } + + content_tag(:span, class: container_class, data: data, title: title) do + visibility_level_icon(project.visibility_level, options: { class: icon_css_class }) + end + end + private def can_admin_project_clusters?(project) @@ -706,6 +729,7 @@ module ProjectsHelper { packagesEnabled: !!project.packages_enabled, packageRegistryAccessLevel: feature.package_registry_access_level, + packageRegistryAllowAnyoneToPullOption: ::Gitlab::CurrentSettings.package_registry_allow_anyone_to_pull_option, visibilityLevel: project.visibility_level, requestAccessEnabled: !!project.request_access_enabled, issuesAccessLevel: feature.issues_access_level, @@ -719,7 +743,7 @@ module ProjectsHelper analyticsAccessLevel: feature.analytics_access_level, containerRegistryEnabled: !!project.container_registry_enabled, lfsEnabled: !!project.lfs_enabled, - emailsDisabled: project.emails_disabled?, + emailsEnabled: project.emails_enabled?, monitorAccessLevel: feature.monitor_access_level, showDefaultAwardEmojis: project.show_default_award_emojis?, warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?, diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 9ef347fff16..cf5cc92587f 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -40,10 +40,6 @@ module SessionsHelper request.env['rack.session.options'][:expire_after] = expiry_s end - def send_rate_limited?(user) - Gitlab::ApplicationRateLimiter.peek(:email_verification_code_send, scope: user) - end - def obfuscated_email(email) # Moved to Gitlab::Utils::Email in 15.9 Gitlab::Utils::Email.obfuscated_email(email) @@ -52,4 +48,23 @@ module SessionsHelper def remember_me_enabled? Gitlab::CurrentSettings.remember_me_enabled? end + + def unconfirmed_verification_email?(user) + token_valid_from = ::Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES.minutes.ago + user.email_reset_offered_at.nil? && user.pending_reconfirmation? && user.confirmation_sent_at >= token_valid_from + end + + def verification_email(user) + unconfirmed_verification_email?(user) ? user.unconfirmed_email : user.email + end + + def verification_data(user) + { + obfuscated_email: obfuscated_email(verification_email(user)), + verify_path: session_path(:user), + resend_path: users_resend_verification_code_path, + offer_email_reset: user.email_reset_offered_at.nil?.to_s, + update_email_path: users_update_email_path + } + end end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 90917cb96e0..1bd7da0a352 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -45,14 +45,37 @@ module SidebarsHelper end def super_sidebar_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize + return super_sidebar_logged_out_context(panel: panel, panel_type: panel_type) unless user + + super_sidebar_logged_in_context(user, group: group, project: project, panel: panel, panel_type: panel_type) + end + + def super_sidebar_logged_out_context(panel:, panel_type:) # rubocop:disable Metrics/AbcSize { + is_logged_in: false, + context_switcher_links: context_switcher_links, current_menu_items: panel.super_sidebar_menu_items, current_context_header: panel.super_sidebar_context_header, + support_path: support_url, + display_whats_new: display_whats_new?, + whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count, + whats_new_version_digest: whats_new_version_digest, + show_version_check: show_version_check?, + gitlab_version: Gitlab.version_info, + gitlab_version_check: gitlab_version_check, + search: search_data, + panel_type: panel_type + } + end + + def super_sidebar_logged_in_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize + super_sidebar_logged_out_context(panel: panel, panel_type: panel_type).merge({ + is_logged_in: true, name: user.name, username: user.username, avatar_url: user.avatar_url, has_link_to_profile: current_user_menu?(:profile), - link_to_profile: user_url(user), + link_to_profile: user_path(user), logo_url: current_appearance&.header_logo_path, status: user_status_menu_data(user), settings: { @@ -75,26 +98,16 @@ module SidebarsHelper merge_request_menu: create_merge_request_menu(user), projects_path: dashboard_projects_path, groups_path: dashboard_groups_path, - support_path: support_url, - display_whats_new: display_whats_new?, - whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count, - whats_new_version_digest: whats_new_version_digest, - show_version_check: show_version_check?, - gitlab_version: Gitlab.version_info, - gitlab_version_check: gitlab_version_check, gitlab_com_but_not_canary: Gitlab.com_but_not_canary?, gitlab_com_and_canary: Gitlab.com_and_canary?, canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url, current_context: super_sidebar_current_context(project: project, group: group), - context_switcher_links: context_switcher_links, - search: search_data, pinned_items: user.pinned_nav_items[panel_type] || super_sidebar_default_pins(panel_type), - panel_type: panel_type, - update_pins_url: pins_url, + update_pins_url: pins_path, is_impersonating: impersonating?, stop_impersonation_path: admin_impersonation_path, shortcut_links: shortcut_links(user, project: project) - } + }) end def super_sidebar_nav_panel( @@ -331,8 +344,7 @@ module SidebarsHelper def context_switcher_links links = [ - # We should probably not return "You work" when used is not logged-in - { title: s_('Navigation|Your work'), link: root_path, icon: 'work' }, + ({ title: s_('Navigation|Your work'), link: root_path, icon: 'work' } if current_user), { title: s_('Navigation|Explore'), link: explore_root_path, icon: 'compass' } ] @@ -368,7 +380,7 @@ module SidebarsHelper end # rubocop: enable Cop/UserAdmin - links + links.compact end def impersonating? diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 2f9117a74be..31ce8317d51 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -45,36 +45,26 @@ module SnippetsHelper def embedded_raw_snippet_button(snippet, blob) return if blob.empty? || blob.binary? || blob.stored_externally? - link_to( - external_snippet_icon('doc-code'), - gitlab_raw_snippet_blob_url(snippet, blob.path), - class: 'gl-button btn btn-default', - target: '_blank', - rel: 'noopener noreferrer', - title: 'Open raw' - ) + render Pajamas::ButtonComponent.new(href: gitlab_raw_snippet_blob_url(snippet, blob.path), target: '_blank', + button_options: { rel: 'noopener noreferrer', title: 'Open raw' }) do + external_snippet_icon('doc-code') + end end def embedded_snippet_download_button(snippet, blob) - link_to( - external_snippet_icon('download'), - gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false), - class: 'gl-button btn btn-default', - target: '_blank', - title: 'Download', - rel: 'noopener noreferrer' - ) + render Pajamas::ButtonComponent.new(href: gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false), + target: '_blank', button_options: { rel: 'noopener noreferrer', title: 'Download' }) do + external_snippet_icon('download') + end end def embedded_copy_snippet_button(blob) return unless blob.rendered_as_text?(ignore_errors: false) - content_tag( - :button, - class: 'gl-button btn btn-default copy-to-clipboard-btn', - title: 'Copy snippet contents', - onclick: "copyToClipboard('.blob-content[data-blob-id=\"#{blob.id}\"] > pre')" - ) do + button_options = { title: 'Copy snippet contents', + onclick: "copyToClipboard('.blob-content[data-blob-id=\"#{blob.id}\"] > pre')" } + + render Pajamas::ButtonComponent.new(button_options: button_options) do external_snippet_icon('copy-to-clipboard') end end diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index ad473875a53..0a5751c5221 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true module TimeHelper + TIME_UNIT_TRANSLATION = { + seconds: ->(seconds) { n_('%d second', '%d seconds', seconds) % seconds }, + minutes: ->(minutes) { n_('%d minute', '%d minutes', minutes) % minutes }, + hours: ->(hours) { n_('%d hour', '%d hours', hours) % hours }, + days: ->(days) { n_('%d day', '%d days', days) % days }, + weeks: ->(weeks) { n_('%d week', '%d weeks', weeks) % weeks }, + months: ->(months) { n_('%d month', '%d months', months) % months }, + years: ->(years) { n_('%d year', '%d years', years) % years } + }.freeze + def time_interval_in_words(interval_in_seconds) - interval_in_seconds = interval_in_seconds.to_i - minutes = interval_in_seconds / 60 - seconds = interval_in_seconds - minutes * 60 + time_parts = ActiveSupport::Duration.build(interval_in_seconds.to_i).parts - if minutes >= 1 - if seconds % 60 == 0 - n_('%d minute', '%d minutes', minutes) % minutes - else - [n_('%d minute', '%d minutes', minutes) % minutes, n_('%d second', '%d seconds', seconds) % seconds].to_sentence - end - else - n_('%d second', '%d seconds', seconds) % seconds - end + time_parts.map { |unit, value| TIME_UNIT_TRANSLATION[unit].call(value) }.to_sentence end def duration_in_numbers(duration_in_seconds) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 9b0810f3d17..4f17634f3e4 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -30,6 +30,7 @@ module TodosHelper when Todo::MEMBER_ACCESS_REQUESTED then format( s_("Todos|has requested access to %{what} %{which}"), what: _(todo.member_access_type), which: _(todo.target.name) ) + when Todo::REVIEW_SUBMITTED then s_('Todos|reviewed your merge request') end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 84512453b7c..880fb8aa9d8 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -153,7 +153,8 @@ module TreeHelper project_short_path: project.path, ref: ref, escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref), - full_name: project.name_with_namespace + full_name: project.name_with_namespace, + ref_type: @ref_type } end diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 0f4cbd6642b..12f78d9bd16 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -89,6 +89,8 @@ module Users end def gitlab_com_user_created_after_new_nav_rollout? + return true unless current_user + Gitlab.com? && current_user.created_at >= Date.new(2023, 6, 2) end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 29998a996e2..ac279904fd2 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -104,6 +104,24 @@ module UsersHelper Gitlab.config.gitlab.impersonation_enabled end + def can_impersonate_user(user, impersonation_in_progress) + can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress + end + + def impersonation_error_text(user, impersonation_in_progress) + if impersonation_in_progress + _("You are already impersonating another user") + elsif user.blocked? + _("You cannot impersonate a blocked user") + elsif user.password_expired? + _("You cannot impersonate a user with an expired password") + elsif user.internal? + _("You cannot impersonate an internal user") + else + _("You cannot impersonate a user who cannot log in") + end + end + def user_badges_in_admin_section(user) [].tap do |badges| badges << blocked_user_badge(user) if user.blocked? @@ -208,6 +226,24 @@ module UsersHelper end end + def user_profile_actions_data(user) + basic_actions_data = { + user_id: user.id + } + + if can?(current_user, :read_user_profile, user) + basic_actions_data[:rss_subscription_path] = user_path(user, rss_url_options) + end + + return basic_actions_data if !current_user || current_user == user + + basic_actions_data.merge( + report_abuse_path: add_category_abuse_reports_path, + reported_user_id: user.id, + reported_from_url: user_url(user) + ) + end + private def admin_users_paths diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb index 7129e577cb8..6a11aeeadb3 100644 --- a/app/mailers/devise_mailer.rb +++ b/app/mailers/devise_mailer.rb @@ -8,6 +8,7 @@ class DeviseMailer < Devise::Mailer helper EmailsHelper helper ApplicationHelper + helper RegistrationsHelper def password_change_by_admin(record, opts = {}) devise_mail(record, :password_change_by_admin, opts) @@ -22,6 +23,14 @@ class DeviseMailer < Devise::Mailer super end + def email_changed(record, opts = {}) + if Gitlab.com? + devise_mail(record, :email_changed_gitlab_com, opts) + else + devise_mail(record, :email_changed, opts) + end + end + protected def subject_for(key) diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 33c955f94ee..221d359c8c6 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -133,6 +133,22 @@ module Emails subject: subject(subject)) end + def member_about_to_expire_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + + return unless member_exists? + return unless member.expires_at + + @days_to_expire = (member.expires_at - Date.today).to_i + + return if @days_to_expire <= 0 + + email_with_layout( + to: member.user.notification_email_for(notification_group), + subject: subject(s_("Your membership will expire in %{days_to_expire} days") % { days_to_expire: @days_to_expire })) + end + # rubocop: disable CodeReuse/ActiveRecord def member @member ||= Member.find_by(id: @member_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 6678bb563ed..cd7869123f3 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -55,6 +55,7 @@ module Emails @previous_reviewers = [] @previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any? + @updated_by_user = User.find(updated_by_user_id) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end @@ -186,5 +187,3 @@ module Emails end end end - -Emails::MergeRequests.prepend_mod_with('Emails::MergeRequests') diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index a382ca15e46..25d68d47228 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -6,7 +6,7 @@ module Emails @current_user = @user = User.find(user_id) @target_url = user_url(@user) @token = token - mail_with_locale(to: @user.notification_email_or_default, subject: subject("Account was created for you")) + email_with_layout(to: @user.notification_email_or_default, subject: subject("Account was created for you")) end def instance_access_request_email(user, recipient) @@ -65,7 +65,7 @@ module Emails @target_url = profile_personal_access_tokens_url @token_name = token_name - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) end def access_token_about_to_expire_email(user, token_names) @@ -107,7 +107,7 @@ module Emails @fingerprints = fingerprints @target_url = profile_keys_url - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired"))) + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired"))) end def ssh_key_expiring_soon_email(user, fingerprints) diff --git a/app/mailers/emails/reviews.rb b/app/mailers/emails/reviews.rb index b98fa8aa6c9..ed1166509a5 100644 --- a/app/mailers/emails/reviews.rb +++ b/app/mailers/emails/reviews.rb @@ -19,17 +19,16 @@ module Emails end def setup_review_email(review_id, recipient_id) - review = Review.find_by_id(review_id) - - @notes = review.notes - @discussions = Discussion.build_discussions(review.discussion_ids, preload_note_diff_file: true) + @review = Review.find_by_id(review_id) + @notes = @review.notes + @discussions = Discussion.build_discussions(@review.discussion_ids, preload_note_diff_file: true) @include_diff_discussion_stylesheet = @discussions.values.any? do |discussion| discussion.diff_discussion? && discussion.on_text? end - @author = review.author - @author_name = review.author_name - @project = review.project - @merge_request = review.merge_request + @author = @review.author + @author_name = @review.author_name + @project = @review.project + @merge_request = @review.merge_request @target_url = project_merge_request_url(@project, @merge_request) @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 036a0fc012e..4180e76e1a0 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -39,6 +39,7 @@ class Notify < ApplicationMailer helper GitlabRoutingHelper helper IssuablesHelper helper InProductMarketingHelper + helper RegistrationsHelper def test_email(recipient_email, subject, body) mail_with_locale( diff --git a/app/mailers/previews/devise_mailer_preview.rb b/app/mailers/previews/devise_mailer_preview.rb index 2919d466073..53f960e64a7 100644 --- a/app/mailers/previews/devise_mailer_preview.rb +++ b/app/mailers/previews/devise_mailer_preview.rb @@ -28,6 +28,10 @@ class DeviseMailerPreview < ActionMailer::Preview DeviseMailer.user_admin_approval(unsaved_user, {}) end + def email_changed + DeviseMailer.email_changed(unsaved_user, {}) + end + private def unsaved_user diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 576dbdd8b52..f43f4511913 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -17,6 +17,10 @@ class NotifyPreview < ActionMailer::Preview end end + def new_user_email + Notify.new_user_email(user.id).message + end + def note_merge_request_email_for_discussion note_email(:note_merge_request_email) do note = <<-MD.strip_heredoc @@ -73,6 +77,11 @@ class NotifyPreview < ActionMailer::Preview Notify.access_token_revoked_email(user, 'token_name').message end + def ssh_key_expired_email + fingerprints = [] + Notify.ssh_key_expired_email(user, fingerprints).message + end + def new_mention_in_merge_request_email Notify.new_mention_in_merge_request_email(user.id, merge_request.id, user.id).message end @@ -166,6 +175,13 @@ class NotifyPreview < ActionMailer::Preview Notify.member_invited_email('project', member.id, '1234').message end + def member_about_to_expire_email + cleanup do + member = project.add_member(user, Gitlab::Access::GUEST, expires_at: 7.days.from_now.to_date) + Notify.member_about_to_expire_email('project', member.id).message + end + end + def pages_domain_enabled_email cleanup do pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now) @@ -284,6 +300,13 @@ class NotifyPreview < ActionMailer::Preview Notify.request_review_merge_request_email(user.id, merge_request.id, user.id).message end + def new_review_email + review = Review.last + mr_author = review.merge_request.author + + Notify.new_review_email(mr_author.id, review.id).message + end + def project_was_moved_email Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab").message end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 1d2eee82827..75c90d370c3 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -18,6 +18,8 @@ class AbuseReport < ApplicationRecord belongs_to :assignee, class_name: 'User', inverse_of: :assigned_abuse_reports has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report + has_many :label_links, as: :target, inverse_of: :target + has_many :labels, through: :label_links has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report @@ -214,6 +216,24 @@ class AbuseReport < ApplicationRecord extension_list: valid_image_extensions.to_sentence(last_word_connector: ' or ')) ) end + + def self.aggregated_by_user_and_category(sort_by_count = false) + sub_query = self + .select('user_id, category, COUNT(id) as count', 'MIN(id) as min') + .group(:user_id, :category) + + reports = AbuseReport.with_users + .open + .select('aggregated.*, status, id, reporter_id, created_at, updated_at') + .from(sub_query, :aggregated) + .joins('INNER JOIN abuse_reports on aggregated.min = abuse_reports.id') + + if sort_by_count + reports.order(count: :desc, created_at: :desc) + else + reports + end + end end AbuseReport.prepend_mod diff --git a/app/models/admin/abuse_report_label.rb b/app/models/admin/abuse_report_label.rb new file mode 100644 index 00000000000..a2ccc8b5513 --- /dev/null +++ b/app/models/admin/abuse_report_label.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportLabel < Label + end +end diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb index 863bdfc7899..b8a2a271976 100644 --- a/app/models/ai/service_access_token.rb +++ b/app/models/ai/service_access_token.rb @@ -5,6 +5,7 @@ module Ai self.table_name = 'service_access_tokens' scope :expired, -> { where('expires_at < :now', now: Time.current) } + scope :active, -> { where('expires_at > :now', now: Time.current) } scope :for_category, ->(category) { where(category: category) } attr_encrypted :token, diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 291375f647c..7058bfd5650 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -6,6 +6,7 @@ class ApplicationRecord < ActiveRecord::Base include LegacyBulkInsert include CrossDatabaseModification include SensitiveSerializableHash + include ResetOnUnionError self.abstract_class = true @@ -95,7 +96,7 @@ class ApplicationRecord < ActiveRecord::Base end def self.underscore - Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { to_s.underscore } + @underscore ||= to_s.underscore end def self.where_exists(query) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 827f8bc93be..f67efaf4f58 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -39,6 +39,12 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord 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' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -254,6 +260,18 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :max_import_remote_file_size, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + validates :bulk_import_max_download_file_size, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + validates :max_decompressed_archive_size, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :max_pages_size, presence: true, numericality: { @@ -407,6 +425,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false + validates :protected_paths_for_get_request, + length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, + allow_nil: false + validates :push_event_hooks_limit, numericality: { greater_than_or_equal_to: 0 } @@ -419,6 +441,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true + validates :ci_max_total_yaml_size_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true + validates :ci_max_includes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true validates :email_restrictions, untrusted_regexp: true @@ -498,6 +522,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord end end + validates :default_project_visibility, :default_group_visibility, + exclusion: { in: :restricted_visibility_levels, message: "cannot be set to a restricted visibility level" }, + if: :should_prevent_visibility_restriction? + validates_each :import_sources do |record, attr, value| value&.each do |source| unless Gitlab::ImportSources.options.value?(source) @@ -712,18 +740,21 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :inactive_projects_send_warning_email_after_months, numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months } - validates :database_apdex_settings, json_schema: { filename: 'application_setting_database_apdex_settings' }, allow_nil: true + validates :prometheus_alert_db_indicators_settings, json_schema: { filename: 'application_setting_prometheus_alert_db_indicators_settings' }, allow_nil: true validates :namespace_aggregation_schedule_lease_duration_in_seconds, numericality: { only_integer: true, greater_than: 0 } + validates :sentry_clientside_traces_sample_rate, + presence: true, + numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1, message: N_('must be a value between 0 and 1') } + validates :instance_level_code_suggestions_enabled, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :ai_access_token, - presence: { message: N_("is required to enable Code Suggestions") }, - if: :instance_level_code_suggestions_enabled + validates :package_registry_allow_anyone_to_pull_option, + inclusion: { in: [true, false], message: N_('must be a boolean value') } attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, @@ -951,7 +982,13 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord def reset_deletion_warning_redis_key Gitlab::InactiveProjectsDeletionWarningTracker.reset_all end + + def should_prevent_visibility_restriction? + Feature.enabled?(:prevent_visibility_restriction) && + (default_project_visibility_changed? || + default_group_visibility_changed? || + restricted_visibility_levels_changed?) + end end -ApplicationSetting.prepend(ApplicationSettingMaskedAttrs) ApplicationSetting.prepend_mod_with('ApplicationSetting') diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 81e816a5b7c..f6bf535158a 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -45,6 +45,7 @@ module ApplicationSettingImplementation allow_possible_spam: false, asset_proxy_enabled: false, authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand + ci_max_total_yaml_size_bytes: 157286400, # max_yaml_size_bytes * ci_max_includes = 1.megabyte * 150 commit_email_hostname: default_commit_email_hostname, container_expiration_policies_enable_historic_entries: false, container_registry_features: [], @@ -61,6 +62,7 @@ module ApplicationSettingImplementation default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_projects_limit: Settings.gitlab['default_projects_limit'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], + default_syntax_highlighting_theme: 1, deny_all_requests_except_allowed: false, diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, diff_max_files: Commit::DEFAULT_MAX_DIFF_FILES_SETTING, @@ -119,6 +121,8 @@ module ApplicationSettingImplementation max_attachment_size: Settings.gitlab['max_attachment_size'], max_export_size: 0, max_import_size: 0, + max_import_remote_file_size: 10240, + max_decompressed_archive_size: 25600, max_terraform_state_size_bytes: 0, max_yaml_size_bytes: 1.megabyte, max_yaml_depth: 100, @@ -254,6 +258,7 @@ module ApplicationSettingImplementation users_get_by_id_limit_allowlist: [], can_create_group: true, bulk_import_enabled: false, + bulk_import_max_download_file_size: 5120, allow_runner_registration_token: true, user_defaults_to_private_profile: false, projects_api_rate_limit_unauthenticated: 400, diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index a70ebb42008..e9fe49f980d 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AuthenticationEvent < ApplicationRecord +class AuthenticationEvent < MainClusterwide::ApplicationRecord include UsageStatistics TWO_FACTOR = 'two-factor' diff --git a/app/models/batched_git_ref_updates/deletion.rb b/app/models/batched_git_ref_updates/deletion.rb new file mode 100644 index 00000000000..61bba8aeba9 --- /dev/null +++ b/app/models/batched_git_ref_updates/deletion.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module BatchedGitRefUpdates + class Deletion < ApplicationRecord + PARTITION_DURATION = 1.day + + include IgnorableColumns + include BulkInsertSafe + include PartitionedTable + include EachBatch + + self.table_name = 'p_batched_git_ref_updates_deletions' + self.primary_key = :id + self.sequence_name = :to_be_deleted_git_refs_id_seq + + # This column must be ignored otherwise Rails will cache the default value and `bulk_insert!` will start saving + # incorrect partition_id. + ignore_column :partition_id, remove_with: '3000.0', remove_after: '3000-01-01' + + belongs_to :project, inverse_of: :to_be_deleted_git_refs + + scope :for_partition, ->(partition) { where(partition_id: partition) } + scope :for_project, ->(project_id) { where(project_id: project_id) } + scope :select_ref_and_identity, -> { select(:ref, :id, arel_table[:partition_id].as('partition')) } + + partitioned_by :partition_id, strategy: :sliding_list, + next_partition_if: ->(active_partition) do + oldest_record_in_partition = Deletion + .select(:id, :created_at) + .for_partition(active_partition.value) + .order(:id) + .limit(1) + .take + + oldest_record_in_partition.present? && + oldest_record_in_partition.created_at < PARTITION_DURATION.ago + end, + detach_partition_if: ->(partition) do + !Deletion + .for_partition(partition.value) + .status_pending + .exists? + end + + enum status: { pending: 1, processed: 2 }, _prefix: :status + + def self.mark_records_processed(records) + update_by_partition(records) do |partitioned_scope| + partitioned_scope.update_all(status: :processed) + end + end + + # Your scope must select_ref_and_identity before calling this method as it relies on partition being explicitly + # selected + def self.update_by_partition(records) + records.group_by(&:partition).each do |partition, records_within_partition| + partitioned_scope = status_pending + .for_partition(partition) + .where(id: records_within_partition.map(&:id)) + + yield(partitioned_scope) + end + end + + private_class_method :update_by_partition + end +end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb deleted file mode 100644 index ccc5ca7395d..00000000000 --- a/app/models/broadcast_message.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -class BroadcastMessage < MainClusterwide::ApplicationRecord - include CacheMarkdownField - include Sortable - - ALLOWED_TARGET_ACCESS_LEVELS = [ - Gitlab::Access::GUEST, - Gitlab::Access::REPORTER, - Gitlab::Access::DEVELOPER, - Gitlab::Access::MAINTAINER, - Gitlab::Access::OWNER - ].freeze - - cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true - - validates :message, presence: true - validates :starts_at, presence: true - validates :ends_at, presence: true - validates :broadcast_type, presence: true - validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS } - validates :show_in_cli, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } - - validates :color, allow_blank: true, color: true - validates :font, allow_blank: true, color: true - - attribute :color, default: '#E75E40' - attribute :font, default: '#FFFFFF' - - scope :current_and_future_messages, -> { where('ends_at > :now', now: Time.current).order_id_asc } - - CACHE_KEY = 'broadcast_message_current_json' - BANNER_CACHE_KEY = 'broadcast_message_current_banner_json' - NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json' - - after_commit :flush_redis_cache - - enum theme: { - indigo: 0, - 'light-indigo': 1, - blue: 2, - 'light-blue': 3, - green: 4, - 'light-green': 5, - red: 6, - 'light-red': 7, - dark: 8, - light: 9 - }, _default: 0, _prefix: true - - enum broadcast_type: { - banner: 1, - notification: 2 - } - - class << self - def current_banner_messages(current_path: nil, user_access_level: nil) - fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do - current_and_future_messages.banner - end - end - - def current_show_in_cli_banner_messages - current_banner_messages.select(&:show_in_cli?) - end - - def current_notification_messages(current_path: nil, user_access_level: nil) - fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do - current_and_future_messages.notification - end - end - - def current(current_path: nil, user_access_level: nil) - fetch_messages CACHE_KEY, current_path, user_access_level do - current_and_future_messages - end - end - - def cache - ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do - Gitlab::Cache::JsonCaches::JsonKeyed.new - end - end - - def cache_expires_in - 2.weeks - end - - private - - def fetch_messages(cache_key, current_path, user_access_level, &block) - messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in, &block) - - now_or_future = messages.select(&:now_or_future?) - - # If there are cached entries but they don't match the ones we are - # displaying we'll refresh the cache so we don't need to keep filtering. - cache.expire(cache_key) if now_or_future != messages - - messages = now_or_future.select(&:now?) - messages = messages.select do |message| - message.matches_current_user_access_level?(user_access_level) - end - messages.select do |message| - message.matches_current_path(current_path) - end - end - end - - def active? - started? && !ended? - end - - def started? - Time.current >= starts_at - end - - def ended? - ends_at < Time.current - end - - def now? - (starts_at..ends_at).cover?(Time.current) - end - - def future? - starts_at > Time.current - end - - def now_or_future? - now? || future? - end - - def matches_current_user_access_level?(user_access_level) - return true unless target_access_levels.present? - - target_access_levels.include? user_access_level - end - - def matches_current_path(current_path) - return false if current_path.blank? && target_path.present? - return true if current_path.blank? || target_path.blank? - - # Ensure paths are consistent across callers. - # This fixes a mismatch between requests in the GUI and CLI - # - # This has to be reassigned due to frozen strings being provided. - current_path = "/#{current_path}" unless current_path.start_with?("/") - - escaped = Regexp.escape(target_path).gsub('\\*', '.*') - regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE - - regexp.match(current_path) - end - - def flush_redis_cache - [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key| - self.class.cache.expire(key) - end - end -end - -BroadcastMessage.prepend_mod_with('BroadcastMessage') diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 5052d84378f..d0ccf5c543a 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -3,7 +3,7 @@ module Ci class Bridge < Ci::Processable include Ci::Contextable - include Ci::Metadatable + include Ci::Deployable include Importable include AfterCommitQueue include Ci::HasRef @@ -71,7 +71,7 @@ module Ci def self.clone_accessors %i[pipeline project ref tag options name allow_failure stage stage_idx - yaml_variables when description needs_attributes + yaml_variables when environment description needs_attributes scheduling_type ci_stage partition_id].freeze end @@ -180,20 +180,6 @@ module Ci false end - def outdated_deployment? - false - end - - def expanded_environment_name - end - - def persisted_environment - end - - def deployment_job? - false - end - def execute_hooks raise NotImplementedError end @@ -266,6 +252,12 @@ module Ci end end + def expand_file_refs? + strong_memoize(:expand_file_refs) do + !Feature.enabled?(:ci_prevent_file_var_expansion_downstream_pipeline, project) + end + end + private def cross_project_params diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index bb1bfe8c889..7a623b0cefb 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,8 +3,8 @@ module Ci class Build < Ci::Processable prepend Ci::BulkInsertableTags - include Ci::Metadatable include Ci::Contextable + include Ci::Deployable include TokenAuthenticatable include AfterCommitQueue include Presentable @@ -34,7 +34,6 @@ module Ci DEPLOYMENT_NAMES = %w[deploy release rollout].freeze - has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build @@ -158,16 +157,9 @@ module Ci .includes(:metadata, :job_artifacts_metadata) end - scope :with_project_and_metadata, -> do - if Feature.enabled?(:non_public_artifacts, type: :development) - joins(:metadata).includes(:metadata).preload(:project) - end - end - scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) } scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) } scope :last_month, -> { where('created_at > ?', Date.today - 1.month) } - scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } scope :scheduled_actions, -> { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) } scope :ref_protected, -> { where(protected: true) } scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where("#{quoted_table_name}.id = #{Ci::BuildTraceChunk.quoted_table_name}.build_id").select(1)) } @@ -327,7 +319,6 @@ module Ci after_transition any => [:success] do |build| build.run_after_commit do - BuildSuccessWorker.perform_async(id) PagesWorker.perform_async(:deploy, id) if build.pages_generator? end end @@ -345,18 +336,6 @@ module Ci end end end - - # Synchronize Deployment Status - # Please note that the data integirty is not assured because we can't use - # a database transaction due to DB decomposition. - after_transition do |build, transition| - next if transition.loopback? - next unless build.project - - build.run_after_commit do - build.deployment&.sync_status_with(build) - end - end end def self.build_matchers(project) @@ -400,10 +379,6 @@ module Ci .fabricate! end - def other_manual_actions - pipeline.manual_actions.reject { |action| action.name == name } - end - def other_scheduled_actions pipeline.scheduled_actions.reject { |action| action.name == name } end @@ -428,15 +403,6 @@ module Ci action? && !archived? && (manual? || scheduled? || retryable?) end - def outdated_deployment? - strong_memoize(:outdated_deployment) do - deployment_job? && - incomplete? && - project.ci_forward_deployment_enabled? && - deployment&.older_than_last_successful_deployment? - end - end - def schedulable? self.when == 'delayed' && options[:start_in].present? end @@ -478,94 +444,10 @@ module Ci Gitlab::Ci::Build::Prerequisite::Factory.new(self).unmet end - def persisted_environment - return unless has_environment_keyword? - - strong_memoize(:persisted_environment) do - # This code path has caused N+1s in the past, since environments are only indirectly - # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445 - # We therefore batch-load them to prevent dormant N+1s until we found a proper solution. - BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args| - Environment.where(name: names, project: args[:key]).find_each do |environment| - loader.call(environment.name, environment) - end - end - end - end - - def persisted_environment=(environment) - strong_memoize(:persisted_environment) { environment } - end - - # If build.persisted_environment is a BatchLoader, we need to remove - # the method proxy in order to clone into new item here - # https://github.com/exAspArk/batch-loader/issues/31 - def actual_persisted_environment - persisted_environment.respond_to?(:__sync) ? persisted_environment.__sync : persisted_environment - end - - def expanded_environment_name - return unless has_environment_keyword? - - strong_memoize(:expanded_environment_name) do - # We're using a persisted expanded environment name in order to avoid - # variable expansion per request. - if metadata&.expanded_environment_name.present? - metadata.expanded_environment_name - else - ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all }) - end - end - end - - def expanded_kubernetes_namespace - return unless has_environment_keyword? - - namespace = options.dig(:environment, :kubernetes, :namespace) - - if namespace.present? - strong_memoize(:expanded_kubernetes_namespace) do - ExpandVariables.expand(namespace, -> { simple_variables }) - end - end - end - - def has_environment_keyword? - environment.present? - end - - def deployment_job? - has_environment_keyword? && environment_action == 'start' - end - - def stops_environment? - has_environment_keyword? && environment_action == 'stop' - end - - def environment_action - options.fetch(:environment, {}).fetch(:action, 'start') if options - end - - def environment_tier_from_options - options.dig(:environment, :deployment_tier) if options - end - - def environment_tier - environment_tier_from_options || persisted_environment.try(:tier) - end - def triggered_by?(current_user) user == current_user end - def on_stop - options&.dig(:environment, :on_stop) - end - - def stop_action_successful? - success? - end - ## # All variables, including persisted environment variables. # @@ -649,9 +531,8 @@ module Ci def google_play_variables return [] unless google_play_integration.try(:activated?) - return [] unless pipeline.protected_ref? - Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables) + Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables(protected_ref: pipeline.protected_ref?)) end def features @@ -1033,19 +914,6 @@ module Ci job_artifacts.all_reports end - # Virtual deployment status depending on the environment status. - def deployment_status - return unless deployment_job? - - if success? - return successful_deployment_status - elsif failed? - return :failed - end - - :creating - end - # Consider this object to have a structural integrity problems def doom! transaction do @@ -1206,31 +1074,11 @@ module Ci strong_memoize(:build_data) { Gitlab::DataBuilder::Build.build(self) } end - def successful_deployment_status - if deployment&.last? - :last - else - :out_of_date - end - end - def job_artifacts_for_types(report_types) # Use select to leverage cached associations and avoid N+1 queries job_artifacts.select { |artifact| artifact.file_type.in?(report_types) } end - def environment_url - options&.dig(:environment, :url) || persisted_environment&.external_url - end - - def environment_status - strong_memoize(:environment_status) do - if has_environment_keyword? && merge_request - EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha) - end - end - end - def has_expiring_artifacts? artifacts_expire_at.present? && artifacts_expire_at > Time.current end diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index 38603ddfe59..799cdce4af7 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -11,6 +11,8 @@ module Ci self.table_name = 'catalog_resources' belongs_to :project + has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :catalog_resource + has_many :versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :catalog_resource scope :for_projects, ->(project_ids) { where(project_id: project_ids) } scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } diff --git a/app/models/ci/catalog/resources/component.rb b/app/models/ci/catalog/resources/component.rb new file mode 100644 index 00000000000..7b95c14ba7e --- /dev/null +++ b/app/models/ci/catalog/resources/component.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + # This class represents a CI/CD Catalog resource component. + # The data will be used as metadata of a component. + class Component < ::ApplicationRecord + self.table_name = 'catalog_resource_components' + + belongs_to :project, inverse_of: :ci_components + belongs_to :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :components + belongs_to :version, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :components + + enum resource_type: { template: 1 } + + validates :inputs, json_schema: { filename: 'catalog_resource_component_inputs' } + validates :version, :catalog_resource, :project, :name, presence: true + end + end + end +end + +Ci::Catalog::Resources::Component.prepend_mod_with('Ci::Catalog::Resources::Component') diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb new file mode 100644 index 00000000000..68f60e6a965 --- /dev/null +++ b/app/models/ci/catalog/resources/version.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + # This class represents a CI/CD Catalog resource version. + # Only versions which contain valid CI components are included in this table. + class Version < ::ApplicationRecord + self.table_name = 'catalog_resource_versions' + + belongs_to :release, inverse_of: :catalog_resource_version + belongs_to :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :versions + belongs_to :project, inverse_of: :catalog_resource_versions + has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :version + + validates :release, :catalog_resource, :project, presence: true + end + end + end +end + +Ci::Catalog::Resources::Version.prepend_mod_with('Ci::Catalog::Resources::Version') diff --git a/app/models/ci/job_annotation.rb b/app/models/ci/job_annotation.rb index a8bef02cc42..a6ce4196cc1 100644 --- a/app/models/ci/job_annotation.rb +++ b/app/models/ci/job_annotation.rb @@ -3,6 +3,7 @@ module Ci class JobAnnotation < Ci::ApplicationRecord include Ci::Partitionable + include BulkInsertSafe self.table_name = :p_ci_job_annotations self.primary_key = :id @@ -13,7 +14,6 @@ module Ci validates :data, json_schema: { filename: 'ci_job_annotation_data' } validates :name, presence: true, - length: { maximum: 255 }, - uniqueness: { scope: [:job_id, :partition_id] } + length: { maximum: 255 } end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 11d70e088e9..3f9d8f07b06 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -60,7 +60,8 @@ module Ci requirements_v2: 'requirements_v2.json', coverage_fuzzing: 'gl-coverage-fuzzing.json', api_fuzzing: 'gl-api-fuzzing-report.json', - cyclonedx: 'gl-sbom.cdx.json' + cyclonedx: 'gl-sbom.cdx.json', + annotations: 'gl-annotations.json' }.freeze INTERNAL_TYPES = { @@ -79,6 +80,7 @@ module Ci cluster_applications: :gzip, # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094 lsif: :zip, cyclonedx: :gzip, + annotations: :gzip, # Security reports and license scanning reports are raw artifacts # because they used to be fetched by the frontend, but this is not the case anymore. @@ -221,7 +223,8 @@ module Ci api_fuzzing: 26, ## EE-specific cluster_image_scanning: 27, ## EE-specific cyclonedx: 28, ## EE-specific - requirements_v2: 29 ## EE-specific + requirements_v2: 29, ## EE-specific + annotations: 30 } # `file_location` indicates where actual files are stored. @@ -341,10 +344,16 @@ module Ci end def to_deleted_object_attrs(pick_up_at = nil) + final_path_store_dir, final_path_filename = nil + if file_final_path.present? + final_path_store_dir = File.dirname(file_final_path) + final_path_filename = File.basename(file_final_path) + end + { file_store: file_store, - store_dir: file.store_dir.to_s, - file: file_identifier, + store_dir: final_path_store_dir || file.store_dir.to_s, + file: final_path_filename || file_identifier, pick_up_at: pick_up_at || expire_at || Time.current } end diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index 96e370bba1e..14c7ee14e71 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -8,7 +8,7 @@ module Ci class ProjectScopeLink < Ci::ApplicationRecord self.table_name = 'ci_job_token_project_scope_links' - PROJECT_LINK_DIRECTIONAL_LIMIT = 100 + PROJECT_LINK_DIRECTIONAL_LIMIT = 200 belongs_to :source_project, class_name: 'Project' # the project added to the scope's allowlist diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb index f713d5952bc..57e2d943a4c 100644 --- a/app/models/ci/persistent_ref.rb +++ b/app/models/ci/persistent_ref.rb @@ -11,7 +11,7 @@ module Ci delegate :project, :sha, to: :pipeline delegate :repository, to: :project - delegate :ref_exists?, :create_ref, :delete_refs, to: :repository + delegate :ref_exists?, :create_ref, :delete_refs, :async_delete_refs, to: :repository def exist? ref_exists?(path) @@ -42,6 +42,12 @@ module Ci .track_exception(e, pipeline_id: pipeline.id) end + def async_delete + return unless should_delete? + + async_delete_refs(path) + end + def path "refs/#{Repository::REF_PIPELINES}/#{pipeline.id}" end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index bd327cfbe7b..3a5db04a687 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -23,6 +23,7 @@ module Ci include IgnorableColumns ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' + ignore_column :auto_canceled_by_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' MAX_OPEN_MERGE_REQUESTS_REFS = 4 @@ -99,7 +100,7 @@ module Ci has_many :downloadable_artifacts, -> do not_expired.or(where_exists(Ci::Pipeline.artifacts_locked.where("#{Ci::Pipeline.quoted_table_name}.id = #{Ci::Build.quoted_table_name}.commit_id"))).downloadable.with_job end, through: :latest_builds, source: :job_artifacts - has_many :latest_successful_builds, -> { latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' + has_many :latest_successful_jobs, -> { latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Processable' has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline @@ -114,7 +115,7 @@ module Ci has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus', inverse_of: :pipeline - has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline + has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Processable', inverse_of: :pipeline has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: :auto_canceled_by_id, @@ -341,7 +342,9 @@ module Ci # This needs to be kept in sync with `Ci::PipelineRef#should_delete?` after_transition any => ::Ci::Pipeline.stopped_statuses do |pipeline| pipeline.run_after_commit do - if Feature.enabled?(:pipeline_cleanup_ref_worker_async, pipeline.project) + if Feature.enabled?(:pipeline_delete_gitaly_refs_in_batches, pipeline.project) + pipeline.persistent_ref.async_delete + elsif Feature.enabled?(:pipeline_cleanup_ref_worker_async, pipeline.project) ::Ci::PipelineCleanupRefWorker.perform_async(pipeline.id) else pipeline.persistent_ref.delete @@ -409,6 +412,7 @@ module Ci joins(:pipeline_metadata).where(name_column.eq(name)) end + scope :for_status, -> (status) { where(status: status) } scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) } scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) } scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } @@ -960,11 +964,15 @@ module Ci Ci::Bridge.latest.where(pipeline: self_and_project_descendants) end + def jobs_in_self_and_project_descendants + Ci::Processable.latest.where(pipeline: self_and_project_descendants) + end + def environments_in_self_and_project_descendants(deployment_status: nil) # We limit to 100 unique environments for application safety. # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 expanded_environment_names = - builds_in_self_and_project_descendants.joins(:metadata) + jobs_in_self_and_project_descendants.joins(:metadata) .where.not(Ci::BuildMetadata.table_name => { expanded_environment_name: nil }) .distinct("#{Ci::BuildMetadata.quoted_table_name}.expanded_environment_name") .limit(100) diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb index ba20c993e36..37916c0b302 100644 --- a/app/models/ci/pipeline_chat_data.rb +++ b/app/models/ci/pipeline_chat_data.rb @@ -3,6 +3,9 @@ module Ci class PipelineChatData < Ci::ApplicationRecord include Ci::NamespacedModelName + include IgnorableColumns + + ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' self.table_name = 'ci_pipeline_chat_data' diff --git a/app/models/ci/pipeline_message.rb b/app/models/ci/pipeline_message.rb index 5668da915e6..c997ec5cd62 100644 --- a/app/models/ci/pipeline_message.rb +++ b/app/models/ci/pipeline_message.rb @@ -2,6 +2,10 @@ module Ci class PipelineMessage < Ci::ApplicationRecord + include IgnorableColumns + + ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-09-22' + MAX_CONTENT_LENGTH = 10_000 belongs_to :pipeline diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index 9747f9ef527..a422aaa7daa 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -9,7 +9,6 @@ module Ci include SafelyChangeColumnDefault columns_changing_default :partition_id - ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' belongs_to :pipeline diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 4c421f066f9..7ad1a727a0e 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -6,6 +6,7 @@ module Ci class Processable < ::CommitStatus include Gitlab::Utils::StrongMemoize include FromUnion + include Ci::Metadatable extend ::Gitlab::Utils::Override has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable @@ -16,6 +17,7 @@ module Ci accepts_nested_attributes_for :needs scope :preload_needs, -> { preload(:needs) } + scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } scope :with_needs, -> (names = nil) do needs = Ci::BuildNeed.scoped_build.select(1) @@ -138,6 +140,10 @@ module Ci raise NotImplementedError end + def other_manual_actions + pipeline.manual_actions.reject { |action| action.name == name } + end + def when read_attribute(:when) || 'on_success' end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 4eb5c3c9ed2..8d93429fd24 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -87,19 +87,23 @@ module Ci scope :active, -> (value = true) { where(active: value) } scope :paused, -> { active(false) } - scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) } + scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) } scope :recent, -> do - where('ci_runners.created_at >= :datetime OR ci_runners.contacted_at >= :datetime', datetime: stale_deadline) + timestamp = stale_deadline + + where(arel_table[:created_at].gteq(timestamp).or(arel_table[:contacted_at].gteq(timestamp))) end scope :stale, -> do - where('ci_runners.created_at <= :datetime AND ' \ - '(ci_runners.contacted_at IS NULL OR ci_runners.contacted_at <= :datetime)', datetime: stale_deadline) + timestamp = stale_deadline + + where(arel_table[:created_at].lteq(timestamp)) + .where(arel_table[:contacted_at].eq(nil).or(arel_table[:contacted_at].lteq(timestamp))) end scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } scope :never_contacted, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } - scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } + scope :with_recent_runner_queue, -> { where(arel_table[:contacted_at].gt(recent_queue_deadline)) } scope :with_running_builds, -> do where('EXISTS(?)', ::Ci::Build.running.select(1) @@ -513,7 +517,7 @@ module Ci private scope :with_upgrade_status, ->(upgrade_status) do - joins(:runner_version).where(runner_version: { status: upgrade_status }) + joins(:runner_managers).merge(RunnerManager.with_upgrade_status(upgrade_status)) end EXECUTOR_NAME_TO_TYPES = { diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb index 3a3f95a8c69..7d8fc097f51 100644 --- a/app/models/ci/runner_manager.rb +++ b/app/models/ci/runner_manager.rb @@ -14,7 +14,8 @@ module Ci belongs_to :runner - has_many :runner_manager_builds, inverse_of: :runner_manager, class_name: 'Ci::RunnerManagerBuild' + has_many :runner_manager_builds, inverse_of: :runner_manager, foreign_key: :runner_machine_id, + class_name: 'Ci::RunnerManagerBuild' has_many :builds, through: :runner_manager_builds, class_name: 'Ci::Build' belongs_to :runner_version, inverse_of: :runner_managers, primary_key: :version, foreign_key: :version, class_name: 'Ci::RunnerVersion' @@ -48,6 +49,23 @@ module Ci where(runner_id: runner_id) end + scope :with_running_builds, -> do + where('EXISTS(?)', + Ci::Build.select(1) + .joins(:runner_manager_build) + .running + .where("#{::Ci::Build.quoted_table_name}.runner_id = #{quoted_table_name}.runner_id") + .where("#{::Ci::RunnerManagerBuild.quoted_table_name}.runner_machine_id = #{quoted_table_name}.id") + .limit(1) + ) + end + + scope :order_id_desc, -> { order(id: :desc) } + + scope :with_upgrade_status, ->(upgrade_status) do + joins(:runner_version).where(runner_version: { status: upgrade_status }) + end + def self.online_contact_time_deadline Ci::Runner.online_contact_time_deadline end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index 4853c57d41f..5b6946b04fd 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -6,6 +6,11 @@ module Ci include Ci::Partitionable include Ci::NamespacedModelName include SafelyChangeColumnDefault + include IgnorableColumns + + ignore_columns [ + :pipeline_id_convert_to_bigint, :source_pipeline_id_convert_to_bigint + ], remove_with: '16.6', remove_after: '2023-10-22' columns_changing_default :partition_id diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 4f9a2e44562..3a498972153 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -8,9 +8,12 @@ module Ci include Gitlab::OptimisticLocking include Presentable include SafelyChangeColumnDefault + include IgnorableColumns columns_changing_default :partition_id + ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' + partitionable scope: :pipeline enum status: Ci::HasStatus::STATUSES_ENUM @@ -151,7 +154,7 @@ module Ci end def manual_playable? - blocked? + blocked? || skipped? end # This will be removed with ci_remove_ensure_stage_service diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 9cae71809fd..f9a34959675 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -45,7 +45,6 @@ module Clusters end has_many :kubernetes_namespaces - has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :provider_aws, update_only: true diff --git a/app/models/commit.rb b/app/models/commit.rb index ded4b06a028..d7aa66588d3 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -29,7 +29,8 @@ class Commit delegate :project, to: :repository, allow_nil: true MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH - COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze + 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 # Used by GFM to match and present link extensions on node texts and hrefs. LINK_EXTENSION_PATTERN = /(patch)/.freeze diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index edc60a757d2..993e1af20d5 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -24,8 +24,12 @@ class CommitCollection commits.each(&block) end - def committers - emails = without_merge_commits.filter_map(&:committer_email).uniq + def committers(with_merge_commits: false) + emails = if with_merge_commits + commits.filter_map(&:committer_email).uniq + else + without_merge_commits.filter_map(&:committer_email).uniq + end User.by_any_email(emails) end diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index c6e507e4b6c..d882a185464 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -31,9 +31,8 @@ class CommitRange REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/.freeze PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/.freeze - # In text references, the beginning and ending refs can only be SHAs - # between 7 and 40 hex characters. - STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/.freeze + # 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 def self.reference_prefix '@' diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 3f631f583b6..c2425e9460a 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -9,10 +9,16 @@ class CommitStatus < Ci::ApplicationRecord include BulkInsertableAssociations include TaggableQueries + ROUTING_FEATURE_FLAG = :ci_partitioning_use_ci_builds_routing_table + self.table_name = 'ci_builds' self.sequence_name = 'ci_builds_id_seq' self.primary_key = :id - partitionable scope: :pipeline + + partitionable scope: :pipeline, through: { + table: :p_ci_builds, + flag: ROUTING_FEATURE_FLAG + } belongs_to :user belongs_to :project diff --git a/app/models/concerns/application_setting_masked_attrs.rb b/app/models/concerns/application_setting_masked_attrs.rb deleted file mode 100644 index 14a7185e39e..00000000000 --- a/app/models/concerns/application_setting_masked_attrs.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -# Similar to MASK_PASSWORD mechanism we do for EE, see: -# https://gitlab.com/gitlab-org/gitlab/-/blob/463bb1f855d71fadef931bd50f1692ee04f211a8/ee/app/models/ee/application_setting.rb#L15 -# but for non-EE attributes. -module ApplicationSettingMaskedAttrs - MASK = '*****' - - def ai_access_token=(value) - return if value == MASK - - super - end -end diff --git a/app/models/concerns/approvable.rb b/app/models/concerns/approvable.rb index 55e138d84fb..b2828821c70 100644 --- a/app/models/concerns/approvable.rb +++ b/app/models/concerns/approvable.rb @@ -14,6 +14,7 @@ module Approvable with_approvals .merge(Approval.with_user) .where(users: { id: user_ids }) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422085') .group(:id) .having("COUNT(users.id) = ?", user_ids.size) end @@ -21,6 +22,7 @@ module Approvable with_approvals .merge(Approval.with_user) .where(users: { username: usernames }) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422085') .group(:id) .having("COUNT(users.id) = ?", usernames.size) end @@ -34,7 +36,7 @@ module Approvable .where(app_table[:merge_request_id].eq(arel_table[:id])) .select('true') .arel.exists.not - ) + ).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422085') end end @@ -50,8 +52,12 @@ module Approvable approvals.where(user: user).any? end + def approved? + approvals.present? + end + def eligible_for_approval_by?(user) - user && !approved_by?(user) && user.can?(:approve_merge_request, self) + user.present? && !approved_by?(user) && user.can?(:approve_merge_request, self) end def eligible_for_unapproval_by?(user) diff --git a/app/models/concerns/ci/deployable.rb b/app/models/concerns/ci/deployable.rb new file mode 100644 index 00000000000..b3b80989410 --- /dev/null +++ b/app/models/concerns/ci/deployable.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +# rubocop:disable Gitlab/StrongMemoizeAttr +module Ci + module Deployable + extend ActiveSupport::Concern + + included do + prepend_mod_with('Ci::Deployable') # rubocop: disable Cop/InjectEnterpriseEditionModule + + has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable + + state_machine :status do + after_transition any => [:success] do |job| + job.run_after_commit do + Environments::StopJobSuccessWorker.perform_async(id) + end + end + + # Synchronize Deployment Status + # Please note that the data integirty is not assured because we can't use + # a database transaction due to DB decomposition. + after_transition do |job, transition| + next if transition.loopback? + next unless job.project + + job.run_after_commit do + job.deployment&.sync_status_with(job) + end + end + end + end + + def outdated_deployment? + strong_memoize(:outdated_deployment) do + deployment_job? && + project.ci_forward_deployment_enabled? && + (!project.ci_forward_deployment_rollback_allowed? || incomplete?) && + deployment&.older_than_last_successful_deployment? + end + end + + # Virtual deployment status depending on the environment status. + def deployment_status + return unless deployment_job? + + if success? + return successful_deployment_status + elsif failed? + return :failed + end + + :creating + end + + def successful_deployment_status + if deployment&.last? + :last + else + :out_of_date + end + end + + def persisted_environment + return unless has_environment_keyword? + + strong_memoize(:persisted_environment) do + # This code path has caused N+1s in the past, since environments are only indirectly + # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445 + # We therefore batch-load them to prevent dormant N+1s until we found a proper solution. + BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args| + Environment.where(name: names, project: args[:key]).find_each do |environment| + loader.call(environment.name, environment) + end + end + end + end + + def persisted_environment=(environment) + strong_memoize(:persisted_environment) { environment } + end + + # If build.persisted_environment is a BatchLoader, we need to remove + # the method proxy in order to clone into new item here + # https://github.com/exAspArk/batch-loader/issues/31 + def actual_persisted_environment + persisted_environment.respond_to?(:__sync) ? persisted_environment.__sync : persisted_environment + end + + def expanded_environment_name + return unless has_environment_keyword? + + strong_memoize(:expanded_environment_name) do + # We're using a persisted expanded environment name in order to avoid + # variable expansion per request. + if metadata&.expanded_environment_name.present? + metadata.expanded_environment_name + else + ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all }) + end + end + end + + def expanded_kubernetes_namespace + return unless has_environment_keyword? + + namespace = options.dig(:environment, :kubernetes, :namespace) + + if namespace.present? # rubocop:disable Style/GuardClause + strong_memoize(:expanded_kubernetes_namespace) do + ExpandVariables.expand(namespace, -> { simple_variables }) + end + end + end + + def has_environment_keyword? + environment.present? + end + + def deployment_job? + has_environment_keyword? && environment_action == 'start' + end + + def stops_environment? + has_environment_keyword? && environment_action == 'stop' + end + + def environment_action + options.fetch(:environment, {}).fetch(:action, 'start') if options + end + + def environment_tier_from_options + options.dig(:environment, :deployment_tier) if options + end + + def environment_tier + environment_tier_from_options || persisted_environment.try(:tier) + end + + def environment_url + options&.dig(:environment, :url) || persisted_environment&.external_url + end + + def environment_status + strong_memoize(:environment_status) do + if has_environment_keyword? && merge_request + EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha) + end + end + end + + def on_stop + options&.dig(:environment, :on_stop) + end + + def stop_action_successful? + success? + end + end +end +# rubocop:enable Gitlab/StrongMemoizeAttr diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 1c6b82d6ea7..b785e39523d 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -24,6 +24,12 @@ module Ci delegate :id_tokens, to: :metadata, allow_nil: true before_validation :ensure_metadata, on: :create + + scope :with_project_and_metadata, -> do + if Feature.enabled?(:non_public_artifacts, type: :development) + joins(:metadata).includes(:metadata).preload(:project) + end + end end def has_exposed_artifacts? diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index a3bcc7bcbbc..ec6c85d888d 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -80,6 +80,7 @@ module Ci def handle_partitionable_through(options) return unless options + return if Gitlab::Utils.to_boolean(ENV['DISABLE_PARTITIONABLE_SWITCH'], default: false) define_singleton_method(:routing_table_name) { options[:table] } define_singleton_method(:routing_table_name_flag) { options[:flag] } diff --git a/app/models/concerns/ci/partitionable/switch.rb b/app/models/concerns/ci/partitionable/switch.rb index c1bbd107e9f..6195f92114f 100644 --- a/app/models/concerns/ci/partitionable/switch.rb +++ b/app/models/concerns/ci/partitionable/switch.rb @@ -2,6 +2,8 @@ module Ci module Partitionable + MUTEX = Mutex.new + module Switch extend ActiveSupport::Concern @@ -14,18 +16,39 @@ module Ci predicate_builder cached_find_by_statement].freeze included do |base| - partitioned = Class.new(base) do - self.table_name = base.routing_table_name + install_partitioned_class(base) + end + + class_methods do + # `Class.new(partitionable_model)` triggers `partitionable_model.inherited` + # and we need the mutex to break the recursion without adding extra accessors + # on the model. This will be used during code loading, not runtime. + # + def install_partitioned_class(partitionable_model) + Partitionable::MUTEX.synchronize do + partitioned = Class.new(partitionable_model) do + self.table_name = partitionable_model.routing_table_name + + def self.routing_class? + true + end + + def self.sti_name + superclass.sti_name + end + end - def self.routing_class? - true + partitionable_model.const_set(:Partitioned, partitioned) end end - base.const_set(:Partitioned, partitioned) - end + def inherited(child_class) + super + return if Partitionable::MUTEX.owned? + + install_partitioned_class(child_class) + end - class_methods do def routing_class? false end @@ -51,6 +74,13 @@ module Ci end end end + + def type_condition(table = arel_table) + sti_column = table[inheritance_column] + sti_names = ([self] + descendants).map(&:sti_name).uniq + + predicate_builder.build(sti_column, sti_names) + end end end end diff --git a/app/models/concerns/cross_database_ignored_tables.rb b/app/models/concerns/cross_database_ignored_tables.rb new file mode 100644 index 00000000000..c97e405cce4 --- /dev/null +++ b/app/models/concerns/cross_database_ignored_tables.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module CrossDatabaseIgnoredTables + extend ActiveSupport::Concern + + class_methods do + def cross_database_ignore_tables(tables, options = {}) + raise "missing issue url" if options[:url].blank? + + options[:on] = %I[save destroy] if options[:on].blank? + events = Array.wrap(options[:on]) + tables = Array.wrap(tables) + + events.each do |event| + register_ignored_cross_database_event(tables, event, options) + end + end + + private + + def register_ignored_cross_database_event(tables, event, options) + case event + when :save + around_save(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) } + when :create + around_create(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) } + when :update + around_update(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) } + when :destroy + around_destroy(prepend: true) { |_, blk| temporary_ignore_cross_database_tables(tables, options, &blk) } + else + raise "Unknown #{event}" + end + end + end + + private + + def temporary_ignore_cross_database_tables(tables, options, &blk) + 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 + ) + end +end diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 79fb81e7820..945d286a2fd 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -219,6 +219,7 @@ module EachBatch new_count, last_value = unscoped .from(inner_query) + .unscope(where: :type) .order(count: :desc) .limit(1) .pick(:count, column) diff --git a/app/models/concerns/enum_inheritance.rb b/app/models/concerns/enum_inheritance.rb new file mode 100644 index 00000000000..1df1f3d43fd --- /dev/null +++ b/app/models/concerns/enum_inheritance.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module EnumInheritance + # == STI through Enum + # + # WARNING: Usage of STI is heavily discouraged: https://docs.gitlab.com/ee/development/database/single_table_inheritance.html + # + # Active Record allows definition of STI through the <tt>Base.inheritance_column</tt>. However, this stores the class + # name as string into the record, which is heavy and unnecessary. EnumInheritance adapts ActiveRecord to use an enum + # instead. + # + # Details: + # - Correct class mapping is specified in the <tt>self.sti_type_map<\tt>, which maps the symbol of the type to + # a fully classified class as string. + # - If the type passed does not have an specified class, then the class will be the base class + # + # Example + # class Animal + # include EnumInheritable + # + # enum animal_type: { + # dog: 1, + # cat: 2, + # bird: 3 + # } + # + # def self.inheritance_column_to_class_map = { + # dog: 'Animals::Dog', + # cat: 'Animals::Cat' + # } + # + # def self.inheritance_column = 'animal_type' + # end + # + # class Animals::Dog < Animal; end + # class Animals::Cat < Animal; end + extend ActiveSupport::Concern + + included do + def self.sti_class_to_enum_map = inheritance_column_to_class_map.invert + end + + class_methods do + extend ::Gitlab::Utils::Override + + def inheritance_column_to_class_map = {}.freeze + + override :sti_class_for + def sti_class_for(type_name) + inheritance_column_to_class_map[type_name.to_sym]&.constantize || base_class + end + + override :sti_name + def sti_name + sti_class_to_enum_map[name].to_s + end + end +end diff --git a/app/models/concerns/from_union.rb b/app/models/concerns/from_union.rb index be6744f1b2a..e816608265b 100644 --- a/app/models/concerns/from_union.rb +++ b/app/models/concerns/from_union.rb @@ -32,6 +32,9 @@ module FromUnion # remove_duplicates - A boolean indicating if duplicate entries should be # removed. Defaults to true. # + # remove_order - A boolean indicating if the order from the relations should be + # removed. Defaults to true. + # # alias_as - The alias to use for the sub query. Defaults to the name of the # table of the current model. # rubocop: disable Gitlab/Union diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index d614d6c4584..0e7381882b5 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -119,6 +119,9 @@ module HasRepository def after_repository_change_head reload_default_branch + + Gitlab::EventStore.publish( + Repositories::DefaultBranchChangedEvent.new(data: { container_id: id, container_type: self.class.name })) end def after_change_head_branch_does_not_exist(branch) diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb index 7f29083d6c6..e884e5acecf 100644 --- a/app/models/concerns/issuable_link.rb +++ b/app/models/concerns/issuable_link.rb @@ -21,6 +21,10 @@ module IssuableLink raise NotImplementedError end + def issuable_name + issuable_type.to_s.humanize(capitalize: false) + end + # Used to get the available types for the API # overriden in EE def available_link_types @@ -53,7 +57,7 @@ module IssuableLink return unless source && target if self.class.base_class.find_by(source: target, target: source) - errors.add(:source, "is already related to this #{self.class.issuable_type}") + errors.add(:source, "is already related to this #{self.class.issuable_name}") end end end diff --git a/app/models/concerns/linkable_item.rb b/app/models/concerns/linkable_item.rb new file mode 100644 index 00000000000..135252727ab --- /dev/null +++ b/app/models/concerns/linkable_item.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# == LinkableItem concern +# +# Contains common functionality shared between related issue links and related work item links +# +# Used by IssueLink, WorkItems::RelatedWorkItemLink +# +module LinkableItem + extend ActiveSupport::Concern + include FromUnion + include IssuableLink + + included do + validate :check_existing_parent_link + + scope :for_source, ->(item) { where(source_id: item.id) } + scope :for_target, ->(item) { where(target_id: item.id) } + scope :for_items, ->(source, target) do + where(source: source, target: target).or(where(source: target, target: source)) + end + + private + + def check_existing_parent_link + return unless source && target + + existing_relation = WorkItems::ParentLink.for_parents([source, target]).for_children([source, target]) + return if existing_relation.none? + + errors.add(:source, format(_('is a parent or child of this %{item}'), item: self.class.issuable_name)) + end + end +end + +LinkableItem.include_mod_with('LinkableItem::Callbacks') +LinkableItem.prepend_mod_with('LinkableItem') diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index e95a8a42aa6..b72d99d211c 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -52,7 +52,9 @@ module Milestoneable def milestone_available? return true if milestone_id.blank? - project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) + (project_id.present? && project_id == milestone&.project_id) || + try(:namespace)&.self_and_ancestors&.include?(milestone&.group) || + project&.ancestors_upto&.compact&.include?(milestone&.group) end ## diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 5c91f2460c4..40a91c8ac94 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -17,7 +17,7 @@ module Noteable # `Noteable` class names that support resolvable notes. def resolvable_types - %w(MergeRequest DesignManagement::Design) + %w(Issue MergeRequest DesignManagement::Design) end # `Noteable` class names that support creating/forwarding individual notes. @@ -49,6 +49,8 @@ module Noteable end def supports_resolvable_notes? + return false if is_a?(Issue) && Feature.disabled?(:resolvable_issue_threads, project) + self.class.resolvable_types.include?(base_class_name) end @@ -171,9 +173,9 @@ module Noteable return unless etag_caching_enabled? # TODO: We need to figure out a way to make ETag caching work for group-level work items - return if is_a?(Issue) && project.nil? + Gitlab::EtagCaching::Store.new.touch(note_etag_key) unless is_a?(Issue) && project.nil? - Gitlab::EtagCaching::Store.new.touch(note_etag_key) + Noteable::NotesChannel.broadcast_to(self, event: 'updated') if Feature.enabled?(:action_cable_notes, project || try(:group)) end def note_etag_key diff --git a/app/models/concerns/packages/nuget/version_normalizable.rb b/app/models/concerns/packages/nuget/version_normalizable.rb new file mode 100644 index 00000000000..473e5f07811 --- /dev/null +++ b/app/models/concerns/packages/nuget/version_normalizable.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module VersionNormalizable + extend ActiveSupport::Concern + + LEADING_ZEROES_REGEX = /^(?!0$)0+(?=\d)/ + + included do + before_validation :set_normalized_version, on: %i[create update] + + private + + def set_normalized_version + return unless package && Feature.enabled?(:nuget_normalized_version, package.project) + + self.normalized_version = normalize + end + + def normalize + version = remove_leading_zeroes + version = remove_build_metadata(version) + version = omit_zero_in_fourth_part(version) + append_suffix(version) + end + + def remove_leading_zeroes + package_version.split('.').map { |part| part.sub(LEADING_ZEROES_REGEX, '') }.join('.') + end + + def remove_build_metadata(version) + version.split('+').first.downcase + end + + def omit_zero_in_fourth_part(version) + parts = version.split('.') + parts[3] = nil if parts.fourth == '0' && parts.third.exclude?('-') + parts.compact.join('.') + end + + def append_suffix(version) + version << '.0.0' if version.count('.') == 0 + version << '.0' if version.count('.') == 1 + version + end + end + end + end +end diff --git a/app/models/concerns/reset_on_union_error.rb b/app/models/concerns/reset_on_union_error.rb new file mode 100644 index 00000000000..42e350b0bed --- /dev/null +++ b/app/models/concerns/reset_on_union_error.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ResetOnUnionError + extend ActiveSupport::Concern + + MAX_RESET_PERIOD = 10.minutes + + included do |base| + base.rescue_from ActiveRecord::StatementInvalid, with: :reset_on_union_error + + base.class_attribute :previous_reset_columns_from_error + end + + class_methods do + def reset_on_union_error(exception) + if reset_on_statement_invalid?(exception) + class_to_be_reset = base_class + + class_to_be_reset.reset_column_information + Gitlab::ErrorTracking.log_exception(exception, { reset_model_name: class_to_be_reset.name }) + + class_to_be_reset.previous_reset_columns_from_error = Time.current + end + + raise + end + + def reset_on_statement_invalid?(exception) + return false unless exception.message.include?("each UNION query must have the same number of columns") + + return false if base_class.previous_reset_columns_from_error? && + base_class.previous_reset_columns_from_error > MAX_RESET_PERIOD.ago + + Feature.enabled?(:reset_column_information_on_statement_invalid, type: :ops) + end + end +end diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index 45818942326..e967c78154d 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -116,6 +116,8 @@ 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 + clear_memoized_values end end diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb index 4e8a1bb643e..7f9a7faa3f5 100644 --- a/app/models/concerns/resolvable_note.rb +++ b/app/models/concerns/resolvable_note.rb @@ -23,12 +23,13 @@ module ResolvableNote class_methods do # This method must be kept in sync with `#resolve!` def resolve!(current_user) - unresolved.update_all(resolved_at: Time.current, resolved_by_id: current_user.id) + now = Time.current + unresolved.update_all(updated_at: now, resolved_at: now, resolved_by_id: current_user.id) end # This method must be kept in sync with `#unresolve!` def unresolve! - resolved.update_all(resolved_at: nil, resolved_by_id: nil) + resolved.update_all(updated_at: Time.current, resolved_at: nil, resolved_by_id: nil) end end @@ -57,7 +58,9 @@ module ResolvableNote return false unless resolvable? return false if resolved? - self.resolved_at = Time.current + now = Time.current + self.updated_at = now + self.resolved_at = now self.resolved_by = current_user self.resolved_by_push = resolved_by_push @@ -69,6 +72,7 @@ module ResolvableNote return false unless resolvable? return false unless resolved? + self.updated_at = Time.current self.resolved_at = nil self.resolved_by = nil diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index d70aad4e9ae..f2badfe48dd 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -25,17 +25,19 @@ 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. - route = - route_scope.find_by(routes: { path: path }) || - route_scope.iwhere(Route.arel_table[:path] => path).take + 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 + if follow_redirects + route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take + end - return unless route + next unless route - route.is_a?(Routable) ? route : route.source + route.is_a?(Routable) ? route : route.source + end end included do @@ -46,7 +48,9 @@ module Routable validates :route, presence: true, unless: -> { is_a?(Namespaces::ProjectNamespace) } - scope :with_route, -> { includes(:route) } + scope :with_route, -> do + includes(:route).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') + end after_validation :set_path_errors @@ -94,7 +98,9 @@ module Routable joins(:route) end - route.where(wheres.join(' OR ')) + route + .where(wheres.join(' OR ')) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") end end diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 2b7447dc700..0f361e70a91 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -17,8 +17,8 @@ module TimeTrackable attribute :time_estimate, default: 0 - validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false - validate :check_negative_time_spent + validate :check_time_estimate + validate :check_negative_time_spent has_many :timelogs, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent after_initialize :set_time_estimate_default_value @@ -106,4 +106,11 @@ module TimeTrackable def original_total_time_spent @original_total_time_spent ||= total_time_spent end + + def check_time_estimate + return unless new_record? || time_estimate_changed? + return if time_estimate.is_a?(Numeric) && time_estimate >= 0 + + errors.add(:time_estimate, _('must have a valid format and be greater than or equal to zero.')) + end end diff --git a/app/models/concerns/web_hooks/auto_disabling.rb b/app/models/concerns/web_hooks/auto_disabling.rb index 2ad2e47ec4e..72812f35f72 100644 --- a/app/models/concerns/web_hooks/auto_disabling.rb +++ b/app/models/concerns/web_hooks/auto_disabling.rb @@ -3,6 +3,7 @@ module WebHooks module AutoDisabling extend ActiveSupport::Concern + include ::Gitlab::Loggable ENABLED_HOOK_TYPES = %w[ProjectHook].freeze MAX_FAILURES = 100 @@ -36,7 +37,9 @@ module WebHooks # - and either: # - disabled_until is nil (i.e. this was set by WebHook#fail!) # - or disabled_until is in the future (i.e. this was set by WebHook#backoff!) + # - OR silent mode is enabled. scope :disabled, -> do + return all if Gitlab::SilentMode.enabled? return none unless auto_disabling_enabled? where( @@ -52,7 +55,9 @@ module WebHooks # - OR we have exceeded the grace period and neither of the following is true: # - disabled_until is nil (i.e. this was set by WebHook#fail!) # - disabled_until is in the future (i.e. this was set by WebHook#backoff!) + # - AND silent mode is not enabled. scope :executable, -> do + return none if Gitlab::SilentMode.enabled? return all unless auto_disabling_enabled? where( @@ -82,17 +87,14 @@ module WebHooks recent_failures > FAILURE_THRESHOLD && disabled_until.blank? end - def disable! - return if !auto_disabling_enabled? || permanently_disabled? - - update_attribute(:recent_failures, EXCEEDED_FAILURE_THRESHOLD) - end - def enable! return unless auto_disabling_enabled? return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0 - assign_attributes(recent_failures: 0, disabled_until: nil, backoff_count: 0) + attrs = { recent_failures: 0, disabled_until: nil, backoff_count: 0 } + + assign_attributes(attrs) + logger.info(hook_id: id, action: 'enable', **attrs) save(validate: false) end @@ -110,14 +112,21 @@ module WebHooks end assign_attributes(attrs) - save(validate: false) if changed? + + return unless changed? + + logger.info(hook_id: id, action: 'backoff', **attrs) + save(validate: false) end def failed! return unless auto_disabling_enabled? return unless recent_failures < MAX_FAILURES - assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count) + attrs = { disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count } + + assign_attributes(**attrs) + logger.info(hook_id: id, action: 'disable', **attrs) save(validate: false) end @@ -143,6 +152,10 @@ module WebHooks private + def logger + @logger ||= Gitlab::WebHooks::Logger.build + end + def next_failure_count recent_failures.succ.clamp(1, MAX_FAILURES) end diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index 16c741d340f..f99b8fa5549 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -35,6 +35,22 @@ class CustomerRelations::Contact < ApplicationRecord scope :order_by_organization_asc, -> { includes(:organization).order("customer_relations_organizations.name ASC NULLS LAST") } scope :order_by_organization_desc, -> { includes(:organization).order("customer_relations_organizations.name DESC NULLS LAST") } + SAFE_ATTRIBUTES = %w[ + created_at + description + first_name + group_id + id + last_name + organization_id + state + updated_at + ].freeze + + def hook_attrs + attributes.slice(*SAFE_ATTRIBUTES) + end + def self.reference_prefix '[contact:' end diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb index 11fe0503f50..702e1679f6a 100644 --- a/app/models/dependency_proxy/manifest.rb +++ b/app/models/dependency_proxy/manifest.rb @@ -15,7 +15,8 @@ class DependencyProxy::Manifest < ApplicationRecord ACCEPTED_TYPES = [ ContainerRegistry::BaseClient::DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, ContainerRegistry::BaseClient::OCI_MANIFEST_V1_TYPE, - ContainerRegistry::BaseClient::OCI_DISTRIBUTION_INDEX_TYPE + ContainerRegistry::BaseClient::OCI_DISTRIBUTION_INDEX_TYPE, + ContainerRegistry::BaseClient::DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE ].freeze validates :group, presence: true diff --git a/app/models/deployment.rb b/app/models/deployment.rb index b59b22c10c4..0bdce18bab5 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -67,7 +67,7 @@ class Deployment < ApplicationRecord state_machine :status, initial: :created do event :run do - transition created: :running + transition [:created, :blocked] => :running end event :block do @@ -79,10 +79,6 @@ class Deployment < ApplicationRecord transition skipped: :created end - event :unblock do - transition blocked: :created - end - event :succeed do transition any - [:success] => :success end @@ -184,23 +180,23 @@ class Deployment < ApplicationRecord # - deploy job B => production environment # In this case, `last_deployment_group` returns both deployments. # - # NOTE: Preload environment.last_deployment and pipeline.latest_successful_builds prior to avoid N+1. + # NOTE: Preload environment.last_deployment and pipeline.latest_successful_jobs prior to avoid N+1. def self.last_deployment_group_for_environment(env) - return self.none unless env.last_deployment_pipeline&.latest_successful_builds&.present? + return self.none unless env.last_deployment_pipeline&.latest_successful_jobs&.present? BatchLoader.for(env).batch(default_value: self.none) do |environments, loader| - latest_successful_build_ids = [] + latest_successful_job_ids = [] environments_hash = {} environments.each do |environment| environments_hash[environment.id] = environment # Refer comment note above, if not preloaded this can lead to N+1. - latest_successful_build_ids << environment.last_deployment_pipeline.latest_successful_builds.map(&:id) + latest_successful_job_ids << environment.last_deployment_pipeline.latest_successful_jobs.map(&:id) end Deployment - .where(deployable_type: 'CommitStatus', deployable_id: latest_successful_build_ids.flatten) + .where(deployable_type: 'CommitStatus', deployable_id: latest_successful_job_ids.flatten) .preload(last_deployment_group_associations) .group_by { |deployment| deployment.environment_id } .each do |env_id, deployment_group| @@ -217,14 +213,14 @@ class Deployment < ApplicationRecord # Fetching any unbounded or large intermediate dataset could lead to loading too many IDs into memory. # See: https://docs.gitlab.com/ee/development/database/multiple_databases.html#use-disable_joins-for-has_one-or-has_many-through-relations # For safety we default limit to fetch not more than 1000 records. - def self.builds(limit = 1000) + def self.jobs(limit = 1000) deployable_ids = where.not(deployable_id: nil).limit(limit).pluck(:deployable_id) - Ci::Build.where(id: deployable_ids) + Ci::Processable.where(id: deployable_ids) end - def build - deployable if deployable.is_a?(::Ci::Build) + def job + deployable if deployable.is_a?(::Ci::Processable) end class << self @@ -289,8 +285,8 @@ class Deployment < ApplicationRecord @scheduled_actions ||= deployable.try(:other_scheduled_actions) end - def playable_build - strong_memoize(:playable_build) do + def playable_job + strong_memoize(:playable_job) do deployable.try(:playable?) ? deployable : nil end end @@ -355,8 +351,8 @@ class Deployment < ApplicationRecord end def deployed_by - # We use deployable's user if available because Ci::PlayBuildService - # does not update the deployment's user, just the one for the deployable. + # We use deployable's user if available because Ci::PlayBuildService and Ci::PlayBridgeService + # do not update the deployment's user, just the one for the deployable. # TODO: use deployment's user once https://gitlab.com/gitlab-org/gitlab-foss/issues/66442 # is completed. deployable&.user || user @@ -402,14 +398,17 @@ class Deployment < ApplicationRecord false end - def sync_status_with(build) - return false unless ::Deployment.statuses.include?(build.status) - return false if build.status == self.status + def sync_status_with(job) + job_status = job.status + job_status = 'blocked' if job_status == 'manual' + + return false unless ::Deployment.statuses.include?(job_status) + return false if job_status == self.status - update_status!(build.status) + update_status!(job_status) rescue StandardError => e Gitlab::ErrorTracking.track_exception( - StatusSyncError.new(e.message), deployment_id: self.id, build_id: build.id) + StatusSyncError.new(e.message), deployment_id: self.id, job_id: job.id) false end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index dc4794ed3cd..2d2519dc995 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -191,4 +191,8 @@ class Discussion def to_global_id(options = {}) GlobalID.new(::Gitlab::GlobalId.build(model_name: Discussion.to_s, id: id)) end + + def noteable_collection_name + noteable.class.underscore.pluralize + end end diff --git a/app/models/email.rb b/app/models/email.rb index 3896dfd5d22..5fca57520b8 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Email < ApplicationRecord +class Email < MainClusterwide::ApplicationRecord include Sortable include Gitlab::SQL::Pattern diff --git a/app/models/environment.rb b/app/models/environment.rb index 241b454f5ce..36445279b86 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -18,14 +18,13 @@ class Environment < ApplicationRecord belongs_to :cluster_agent, class_name: 'Clusters::Agent', optional: true, inverse_of: :environments use_fast_destroy :all_deployments - nullify_if_blank :external_url, :kubernetes_namespace + nullify_if_blank :external_url, :kubernetes_namespace, :flux_resource_path has_many :all_deployments, class_name: 'Deployment' has_many :deployments, -> { visible } 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 :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', 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 @@ -78,6 +77,10 @@ class Environment < ApplicationRecord message: Gitlab::Regex.kubernetes_namespace_regex_message } + validates :flux_resource_path, + length: { maximum: 255 }, + allow_nil: true + validates :tier, presence: true validate :safe_external_url @@ -331,9 +334,9 @@ class Environment < ApplicationRecord end def cancel_deployment_jobs! - active_deployments.builds.each do |build| - Gitlab::OptimisticLocking.retry_lock(build, name: 'environment_cancel_deployment_jobs') do |build| - build.cancel! if build&.cancelable? + active_deployments.jobs.each do |job| + Gitlab::OptimisticLocking.retry_lock(job, name: 'environment_cancel_deployment_jobs') do |job| + job.cancel! if job&.cancelable? end rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id) @@ -355,8 +358,12 @@ class Environment < ApplicationRecord Gitlab::OptimisticLocking.retry_lock( stop_action, name: 'environment_stop_with_actions' - ) do |build| - actions << build.play(current_user) + ) do |job| + actions << job.play(current_user) + rescue StateMachines::InvalidTransition + # Ci::PlayBuildService rescues an error of StateMachines::InvalidTransition and fall back to retry. However, + # Ci::PlayBridgeService doesn't rescue it, so we're ignoring the error if it's not playable. + # We should fix this inconsistency in https://gitlab.com/gitlab-org/gitlab/-/issues/420855. end end diff --git a/app/models/event.rb b/app/models/event.rb index 9345776c32b..4547d7b9e60 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -11,7 +11,7 @@ class Event < ApplicationRecord include ShaAttribute include IgnorableColumns - ignore_column :target_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :target_id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22' ACTIONS = HashWithIndifferentAccess.new( created: 1, diff --git a/app/models/group.rb b/app/models/group.rb index 2b5a392e02c..9df3c143e0c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -184,6 +184,7 @@ class Group < Namespace ids_by_full_path = Route .for_routable_type(Namespace.name) .where('LOWER(routes.path) IN (?)', paths.map(&:downcase)) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") .select(:namespace_id) Group.from_union([by_id(ids), by_id(ids_by_full_path), where('LOWER(path) IN (?)', paths.map(&:downcase))]) @@ -397,7 +398,7 @@ class Group < Namespace end def visibility_level_allowed_by_projects?(level = self.visibility_level) - !projects.where('visibility_level > ?', level).exists? + !projects.without_deleted.where('visibility_level > ?', level).exists? end def visibility_level_allowed_by_sub_groups?(level = self.visibility_level) @@ -635,19 +636,26 @@ class Group < Namespace end # Returns all members that are part of the group, it's subgroups, and ancestor groups - def direct_and_indirect_members + def hierarchy_members GroupMember .active_without_invites_and_requests .where(source_id: self_and_hierarchy.reorder(nil).select(:id)) end - def direct_and_indirect_members_with_inactive + def hierarchy_members_with_inactive GroupMember .non_request .non_invite .where(source_id: self_and_hierarchy.reorder(nil).select(:id)) end + def descendant_project_members_with_inactive + ProjectMember + .with_source_id(all_projects) + .non_request + .non_invite + end + def users_with_parents User .where(id: members_with_parents.select(:user_id)) @@ -660,45 +668,6 @@ class Group < Namespace .reorder(nil) end - # Returns all users that are members of the group because: - # 1. They belong to the group - # 2. They belong to a project that belongs to the group - # 3. They belong to a sub-group or project in such sub-group - # 4. They belong to an ancestor group - # 5. They belong to a group that is shared with this group, if share_with_groups is true - def direct_and_indirect_users(share_with_groups: false) - members = if share_with_groups - # We only need :user_id column, but - # `members_from_self_and_ancestor_group_shares` needs more - # columns to make the CTE query work. - GroupMember.from_union([ - direct_and_indirect_members.select(:user_id, :source_type, :type), - members_from_self_and_ancestor_group_shares.reselect(:user_id, :source_type, :type) - ]) - else - direct_and_indirect_members - end - - User.from_union([ - User.where(id: members.select(:user_id)).reorder(nil), - project_users_with_descendants - ]) - end - - # Returns all users (also inactive) that are members of the group because: - # 1. They belong to the group - # 2. They belong to a project that belongs to the group - # 3. They belong to a sub-group or project in such sub-group - # 4. They belong to an ancestor group - def direct_and_indirect_users_with_inactive - User.from_union([ - User - .where(id: direct_and_indirect_members_with_inactive.select(:user_id)) - .reorder(nil), - project_users_with_descendants - ]).allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") # failed in spec/tasks/gitlab/user_management_rake_spec.rb - end - def users_count members.count end @@ -925,6 +894,10 @@ class Group < Namespace feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc_2) end + def linked_work_items_feature_flag_enabled? + feature_flag_enabled_for_self_or_ancestor?(:linked_work_items) + end + def usage_quotas_enabled? ::Feature.enabled?(:usage_quotas_for_all_editions, self) && root? end @@ -951,7 +924,7 @@ class Group < Namespace end def update_two_factor_requirement_for_members - direct_and_indirect_members.find_each(&:update_two_factor_requirement) + hierarchy_members.find_each(&:update_two_factor_requirement) end def readme_project diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index dba52aa51cd..13f74b938af 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -13,6 +13,7 @@ class GroupGroupLink < ApplicationRecord validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) } + scope :for_shared_groups, -> (group_ids) { where(shared_group_id: group_ids) } scope :with_owner_or_maintainer_access, -> do where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER]) diff --git a/app/models/identity.rb b/app/models/identity.rb index df1185f330d..a4c59694050 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Identity < ApplicationRecord +class Identity < MainClusterwide::ApplicationRecord include Sortable include CaseSensitivity diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb index b41b4572e82..598b7e34738 100644 --- a/app/models/identity/uniqueness_scopes.rb +++ b/app/models/identity/uniqueness_scopes.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Identity < ApplicationRecord +class Identity < MainClusterwide::ApplicationRecord # This module and method are defined in a separate file to allow EE to # redefine the `scopes` method before it is used in the `Identity` model. module UniquenessScopes diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index 64c9680ce90..57638356362 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -53,7 +53,9 @@ class InstanceConfiguration diff_max_patch_bytes: application_settings[:diff_max_patch_bytes].bytes, max_artifacts_size: application_settings[:max_artifacts_size].megabytes, max_pages_size: application_settings[:max_pages_size] > 0 ? application_settings[:max_pages_size].megabytes : nil, - snippet_size_limit: application_settings[:snippet_size_limit]&.bytes + snippet_size_limit: application_settings[:snippet_size_limit]&.bytes, + max_import_remote_file_size: application_settings[:max_import_remote_file_size] > 0 ? application_settings[:max_import_remote_file_size].megabytes : 0, + bulk_import_max_download_file_size: application_settings[:bulk_import_max_download_file_size] > 0 ? application_settings[:bulk_import_max_download_file_size].megabytes : 0 } end diff --git a/app/models/integration.rb b/app/models/integration.rb index f823a385022..bc86b08018f 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -167,7 +167,7 @@ class Integration < ApplicationRecord raise ArgumentError, "Unknown field storage: #{storage}" end - boolean_accessor(name) if attrs[:type] == 'checkbox' && storage != :attribute + boolean_accessor(name) if attrs[:type] == :checkbox && storage != :attribute end # :nocov: @@ -472,7 +472,7 @@ class Integration < ApplicationRecord # 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 { |f| f[:type] == :password }.pluck(:name) end # Expose a list of fields in the JSON endpoint. @@ -517,11 +517,11 @@ class Integration < ApplicationRecord end def api_field_names - fields.reject { _1[:type] == 'password' || _1[:name] == 'webhook' }.pluck(:name) + fields.reject { _1[:type] == :password || _1[:name] == 'webhook' || (_1.key?(:if) && _1[:if] != true) }.pluck(:name) end def form_fields - fields.reject { _1[:api_only] == true } + fields.reject { _1[:api_only] == true || (_1.key?(:if) && _1[:if] != true) } end def configurable_events diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index a4036a82cec..6f96626718f 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -32,7 +32,7 @@ module Integrations field :app_store_private_key, api_only: true field :app_store_protected_refs, - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('AppleAppStore|Protected branches and tags only') }, checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') } diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index b8cfd718007..7436c08aa38 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -7,7 +7,7 @@ module Integrations validates :api_key, presence: true, if: :activated? field :api_key, - type: 'password', + type: :password, title: 'API key', 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') }, diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb index 536d5584bf6..6831fac32e6 100644 --- a/app/models/integrations/assembla.rb +++ b/app/models/integrations/assembla.rb @@ -5,7 +5,7 @@ module Integrations validates :token, presence: true, if: :activated? field :token, - type: 'password', + type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, placeholder: '', diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 4638ca0c5f1..0b8432136dd 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -24,7 +24,7 @@ module Integrations help: -> { s_('BambooService|The user with API access to the Bamboo server.') } field :password, - type: 'password', + type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new password') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') } diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index c9de4d2b3bb..4d207574ca7 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -23,7 +23,6 @@ module Integrations ].freeze SECRET_MASK = '************' - CHANNEL_LIMIT_PER_EVENT = 10 attribute :category, default: 'chat' @@ -79,27 +78,27 @@ module Integrations def default_fields [ { - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION, name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, { - type: 'select', + 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', + 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', + type: :select, section: SECTION_TYPE_CONFIGURATION, name: 'labels_to_be_notified_behavior', choices: [ @@ -111,8 +110,8 @@ module Integrations next unless requires_webhook? fields.unshift( - { type: 'text', name: 'webhook', help: webhook_help, required: true }.freeze, - { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze + { type: :text, name: 'webhook', help: webhook_help, required: true }.freeze, + { type: :text, name: 'username', placeholder: 'GitLab-integration' }.freeze ) end.freeze end @@ -186,6 +185,14 @@ module Integrations true end + def channel_limit_per_event + 10 + end + + def mask_configurable_channels? + false + end + private def should_execute?(object_kind) @@ -257,7 +264,7 @@ module Integrations def build_event_channels event_channel_names.map do |channel_field| - { type: 'text', name: channel_field, placeholder: default_channel_placeholder } + { type: :text, name: channel_field, placeholder: default_channel_placeholder } end end @@ -314,13 +321,13 @@ module Integrations def validate_channel_limit supported_events.each do |event| count = channels_for_event(event).count - next unless count > CHANNEL_LIMIT_PER_EVENT + next unless count > channel_limit_per_event errors.add( event_channel_name(event).to_sym, format( s_('SlackIntegration|cannot have more than %{limit} channels'), - limit: CHANNEL_LIMIT_PER_EVENT + limit: channel_limit_per_event ) ) end diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb index 5c08eac8557..6cd36e545a5 100644 --- a/app/models/integrations/buildkite.rb +++ b/app/models/integrations/buildkite.rb @@ -16,7 +16,7 @@ module Integrations required: true field :token, - type: 'password', + type: :password, title: -> { _('Token') }, help: -> do s_('ProjectService|The token you get after you create a Buildkite pipeline with a GitLab repository.') diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 9b837faf79b..007578e5830 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -13,7 +13,7 @@ module Integrations format: { with: SUBDOMAIN_REGEXP }, length: { in: 1..63 } field :token, - type: 'password', + type: :password, title: -> { _('Campfire token') }, help: -> { s_('CampfireService|API authentication token from Campfire.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb index 1c234630370..dd516362491 100644 --- a/app/models/integrations/chat_message/issue_message.rb +++ b/app/models/integrations/chat_message/issue_message.rb @@ -27,7 +27,7 @@ module Integrations def attachments return [] unless opened_issue? - return description if markdown + return SlackMarkdownSanitizer.sanitize_slack_link(description) if markdown description_message end @@ -55,7 +55,7 @@ module Integrations [{ title: issue_title, title_link: issue_url, - text: format(description), + text: format(SlackMarkdownSanitizer.sanitize_slack_link(description)), color: '#C95823' }] end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index c7306209174..1a56763fe57 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -32,7 +32,7 @@ module Integrations help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } field :api_key, - type: 'password', + type: :password, title: -> { _('API key') }, non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, @@ -48,7 +48,7 @@ module Integrations field :archive_trace_events, storage: :attribute, - type: 'checkbox', + type: :checkbox, title: -> { _('Logs') }, checkbox_label: -> { _('Enable logs collection') }, help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } @@ -73,7 +73,7 @@ module Integrations end field :datadog_tags, - type: 'textarea', + type: :textarea, title: -> { s_('DatadogIntegration|Tags') }, placeholder: "tag:value\nanother_tag:value", help: -> do diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 061c491034d..7cae3ca20f9 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -10,15 +10,15 @@ module Integrations field :webhook, section: SECTION_TYPE_CONNECTION, - help: 'e.g. https://discordapp.com/api/webhooks/…', + help: 'e.g. https://discord.com/api/webhooks/…', required: true field :notify_only_broken_pipelines, - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION field :branches_to_be_notified, - type: 'select', + type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } @@ -45,7 +45,7 @@ module Integrations end def default_channel_placeholder - # No-op. + s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)') end def self.supported_events @@ -72,10 +72,23 @@ module Integrations ] end + def configurable_channels? + true + end + + def channel_limit_per_event + 1 + end + + def mask_configurable_channels? + true + end + private def notify(message, opts) - client = Discordrb::Webhooks::Client.new(url: webhook) + webhook_url = opts[:channel]&.first || webhook + client = Discordrb::Webhooks::Client.new(url: webhook_url) client.execute do |builder| builder.add_embed do |embed| diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index 781acf65c47..ac464c020dd 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -16,7 +16,7 @@ module Integrations required: true field :token, - type: 'password', + type: :password, help: -> { s_('ProjectService|Token for the Drone project.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index 25bda8c2bf0..eb893ae45d0 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -10,7 +10,7 @@ module Integrations validate :number_of_recipients_within_limit, if: :validate_recipients? field :send_from_committer_email, - type: 'checkbox', + type: :checkbox, title: -> { s_("EmailsOnPushService|Send from committer") }, help: -> do @help ||= begin @@ -21,17 +21,17 @@ module Integrations end field :disable_diffs, - type: 'checkbox', + type: :checkbox, title: -> { s_("EmailsOnPushService|Disable code diffs") }, help: -> { s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") } field :branches_to_be_notified, - type: 'select', + type: :select, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: branch_choices field :recipients, - type: 'textarea', + type: :textarea, placeholder: -> { s_('EmailsOnPushService|tanuki@example.com gitlab@example.com') }, help: -> { s_('EmailsOnPushService|Emails separated by whitespace.') } diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb index 9f2274216f6..9dc90629344 100644 --- a/app/models/integrations/field.rb +++ b/app/models/integrations/field.rb @@ -6,21 +6,24 @@ module Integrations ATTRIBUTES = %i[ section type placeholder choices value checkbox_label - title help + title help if non_empty_password_help non_empty_password_title ].concat(BOOLEAN_ATTRIBUTES).freeze - TYPES = %w[text textarea password checkbox select].freeze + TYPES = %i[text textarea password checkbox select].freeze attr_reader :name, :integration_class - def initialize(name:, integration_class:, type: 'text', is_secret: false, api_only: false, **attributes) + delegate :key?, to: :attributes + + def initialize(name:, integration_class:, type: :text, is_secret: false, api_only: false, **attributes) @name = name.to_s.freeze @integration_class = integration_class - attributes[:type] = is_secret ? 'password' : type + attributes[:type] = is_secret ? :password : type attributes[:api_only] = api_only + attributes[:if] = attributes.fetch(:if, true) attributes[:is_secret] = is_secret @attributes = attributes.freeze @@ -35,14 +38,14 @@ module Integrations def [](key) return name if key == :name - value = @attributes[key] + value = attributes[key] return integration_class.class_exec(&value) if value.respond_to?(:call) value end def secret? - self[:type] == 'password' + self[:type] == :password end ATTRIBUTES.each do |name| @@ -56,5 +59,9 @@ module Integrations TYPES.each do |type| define_method("#{type}?") { self[:type] == type } end + + private + + attr_reader :attributes end end diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb index 9fa6dc19f11..5389e8dfa81 100644 --- a/app/models/integrations/google_play.rb +++ b/app/models/integrations/google_play.rb @@ -12,6 +12,7 @@ module Integrations } validates :service_account_key_file_name, presence: true validates :package_name, presence: true, format: { with: PACKAGE_NAME_REGEX } + validates :google_play_protected_refs, inclusion: [true, false] end field :package_name, @@ -25,6 +26,12 @@ module Integrations field :service_account_key, api_only: true + field :google_play_protected_refs, + type: :checkbox, + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('GooglePlayStore|Protected branches and tags only') }, + checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') } + def title s_('GooglePlay|Google Play') end @@ -76,8 +83,9 @@ module Integrations { success: false, message: error } end - def ci_variables + def ci_variables(protected_ref:) return [] unless activated? + return [] if google_play_protected_refs && !protected_ref [ { key: 'SUPPLY_JSON_KEY_DATA', value: service_account_key, masked: true, public: false }, @@ -85,6 +93,11 @@ module Integrations ] end + def initialize_properties + super + self.google_play_protected_refs = true if google_play_protected_refs.nil? + end + private def client diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 7ba9bbc38e6..037c689c75e 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -10,11 +10,11 @@ module Integrations required: true field :notify_only_broken_pipelines, - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION field :branches_to_be_notified, - type: 'select', + type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 079811e0df0..559e48afd10 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -25,7 +25,7 @@ module Integrations required: true field :password, - type: 'password', + type: :password, title: -> { s_('HarborIntegration|Harbor password') }, help: -> { s_('HarborIntegration|Password for your Harbor username.') }, non_empty_password_title: -> { s_('HarborIntegration|Enter new Harbor password') }, diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index 3f3e321f45e..a54946f074a 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -23,7 +23,7 @@ module Integrations placeholder: 'irc://irc.network.net:6697/' field :recipients, - type: 'textarea', + type: :textarea, title: -> { s_('IrkerService|Recipients') }, placeholder: 'irc[s]://irc.network.net[:port]/#channel', required: true, @@ -45,7 +45,7 @@ module Integrations end field :colorize_messages, - type: 'checkbox', + type: :checkbox, title: -> { _('Colorize messages') } # NOTE: This field is only used internally to store the parsed diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb index d2e8393ef95..7769ea7d2dd 100644 --- a/app/models/integrations/jenkins.rb +++ b/app/models/integrations/jenkins.rb @@ -22,7 +22,7 @@ module Integrations help: -> { s_('The username for the Jenkins server.') } field :password, - type: 'password', + type: :password, help: -> { s_('The password for the Jenkins server.') }, non_empty_password_title: -> { s_('ProjectService|Enter new password.') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password.') } diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 4e0c2dde13b..faf0a378a17 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -74,7 +74,7 @@ module Integrations exposes_secrets: true field :jira_auth_type, - type: 'select', + type: :select, required: true, section: SECTION_TYPE_CONNECTION, title: -> { s_('JiraService|Authentication type') }, diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb index e075400d9b5..73cddd163e0 100644 --- a/app/models/integrations/mattermost_slash_commands.rb +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -5,7 +5,7 @@ module Integrations include Ci::TriggersHelper field :token, - type: 'password', + type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, placeholder: '' diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index a9ed0bd3da1..25308948d51 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -10,12 +10,12 @@ module Integrations required: true field :notify_only_broken_pipelines, - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION, help: 'If selected, successful pipelines do not trigger a notification event.' field :branches_to_be_notified, - type: 'select', + type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index 3973b492b6d..c9c08ec9771 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -11,7 +11,7 @@ module Integrations required: true field :token, - type: 'password', + type: :password, title: -> { _('Token') }, help: -> { _('Enter your Packagist token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index 55a8ce0be11..fa22bd1a73c 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -10,19 +10,19 @@ module Integrations validate :number_of_recipients_within_limit, if: :validate_recipients? field :recipients, - type: 'textarea', + type: :textarea, help: -> { _('Comma-separated list of email addresses.') }, required: true field :notify_only_broken_pipelines, - type: 'checkbox' + type: :checkbox field :notify_only_default_branch, - type: 'checkbox', + type: :checkbox, api_only: true field :branches_to_be_notified, - type: 'select', + type: :select, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: branch_choices diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index 1acdbbbf9bc..0d9a3f05a86 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -7,7 +7,7 @@ module Integrations validates :token, presence: true, if: :activated? field :token, - type: 'password', + type: :password, help: -> { s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 8969c6c13b2..736318ed707 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -6,7 +6,7 @@ module Integrations include Gitlab::Utils::StrongMemoize field :manual_configuration, - type: 'checkbox', + type: :checkbox, title: -> { s_('PrometheusService|Active') }, help: -> { s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.') }, required: true @@ -24,7 +24,7 @@ module Integrations required: false field :google_iap_service_account_json, - type: 'textarea', + type: :textarea, title: 'Google IAP Service Account JSON', placeholder: -> { s_('PrometheusService|{ "type": "service_account", "project_id": ... }') }, help: -> { s_('PrometheusService|The contents of the credentials.json file of your service account.') }, diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb index e08dc6d0f51..8f0dddcc5c5 100644 --- a/app/models/integrations/pumble.rb +++ b/app/models/integrations/pumble.rb @@ -2,6 +2,24 @@ module Integrations class Pumble < BaseChatNotification + undef :notify_only_broken_pipelines + + field :webhook, + section: SECTION_TYPE_CONNECTION, + help: 'https://api.pumble.com/workspaces/x/...', + required: true + + field :notify_only_broken_pipelines, + type: :checkbox, + section: SECTION_TYPE_CONFIGURATION, + help: 'If selected, successful pipelines do not trigger a notification event.' + + field :branches_to_be_notified, + type: :select, + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + def title 'Pumble' end @@ -34,17 +52,8 @@ module Integrations pipeline wiki_page] end - def default_fields - [ - { type: 'text', name: 'webhook', help: 'https://api.pumble.com/workspaces/x/...', required: true }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { - type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - } - ] + def fields + self.class.fields + build_event_channels end private diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 6bb6b6d60f6..006b731c6c2 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -7,7 +7,7 @@ module Integrations validates :api_key, :user_key, :priority, presence: true, if: :activated? field :api_key, - type: 'password', + type: :password, title: -> { _('API key') }, help: -> { s_('PushoverService|Enter your application key.') }, non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, @@ -16,7 +16,7 @@ module Integrations required: true field :user_key, - type: 'password', + type: :password, title: -> { _('User key') }, help: -> { s_('PushoverService|Enter your user key.') }, non_empty_password_title: -> { s_('PushoverService|Enter new user key') }, @@ -30,7 +30,7 @@ module Integrations placeholder: '' field :priority, - type: 'select', + type: :select, required: true, choices: -> do [ @@ -42,7 +42,7 @@ module Integrations end field :sound, - type: 'select', + type: :select, choices: -> do [ ['Device default sound', nil], diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb index 343c8d68166..b209f37ee7c 100644 --- a/app/models/integrations/slack_slash_commands.rb +++ b/app/models/integrations/slack_slash_commands.rb @@ -5,7 +5,7 @@ module Integrations include Ci::TriggersHelper field :token, - type: 'password', + type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, placeholder: '' diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb index e0a63b5ae6a..bf3f391564f 100644 --- a/app/models/integrations/squash_tm.rb +++ b/app/models/integrations/squash_tm.rb @@ -11,7 +11,7 @@ module Integrations required: true field :token, - type: 'password', + type: :password, title: -> { s_('SquashTmIntegration|Secret token (optional)') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index af629d6ef1e..c74e0aab030 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -22,7 +22,7 @@ module Integrations help: -> { s_('ProjectService|Must have permission to trigger a manual build in TeamCity.') } field :password, - type: 'password', + type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new password') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') } diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index 6c447c8f4e4..6de693b5278 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -10,11 +10,11 @@ module Integrations required: true field :notify_only_broken_pipelines, - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION field :branches_to_be_notified, - type: 'select', + type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index ef1bc81ea58..21c65cc2b32 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -10,11 +10,11 @@ module Integrations required: true field :notify_only_broken_pipelines, - type: 'checkbox', + type: :checkbox, section: SECTION_TYPE_CONFIGURATION field :branches_to_be_notified, - type: 'select', + type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index 459756c865b..fd2c741bd6b 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -19,7 +19,7 @@ module Integrations exposes_secrets: true field :api_token, - type: 'password', + type: :password, title: -> { s_('ZentaoIntegration|ZenTao API token') }, non_empty_password_title: -> { s_('ZentaoIntegration|Enter new ZenTao API token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, diff --git a/app/models/issue.rb b/app/models/issue.rb index 6e48dcab9ed..d227448961a 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -317,6 +317,10 @@ class Issue < ApplicationRecord pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN ) end + + def related_link_class + IssueLink + end end def self.participant_includes @@ -542,18 +546,18 @@ class Issue < ApplicationRecord end def related_issues(current_user, preload: nil) - related_issues = ::Issue - .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 = 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 = yield related_issues if block_given? @@ -642,12 +646,13 @@ class Issue < ApplicationRecord end def issue_link_type + link_class = self.class.related_link_class return unless respond_to?(:issue_link_type_value) && respond_to?(:issue_link_source_id) - type = IssueLink.link_types.key(issue_link_type_value) || IssueLink::TYPE_RELATES_TO + type = link_class.link_types.key(issue_link_type_value) || link_class::TYPE_RELATES_TO return type if issue_link_source_id == id - IssueLink.inverse_link_type(type) + link_class.inverse_link_type(type) end def relocation_target @@ -770,7 +775,7 @@ class Issue < ApplicationRecord return unless persisted? if confidential? && WorkItems::ParentLink.has_public_children?(id) - errors.add(:base, _('A confidential issue cannot have a parent that already has non-confidential children.')) + errors.add(:base, _('A confidential issue must have only confidential children. Make any child items confidential and try again.')) end if !confidential? && WorkItems::ParentLink.has_confidential_parent?(id) @@ -784,7 +789,7 @@ class Issue < ApplicationRecord # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/393126 return unless project - Issues::SearchData.upsert({ 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! diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb index af55a5dec91..1c596ad0341 100644 --- a/app/models/issue_link.rb +++ b/app/models/issue_link.rb @@ -1,18 +1,11 @@ # frozen_string_literal: true class IssueLink < ApplicationRecord - include FromUnion - include IssuableLink + include LinkableItem belongs_to :source, class_name: 'Issue' belongs_to :target, class_name: 'Issue' - scope :for_source_issue, ->(issue) { where(source_id: issue.id) } - scope :for_target_issue, ->(issue) { where(target_id: issue.id) } - scope :for_issues, ->(source, target) do - where(source: source, target: target).or(where(source: target, target: source)) - end - class << self def issuable_type :issue diff --git a/app/models/label.rb b/app/models/label.rb index 0831ba40536..d0d278b68fd 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -25,8 +25,10 @@ class Label < ApplicationRecord has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' before_validation :strip_whitespace_from_title + before_destroy :prevent_locked_label_destroy, prepend: true validates :color, color: true, presence: true + validate :ensure_lock_on_merge_allowed # Don't allow ',' for label titles validates :title, presence: true, format: { with: /\A[^,]+\z/ } @@ -42,6 +44,7 @@ class Label < ApplicationRecord scope :templates, -> { where(template: true, type: [Label.name, nil]) } scope :with_title, ->(title) { where(title: title) } scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } + scope :with_lock_on_merge, -> { where(lock_on_merge: true) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } scope :on_board, ->(board_id) { with_lists_and_board.where(boards: { id: board_id }) } scope :order_name_asc, -> { reorder(title: :asc) } @@ -319,6 +322,20 @@ class Label < ApplicationRecord def strip_whitespace_from_title self[:title] = title&.strip end + + def prevent_locked_label_destroy + return unless lock_on_merge + + errors.add(:base, format(_('%{label_name} is locked and was not removed'), label_name: name)) + throw :abort # rubocop:disable Cop/BanCatchThrow + end + + def ensure_lock_on_merge_allowed + return unless template? + return unless lock_on_merge || will_save_change_to_lock_on_merge? + + errors.add(:lock_on_merge, _('can not be set for template labels')) + end end Label.prepend_mod_with('Label') diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index 7f64606e97b..1d26c3c11e4 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel + include FromUnion + PARTITION_DURATION = 1.day include PartitionedTable @@ -34,13 +36,34 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel enum status: { pending: 1, processed: 2 }, _prefix: :status def self.load_batch_for_table(table, batch_size) - # selecting partition as partition_number to workaround the sliding partitioning column ignore - select(arel_table[Arel.star], arel_table[:partition].as('partition_number')) - .for_table(table) - .status_pending - .consume_order - .limit(batch_size) - .to_a + if Feature.enabled?("loose_foreign_keys_batch_load_using_union") + partition_names = Gitlab::Database::PostgresPartitionedTable.each_partition(table_name).map(&:name) + + unions = partition_names.map do |partition_name| + partition_number = partition_name[/\d+/].to_i + + select(arel_table[Arel.star], arel_table[:partition].as('partition_number')) + .from("#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{partition_name} AS #{table_name}") + .for_table(table) + .where(partition: partition_number) + .status_pending + .consume_order + .limit(batch_size) + end + + select(arel_table[Arel.star]) + .from_union(unions, remove_duplicates: false, remove_order: false) + .limit(batch_size) + .to_a + else + # selecting partition as partition_number to workaround the sliding partitioning column ignore + select(arel_table[Arel.star], arel_table[:partition].as('partition_number')) + .for_table(table) + .status_pending + .consume_order + .limit(batch_size) + .to_a + end end def self.mark_records_processed(records) diff --git a/app/models/member.rb b/app/models/member.rb index f164ea244b4..cdf40eaa8f5 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -153,6 +153,7 @@ class Member < ApplicationRecord scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + scope :expiring_and_not_notified, ->(date) { where("expiry_notified_at is null AND expires_at >= ? AND expires_at <= ?", Date.current, date) } scope :created_today, -> do now = Date.current diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2773569161d..469dba42952 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -656,8 +656,8 @@ class MergeRequest < ApplicationRecord [:assignees, :reviewers] + super end - def committers - @committers ||= commits.committers + def committers(with_merge_commits: false) + @committers ||= commits.committers(with_merge_commits: with_merge_commits) end # Verifies if title has changed not taking into account Draft prefix @@ -984,6 +984,18 @@ class MergeRequest < ApplicationRecord branch_merge_base_commit.try(:sha) end + def existing_mrs_targeting_same_branch + similar_mrs = target_project + .merge_requests + .where(source_branch: source_branch, target_branch: target_branch) + .where(source_project: source_project) + .opened + + similar_mrs = similar_mrs.id_not_in(id) if persisted? + + similar_mrs + end + def validate_branches return unless target_project && source_project @@ -995,25 +1007,24 @@ class MergeRequest < ApplicationRecord [:source_branch, :target_branch].each { |attr| validate_branch_name(attr) } if opened? - similar_mrs = target_project - .merge_requests - .where(source_branch: source_branch, target_branch: target_branch) - .where(source_project_id: source_project&.id) - .opened + conflicting_mr = existing_mrs_targeting_same_branch.first - similar_mrs = similar_mrs.where.not(id: id) if persisted? - - conflict = similar_mrs.first - - if conflict.present? + if conflicting_mr errors.add( :validate_branches, - "Another open merge request already exists for this source branch: #{conflict.to_reference}" + conflicting_mr_message(conflicting_mr) ) end end end + def conflicting_mr_message(conflicting_mr) + format( + _("Another open merge request already exists for this source branch: %{conflicting_mr_reference}"), + conflicting_mr_reference: conflicting_mr.to_reference + ) + end + def validate_branch_name(attr) return unless will_save_change_to_attribute?(attr) @@ -1155,7 +1166,7 @@ class MergeRequest < ApplicationRecord MergeRequests::ReloadDiffsService.new(self, current_user).execute end - def check_mergeability(async: false) + def check_mergeability(async: false, sync_retry_lease: false) return unless recheck_merge_status? check_service = MergeRequests::MergeabilityCheckService.new(self) @@ -1163,7 +1174,7 @@ class MergeRequest < ApplicationRecord if async check_service.async_execute else - check_service.execute(retry_lease: false) + check_service.execute(retry_lease: sync_retry_lease) end end # rubocop: enable CodeReuse/ServiceClass @@ -1207,14 +1218,14 @@ class MergeRequest < ApplicationRecord } end - def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false) + def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false) return false unless mergeable_state?( skip_ci_check: skip_ci_check, skip_discussions_check: skip_discussions_check, skip_approved_check: skip_approved_check ) - check_mergeability + check_mergeability(sync_retry_lease: check_mergeability_retry_lease) can_be_merged? && !should_be_rebased? end @@ -1537,20 +1548,29 @@ class MergeRequest < ApplicationRecord end def schedule_cleanup_refs(only: :all) - if Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project) + if Feature.enabled?(:merge_request_delete_gitaly_refs_in_batches, target_project) + async_cleanup_refs(only: only) + elsif Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project) MergeRequests::CleanupRefWorker.perform_async(id, only.to_s) else cleanup_refs(only: only) end end - def cleanup_refs(only: :all) + def refs_to_cleanup(only: :all) target_refs = [] target_refs << ref_path if %i[all head].include?(only) target_refs << merge_ref_path if %i[all merge].include?(only) target_refs << train_ref_path if %i[all train].include?(only) + target_refs + end + + def cleanup_refs(only: :all) + project.repository.delete_refs(*refs_to_cleanup(only: only)) + end - project.repository.delete_refs(*target_refs) + def async_cleanup_refs(only: :all) + project.repository.async_delete_refs(*refs_to_cleanup(only: only)) end def self.merge_request_ref?(ref) diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index a13cb353c7b..3c592c0008f 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class MergeRequest::Metrics < ApplicationRecord - include IgnorableColumns include DatabaseEventTracking belongs_to :merge_request, inverse_of: :metrics @@ -17,8 +16,6 @@ class MergeRequest::Metrics < ApplicationRecord scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) } scope :by_target_project, ->(project) { where(target_project_id: project) } - ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' - class << self def time_to_merge_expression Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))') diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 33930836c48..bddc03d8b21 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -7,6 +7,7 @@ class MergeRequestDiff < ApplicationRecord include EachBatch include Gitlab::Utils::StrongMemoize include BulkInsertableAssociations + include ShaAttribute # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 @@ -34,6 +35,8 @@ class MergeRequestDiff < ApplicationRecord has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }, inverse_of: :merge_request_diff + sha_attribute :patch_id_sha + validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true validates :merge_request_id, uniqueness: { scope: :diff_type }, if: :merge_head? diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb index d3d3f973398..ac0fcb41089 100644 --- a/app/models/metrics/dashboard/annotation.rb +++ b/app/models/metrics/dashboard/annotation.rb @@ -7,15 +7,10 @@ module Metrics self.table_name = 'metrics_dashboard_annotations' - belongs_to :environment, inverse_of: :metrics_dashboard_annotations - belongs_to :cluster, class_name: 'Clusters::Cluster', inverse_of: :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 :single_ownership - validate :orphaned_annotation validate :ending_at_after_starting_at scope :after, ->(after) { where('starting_at >= ?', after) } @@ -34,18 +29,6 @@ module Metrics errors.add(:ending_at, s_("MetricsDashboardAnnotation|can't be before starting_at time")) end - - def single_ownership - return if cluster.nil? ^ environment.nil? - - errors.add(:base, s_("MetricsDashboardAnnotation|Annotation can't belong to both a cluster and an environment at the same time")) - end - - def orphaned_annotation - return if cluster.present? || environment.present? - - errors.add(:base, s_("MetricsDashboardAnnotation|Annotation must belong to a cluster or an environment")) - end end end end diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index 5c5f8d3b2db..ad6c6b7b3bf 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -59,6 +59,10 @@ module Ml numeric?(iid) end + def find_or_create(project, name, user) + create_with(user: user).find_or_create_by(project: project, name: name) + end + private def numeric?(value) diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb index 684b8e1983b..fb15b9fea72 100644 --- a/app/models/ml/model.rb +++ b/app/models/ml/model.rb @@ -2,6 +2,8 @@ module Ml class Model < ApplicationRecord + include Presentable + validates :project, :default_experiment, presence: true validates :name, format: Gitlab::Regex.ml_model_name_regex, @@ -14,6 +16,10 @@ module Ml has_one :default_experiment, class_name: 'Ml::Experiment' belongs_to :project has_many :versions, class_name: 'Ml::ModelVersion' + has_one :latest_version, -> { latest_by_model }, class_name: 'Ml::ModelVersion', inverse_of: :model + + scope :including_latest_version, -> { includes(:latest_version) } + scope :by_project, ->(project) { where(project_id: project.id) } def valid_default_experiment? return unless default_experiment @@ -21,5 +27,10 @@ module Ml errors.add(:default_experiment) unless default_experiment.name == name errors.add(:default_experiment) unless default_experiment.project_id == project_id end + + def self.find_or_create(project, name, experiment) + create_with(default_experiment: experiment) + .find_or_create_by(project: project, name: name) + end end end diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 540fe6018a1..6d0e7c35865 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -5,7 +5,7 @@ module Ml validates :project, :model, presence: true validates :version, - format: Gitlab::Regex.ml_model_version_regex, + format: Gitlab::Regex.semver_regex, uniqueness: { scope: [:project, :model_id] }, presence: true, length: { maximum: 255 } @@ -18,6 +18,15 @@ module Ml delegate :name, to: :model + scope :order_by_model_id_id_desc, -> { order('model_id, id DESC') } + scope :latest_by_model, -> { order_by_model_id_id_desc.select('DISTINCT ON (model_id) *') } + + class << self + def find_or_create!(model, version, package) + create_with(package: package).find_or_create_by!(project: model.project, model: model, version: version) + end + end + private def valid_model? diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5449f006a2e..a7d03c3688a 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -137,6 +137,7 @@ class Namespace < ApplicationRecord :pypi_package_requests_forwarding, :npm_package_requests_forwarding, to: :package_settings + delegate :default_branch_protection_defaults, to: :namespace_settings, allow_nil: true before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? } before_create :sync_share_with_group_lock_with_parent @@ -234,6 +235,7 @@ class Namespace < ApplicationRecord if include_parents 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]], use_minimum_char_limit: use_minimum_char_limit) .select(:source_id)) @@ -543,8 +545,8 @@ class Namespace < ApplicationRecord def changing_allow_descendants_override_disabled_shared_runners_is_allowed return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners) - if shared_runners_enabled && !new_record? - errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled')) + if shared_runners_enabled && allow_descendants_override_disabled_shared_runners + errors.add(:allow_descendants_override_disabled_shared_runners, _('can not be true if shared runners are enabled')) end if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == SR_DISABLED_AND_UNOVERRIDABLE diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb index 6c977505f17..08187a9273e 100644 --- a/app/models/namespace/aggregation_schedule.rb +++ b/app/models/namespace/aggregation_schedule.rb @@ -13,11 +13,7 @@ class Namespace::AggregationSchedule < ApplicationRecord after_create :schedule_root_storage_statistics def default_lease_timeout - if Feature.enabled?(:reduce_aggregation_schedule_lease, namespace.root_ancestor) - ::Gitlab::CurrentSettings.namespace_aggregation_schedule_lease_duration_in_seconds - else - 30.minutes.to_i - end + ::Gitlab::CurrentSettings.namespace_aggregation_schedule_lease_duration_in_seconds end def schedule_root_storage_statistics diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index 2660d11171e..6c825b5364f 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -4,6 +4,10 @@ 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' + ignore_column :free_user_cap_over_limit_notified_at, remove_with: '16.5', remove_after: '2023-08-22' belongs_to :namespace, inverse_of: :namespace_details validates :namespace, presence: true diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index 22c3e41ff21..a249bb144f9 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -12,7 +12,7 @@ class Namespace::PackageSetting < ApplicationRecord PackageSettingNotImplemented = Class.new(StandardError) - PACKAGES_WITH_SETTINGS = %w[maven generic].freeze + PACKAGES_WITH_SETTINGS = %w[maven generic nuget].freeze belongs_to :namespace, inverse_of: :package_setting_relation @@ -21,6 +21,8 @@ class Namespace::PackageSetting < ApplicationRecord validates :maven_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } validates :generic_duplicates_allowed, inclusion: { in: [true, false] } validates :generic_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + validates :nuget_duplicates_allowed, inclusion: { in: [true, false] } + validates :nuget_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } class << self def duplicates_allowed?(package) diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 5b114bb42aa..8d5d788c738 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -45,6 +45,7 @@ class NamespaceSetting < ApplicationRecord enabled_git_access_protocol subgroup_runner_token_expiration_interval project_runner_token_expiration_interval + default_branch_protection_defaults ].freeze # matches the size set in the database constraint diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb index bf23fc21124..288c5c0d2d4 100644 --- a/app/models/namespaces/project_namespace.rb +++ b/app/models/namespaces/project_namespace.rb @@ -6,10 +6,10 @@ module Namespaces # project.namespace/project.namespace_id attribute. # # TODO: we can remove these attribute aliases when we no longer need to sync these with project model, - # see project#sync_attributes + # see ProjectNamespace#sync_attributes_from_project alias_attribute :namespace, :parent alias_attribute :namespace_id, :parent_id - has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace + has_one :project, inverse_of: :project_namespace delegate :execute_hooks, :execute_integrations, :group, to: :project, allow_nil: true delegate :external_references_supported?, :default_issues_tracker?, to: :project @@ -21,5 +21,40 @@ module Namespaces def self.polymorphic_name 'Namespaces::ProjectNamespace' end + + def self.create_from_project!(project) + return unless project.new_record? + return unless project.namespace + + proj_namespace = project.project_namespace || project.build_project_namespace + project.project_namespace.sync_attributes_from_project(project) + proj_namespace.save! + proj_namespace + end + + def sync_attributes_from_project(project) + attributes_to_sync = project + .changes + .slice(*%w[name path namespace_id namespace visibility_level shared_runners_enabled]) + .transform_values { |val| val[1] } + + # if visibility_level is not set explicitly for project, it defaults to 0, + # but for namespace visibility_level defaults to 20, + # so it gets out of sync right away if we do not set it explicitly when creating the project namespace + attributes_to_sync['visibility_level'] ||= project.visibility_level if project.new_record? + + # when a project is associated with a group while the group is created we need to ensure we associate the new + # group with the project namespace as well. + # E.g. + # project = create(:project) <- project is saved + # create(:group, projects: [project]) <- associate project with a group that is not yet created. + if attributes_to_sync.has_key?('namespace_id') && + attributes_to_sync['namespace_id'].blank? && + project.namespace.present? + attributes_to_sync['parent'] = project.namespace + end + + assign_attributes(attributes_to_sync) + end end end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 7ffcb8b9219..0f410d4810d 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -86,6 +86,7 @@ module Network skip = 0 while offset == -1 tmp_commits = find_commits(skip) + if tmp_commits.present? index = tmp_commits.index do |c| c.id == @commit.id @@ -112,15 +113,17 @@ module Network end def find_commits(skip = 0) - opts = { - max_count: self.class.max_count, - skip: skip, - order: :date - } + Gitlab::SafeRequestStore.fetch([@project, :network_graph_commits, skip]) do + opts = { + max_count: self.class.max_count, + skip: skip, + order: :date + } - opts[:ref] = @commit.id if @filter_ref + opts[:ref] = @commit.id if @filter_ref - Gitlab::Git::Commit.find_all(@repo.raw_repository, opts) + Gitlab::Git::Commit.find_all(@repo.raw_repository, opts) + end end def commits_sort_by_ref diff --git a/app/models/note.rb b/app/models/note.rb index 2df643c46aa..f1760a8dc4a 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -149,7 +149,7 @@ class Note < ApplicationRecord scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) } scope :inc_relations_for_view, ->(noteable = nil) do relations = [{ project: :group }, { author: :status }, :updated_by, :resolved_by, - :award_emoji, { system_note_metadata: :description_version }, :suggestions] + :award_emoji, :note_metadata, { system_note_metadata: :description_version }, :suggestions] if noteable.nil? || DiffNote.noteable_types.include?(noteable.class.name) relations += [:note_diff_file, :diff_note_positions] @@ -197,9 +197,7 @@ class Note < ApplicationRecord # Syncs `confidential` with `internal` as we rename the column. # https://gitlab.com/gitlab-org/gitlab/-/issues/367923 before_create :set_internal_flag - after_destroy :expire_etag_cache after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits } - after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? after_commit :notify_after_create, on: :create after_commit :notify_after_destroy, on: :destroy @@ -207,6 +205,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? def trigger_note_subscription_create return unless trigger_note_subscription? @@ -498,7 +497,7 @@ class Note < ApplicationRecord end def can_be_discussion_note? - self.noteable.supports_discussions? && !part_of_discussion? + self.noteable.supports_discussions? && !part_of_discussion? && !system? end def can_create_todo? @@ -853,7 +852,9 @@ class Note < ApplicationRecord user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count else refs = all_references(user) - refs.all.any? && refs.all_visible? + refs.all + + refs.all_visible? end end diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index 6876af09c2c..01db0a5cf8b 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -30,7 +30,9 @@ module Operations length: 2..63, format: { with: Gitlab::Regex.feature_flag_regex, - message: Gitlab::Regex.feature_flag_regex_message + message: ->(_object, _data) { + s_("Validation|can contain only lowercase letters, digits, '_' and '-'. Must start with a letter, and cannot end with '-' or '_'") + } } validates :name, uniqueness: { scope: :project_id } validates :description, allow_blank: true, length: 0..255 diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb index ed9400dde8f..5dc6de7dfc1 100644 --- a/app/models/operations/feature_flags/strategy.rb +++ b/app/models/operations/feature_flags/strategy.rb @@ -28,7 +28,7 @@ module Operations validates :name, inclusion: { in: STRATEGIES.keys, - message: 'strategy name is invalid' + message: ->(_object, _data) { s_('Validation|strategy name is invalid') } } validate :parameters_validations, if: -> { errors[:name].blank? } @@ -46,7 +46,7 @@ module Operations def same_project_validation unless user_list.project_id == feature_flag.project_id - errors.add(:user_list, 'must belong to the same project') + errors.add(:user_list, s_('Validation|must belong to the same project')) end end @@ -57,13 +57,13 @@ module Operations end def validate_parameters_type - parameters.is_a?(Hash) || parameters_error('parameters are invalid') + parameters.is_a?(Hash) || parameters_error(s_('Validation|parameters are invalid')) end def validate_parameters_keys actual_keys = parameters.keys.sort expected_keys = STRATEGIES[name].sort - expected_keys == actual_keys || parameters_error('parameters are invalid') + expected_keys == actual_keys || parameters_error(s_('Validation|parameters are invalid')) end def validate_parameters_values @@ -89,11 +89,11 @@ module Operations group_id = parameters['groupId'] unless within_range?(percentage, 0, 100) - parameters_error('percentage must be a string between 0 and 100 inclusive') + parameters_error(s_('Validation|percentage must be a string between 0 and 100 inclusive')) end unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) - parameters_error('groupId parameter is invalid') + parameters_error(s_('Validation|groupId parameter is invalid')) end end @@ -108,11 +108,11 @@ module Operations end unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) - parameters_error('groupId parameter is invalid') + parameters_error(s_('Validation|groupId parameter is invalid')) end unless within_range?(rollout, 0, 100) - parameters_error('rollout must be a string between 0 and 100 inclusive') + parameters_error(s_('Validation|rollout must be a string between 0 and 100 inclusive')) end end diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 8aeca2eb137..9f2119949fb 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -37,6 +37,10 @@ module Organizations path end + def user?(user) + users.exists?(user.id) + end + private def check_if_default_organization diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb index fae7728cccb..e7cf4528f16 100644 --- a/app/models/packages/nuget/metadatum.rb +++ b/app/models/packages/nuget/metadatum.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Packages::Nuget::Metadatum < ApplicationRecord + include Packages::Nuget::VersionNormalizable + MAX_AUTHORS_LENGTH = 255 MAX_DESCRIPTION_LENGTH = 4000 MAX_URL_LENGTH = 255 @@ -13,9 +15,15 @@ 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) } validate :ensure_nuget_package_type + delegate :version, to: :package, prefix: true + + scope :normalized_version_in, ->(version) { where(normalized_version: version.downcase) } + private def ensure_nuget_package_type diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index b618c7c20c4..b09911f4216 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -10,6 +10,7 @@ class Packages::Package < ApplicationRecord DISPLAYABLE_STATUSES = [:default, :error].freeze INSTALLABLE_STATUSES = [:default, :hidden].freeze + STATUS_MESSAGE_MAX_LENGTH = 255 enum package_type: { maven: 1, @@ -123,6 +124,22 @@ class Packages::Package < ApplicationRecord where('LOWER(version) = ?', version.downcase) end + scope :with_case_insensitive_name, ->(name) do + where(arel_table[:name].lower.eq(name.downcase)) + end + + scope :with_nuget_version_or_normalized_version, ->(version, with_normalized: true) do + relation = with_case_insensitive_version(version) + + return relation unless with_normalized + + relation + .left_joins(:nuget_metadatum) + .or( + merge(Packages::Nuget::Metadatum.normalized_version_in(version)) + ) + end + scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } scope :with_version, ->(version) { where(version: version) } scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } @@ -161,6 +178,14 @@ class Packages::Package < ApplicationRecord scope :preload_pypi_metadatum, -> { preload(:pypi_metadatum) } 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 + end + scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } scope :has_version, -> { where.not(version: nil) } @@ -169,14 +194,12 @@ class Packages::Package < ApplicationRecord scope :preload_pipelines, -> { preload(pipelines: :user) } scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } scope :select_distinct_name, -> { select(:name).distinct } - scope :select_only_first_by_name, -> { select('DISTINCT ON (name) *') } # Sorting scope :order_created, -> { reorder(created_at: :asc) } scope :order_created_desc, -> { reorder(created_at: :desc) } scope :order_name, -> { reorder(name: :asc) } scope :order_name_desc, -> { reorder(name: :desc) } - scope :order_name_desc_version_desc, -> { reorder(name: :desc, version: :desc) } scope :order_version, -> { reorder(version: :asc) } scope :order_version_desc, -> { reorder(version: :desc) } scope :order_type, -> { reorder(package_type: :asc) } @@ -184,7 +207,6 @@ class Packages::Package < ApplicationRecord scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') } scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') } scope :order_by_package_file, -> { joins(:package_files).order('packages_package_files.created_at ASC') } - scope :with_npm_scope, ->(scope) { npm.where("name ILIKE :package_name", package_name: "@#{sanitize_sql_like(scope)}/%") } scope :order_project_path, -> do keyset_order = keyset_pagination_order(join_class: Project, column_name: :path, direction: :asc) @@ -361,6 +383,12 @@ class Packages::Package < ApplicationRecord name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase end + def normalized_nuget_version + return unless nuget? + + nuget_metadatum&.normalized_version + end + def publish_creation_event ::Gitlab::EventStore.publish( ::Packages::PackageCreatedEvent.new(data: { diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index fa29cbf8352..ec2293fa032 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -29,18 +29,13 @@ class PagesDeployment < ApplicationRecord mount_file_store_uploader ::Pages::DeploymentUploader - skip_callback :save, :after, :store_file!, if: :store_after_commit? - after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit? + skip_callback :save, :after, :store_file! + after_commit :store_file_after_commit!, on: [:create, :update] def migrated? file.filename == MIGRATED_FILE_NAME end - def store_after_commit? - Feature.enabled?(:pages_deploy_upload_file_outside_transaction, project) - end - strong_memoize_attr :store_after_commit? - private def set_size diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb deleted file mode 100644 index 6fea3abf3d9..00000000000 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module PerformanceMonitoring - class PrometheusDashboard - include ActiveModel::Model - - attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating, :links - - validates :dashboard, presence: true - validates :panel_groups, array_members: { member_class: PerformanceMonitoring::PrometheusPanelGroup } - - class << self - def from_json(json_content) - build_from_hash(json_content).tap(&:validate!) - end - - def find_for(project:, user:, path:, options: {}) - template = { path: path, environment: options[:environment] } - rsp = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path)) - - case rsp[:http_status] || rsp[:status] - when :success - new(template.merge(rsp[:dashboard] || {})) # when there is empty dashboard file returned rsp is still a success - when :unprocessable_entity - new(template) # validation error - else - nil # any other error - end - end - - private - - def build_from_hash(attributes) - return new unless attributes.is_a?(Hash) - - new( - dashboard: attributes['dashboard'], - panel_groups: initialize_children_collection(attributes['panel_groups']) - ) - end - - def initialize_children_collection(children) - return unless children.is_a?(Array) - - children.map { |group| PerformanceMonitoring::PrometheusPanelGroup.from_json(group) } - end - end - - def to_yaml - self.as_json(only: yaml_valid_attributes).to_yaml - end - - # This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398 - # implementation. For new existing logic was reused to faster deliver MVC - def schema_validation_warnings - self.class.from_json(reload_schema) - [] - rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => e - [e.message] - rescue ActiveModel::ValidationError => e - e.model.errors.map { |error| "#{error.attribute}: #{error.message}" } - end - - private - - # dashboard finder methods are somehow limited, #find includes checking if - # user is authorised to view selected dashboard, but modifies schema, which in some cases may - # cause false positives returned from validation, and #find_raw does not authorise users - def reload_schema - project = environment&.project - project.nil? ? self.as_json : Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: path) - end - - def yaml_valid_attributes - %w(panel_groups panels metrics group priority type title y_label weight id unit label query query_range dashboard) - end - end -end diff --git a/app/models/plan.rb b/app/models/plan.rb index e16ecb4c629..22c1201421c 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Plan < ApplicationRecord +class Plan < MainClusterwide::ApplicationRecord DEFAULT = 'default' has_one :limits, class_name: 'PlanLimits' diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index f22a63ee980..bc3898fafe7 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -12,7 +12,13 @@ class PoolRepository < ApplicationRecord has_many :member_projects, class_name: 'Project' - after_create :correct_disk_path + 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) + .for_repository_storage(shard_name) + end state_machine :state, initial: :none do state :scheduled @@ -107,8 +113,8 @@ class PoolRepository < ApplicationRecord private - def correct_disk_path - update!(disk_path: storage.disk_path) + def set_disk_path + update!(disk_path: storage.disk_path) if disk_path.blank? end def storage diff --git a/app/models/project.rb b/app/models/project.rb index 8959eccbd1f..ad8757880fd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -43,6 +43,9 @@ class Project < ApplicationRecord include Subquery include IssueParent include UpdatedAtFilterable + include IgnorableColumns + + ignore_column :emails_disabled, remove_with: '16.3', remove_after: '2023-08-22' extend Gitlab::Cache::RequestCache extend Gitlab::Utils::Override @@ -125,7 +128,6 @@ class Project < ApplicationRecord before_validation :remove_leading_spaces_on_name after_validation :check_pending_delete before_save :ensure_runners_token - before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? } after_create -> { create_or_load_association(:project_feature) } after_create -> { create_or_load_association(:ci_cd_settings) } @@ -165,11 +167,14 @@ class Project < ApplicationRecord belongs_to :namespace # Sync deletion via DB Trigger to ensure we do not have # a project without a project_namespace (or vice-versa) - belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id' + belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project alias_method :parent, :namespace alias_attribute :parent_id, :namespace_id has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project + has_many :ci_components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :project + has_many :catalog_resource_versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :project + has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event' has_many :boards @@ -312,7 +317,8 @@ class Project < ApplicationRecord has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design' has_many :project_authorizations - has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' + has_many :authorized_users, -> { allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') }, + through: :project_authorizations, source: :user, class_name: 'User' has_many :project_members, -> { where(requested_at: nil) }, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -506,6 +512,7 @@ class Project < ApplicationRecord with_options prefix: :ci do delegate :default_git_depth, :default_git_depth= delegate :forward_deployment_enabled, :forward_deployment_enabled= + delegate :forward_deployment_rollback_allowed, :forward_deployment_rollback_allowed= delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled= delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project= delegate :separated_caches, :separated_caches= @@ -518,6 +525,7 @@ class Project < ApplicationRecord delegate :has_shimo? delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email? delegate :runner_registration_enabled, :runner_registration_enabled=, :runner_registration_enabled? + delegate :emails_enabled, :emails_enabled=, :emails_enabled? delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly? delegate :mr_default_target_self, :mr_default_target_self= delegate :previous_default_branch, :previous_default_branch= @@ -585,6 +593,7 @@ class Project < ApplicationRecord scope :pending_delete, -> { where(pending_delete: true) } scope :without_deleted, -> { where(pending_delete: false) } scope :not_hidden, -> { where(hidden: false) } + scope :not_in_groups, ->(groups) { where.not(group: groups) } scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted } scope :with_storage_feature, ->(feature) do @@ -703,6 +712,7 @@ class Project < ApplicationRecord # includes(:route) which we use in ProjectsFinder. joins("INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'") .where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%") + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') end scope :with_feature_enabled, ->(feature) { @@ -932,6 +942,7 @@ class Project < ApplicationRecord if include_namespace joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description], use_minimum_char_limit: use_minimum_char_limit) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') else fuzzy_search(query, [:path, :name, :description], use_minimum_char_limit: use_minimum_char_limit) end @@ -1209,14 +1220,8 @@ class Project < ApplicationRecord end def emails_disabled? - strong_memoize(:emails_disabled) do - # disabling in the namespace overrides the project setting - super || namespace.emails_disabled? - end - end - - def emails_enabled? - !emails_disabled? + # disabling in the namespace overrides the project setting + !emails_enabled? end override :lfs_enabled? @@ -1760,7 +1765,8 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') + # slice on column_names to ensure an added DB column will not break a mixed deployment + params = label.attributes.slice(*Label.column_names).except('id', 'template', 'created_at', 'updated_at', 'type') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end @@ -1951,6 +1957,8 @@ class Project < ApplicationRecord def track_project_repository repository = project_repository || build_project_repository repository.update!(shard_name: repository_storage, disk_path: disk_path) + + cleanup if replicate_object_pool_on_move_ff_enabled? end def create_repository(force: false, default_branch: nil) @@ -2466,7 +2474,7 @@ class Project < ApplicationRecord break unless pages_enabled? variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host) - variables.append(key: 'CI_PAGES_URL', value: Gitlab::Pages::UrlBuilder.new(self).pages_url) + variables.append(key: 'CI_PAGES_URL', value: Gitlab::Pages::UrlBuilder.new(self).pages_url(with_unique_domain: true)) end end @@ -2825,8 +2833,26 @@ class Project < ApplicationRecord update_column(:pool_repository_id, nil) end + # After repository is moved from shard to shard, disconnect it from the previous object pool and connect to the new pool + def swap_pool_repository! + return unless replicate_object_pool_on_move_ff_enabled? + return unless repository_exists? + + old_pool_repository = pool_repository + 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! + update!(pool_repository: new_pool_repository) + + old_pool_repository.unlink_repository(repository, disconnect: !pending_delete?) + end + def link_pool_repository - pool_repository&.link_repository(repository) + return unless pool_repository + return if (pool_repository.shard_name != repository.shard) && replicate_object_pool_on_move_ff_enabled? + + pool_repository.link_repository(repository) end def has_pool_repository? @@ -3048,6 +3074,12 @@ class Project < ApplicationRecord ci_cd_settings.forward_deployment_enabled? end + def ci_forward_deployment_rollback_allowed? + return false unless ci_cd_settings + + ci_cd_settings.forward_deployment_rollback_allowed? + end + def ci_allow_fork_pipelines_to_run_in_parent_project? return false unless ci_cd_settings @@ -3151,6 +3183,8 @@ class Project < ApplicationRecord end def created_and_owned_by_banned_user? + return false unless creator + creator.banned? && team.max_member_access(creator.id) == Gitlab::Access::OWNER end @@ -3170,6 +3204,10 @@ class Project < ApplicationRecord group&.work_items_mvc_2_feature_flag_enabled? || Feature.enabled?(:work_items_mvc_2) end + def linked_work_items_feature_flag_enabled? + group&.linked_work_items_feature_flag_enabled? || Feature.enabled?(:linked_work_items, self) + end + def enqueue_record_project_target_platforms return unless Gitlab.com? @@ -3437,7 +3475,7 @@ class Project < ApplicationRecord # create project_namespace when project is created build_project_namespace if project_namespace_creation_enabled? - sync_attributes(project_namespace) if sync_project_namespace? + project_namespace.sync_attributes_from_project(self) if sync_project_namespace? end def project_namespace_creation_enabled? @@ -3448,27 +3486,6 @@ class Project < ApplicationRecord (changes.keys & %w(name path namespace_id namespace visibility_level shared_runners_enabled)).any? && project_namespace.present? end - def sync_attributes(project_namespace) - attributes_to_sync = changes.slice(*%w(name path namespace_id namespace visibility_level shared_runners_enabled)) - .transform_values { |val| val[1] } - - # if visibility_level is not set explicitly for project, it defaults to 0, - # but for namespace visibility_level defaults to 20, - # so it gets out of sync right away if we do not set it explicitly when creating the project namespace - attributes_to_sync['visibility_level'] ||= visibility_level if new_record? - - # when a project is associated with a group while the group is created we need to ensure we associate the new - # group with the project namespace as well. - # E.g. - # project = create(:project) <- project is saved - # create(:group, projects: [project]) <- associate project with a group that is not yet created. - if attributes_to_sync.has_key?('namespace_id') && attributes_to_sync['namespace_id'].blank? && namespace.present? - attributes_to_sync['parent'] = namespace - end - - project_namespace.assign_attributes(attributes_to_sync) - end - def reload_project_namespace_details return unless (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && project_namespace.namespace_details.present? @@ -3511,19 +3528,18 @@ class Project < ApplicationRecord end end - def update_new_emails_created_column - return if project_setting.nil? - return if project_setting.emails_enabled == !emails_disabled + def runners_token_prefix + RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX + end - if project_setting.persisted? - project_setting.update!(emails_enabled: !emails_disabled) - elsif project_setting - project_setting.emails_enabled = !emails_disabled - end + def replicate_object_pool_on_move_ff_enabled? + Feature.enabled?(:replicate_object_pool_on_move, self) end - def runners_token_prefix - RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX + def pool_repository_shard_matches_repository?(pool) + pool_repository_shard = pool.shard.name + + pool_repository_shard == repository_storage end end diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index cb578496f26..99128d3cddf 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ProjectAuthorization < ApplicationRecord - BATCH_SIZE = 1000 - SLEEP_DELAY = 0.1 - extend SuppressCompositePrimaryKeyWarning include FromUnion @@ -28,57 +25,6 @@ class ProjectAuthorization < ApplicationRecord def self.insert_all(attributes) super(attributes, unique_by: connection.schema_cache.primary_keys(table_name)) end - - def self.insert_all_in_batches(attributes, per_batch = BATCH_SIZE) - add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: per_batch) - log_details(entire_size: attributes.size, batch_size: per_batch) if add_delay - - attributes.each_slice(per_batch) do |attributes_batch| - insert_all(attributes_batch) - perform_delay if add_delay - end - end - - def self.delete_all_in_batches_for_project(project:, user_ids:, per_batch: BATCH_SIZE) - add_delay = add_delay_between_batches?(entire_size: user_ids.size, batch_size: per_batch) - log_details(entire_size: user_ids.size, batch_size: per_batch) if add_delay - - user_ids.each_slice(per_batch) do |user_ids_batch| - project.project_authorizations.where(user_id: user_ids_batch).delete_all - perform_delay if add_delay - end - end - - def self.delete_all_in_batches_for_user(user:, project_ids:, per_batch: BATCH_SIZE) - add_delay = add_delay_between_batches?(entire_size: project_ids.size, batch_size: per_batch) - log_details(entire_size: project_ids.size, batch_size: per_batch) if add_delay - - project_ids.each_slice(per_batch) do |project_ids_batch| - user.project_authorizations.where(project_id: project_ids_batch).delete_all - perform_delay if add_delay - end - end - - private_class_method def self.add_delay_between_batches?(entire_size:, batch_size:) - # The reason for adding a delay is to give the replica database enough time to - # catch up with the primary when large batches of records are being added/removed. - # Hance, we add a delay only if the GitLab installation has a replica database configured. - entire_size > batch_size && - !::Gitlab::Database::LoadBalancing.primary_only? - end - - private_class_method def self.log_details(entire_size:, batch_size:) - Gitlab::AppLogger.info( - entire_size: entire_size, - total_delay: (entire_size / batch_size.to_f).ceil * SLEEP_DELAY, - message: 'Project authorizations refresh performed with delay', - **Gitlab::ApplicationContext.current - ) - end - - private_class_method def self.perform_delay - sleep(SLEEP_DELAY) - end end ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb new file mode 100644 index 00000000000..1d717950c1c --- /dev/null +++ b/app/models/project_authorizations/changes.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module ProjectAuthorizations + # How to use this class + # authorizations_to_add: + # Rows to insert in the form `[{ user_id: user_id, project_id: project_id, access_level: access_level}, ...] + # + # ProjectAuthorizations::Changes.new do |changes| + # changes.add(authorizations_to_add) + # changes.remove_users_in_project(project, user_ids) + # changes.remove_projects_for_user(user, project_ids) + # end.apply! + class Changes + attr_reader :projects_to_remove, :users_to_remove, :authorizations_to_add + + BATCH_SIZE = 1000 + SLEEP_DELAY = 0.1 + + def initialize + @authorizations_to_add = [] + @affected_project_ids = Set.new + yield self + end + + def add(authorizations_to_add) + @authorizations_to_add += authorizations_to_add + end + + def remove_users_in_project(project, user_ids) + @users_to_remove = { user_ids: user_ids, scope: project } + end + + def remove_projects_for_user(user, project_ids) + @projects_to_remove = { project_ids: project_ids, scope: user } + end + + def apply! + delete_authorizations_for_user if should_delete_authorizations_for_user? + delete_authorizations_for_project if should_delete_authorizations_for_project? + add_authorizations if should_add_authorization? + + publish_events + end + + private + + def should_add_authorization? + authorizations_to_add.present? + end + + def should_delete_authorizations_for_user? + user && project_ids.present? + end + + def should_delete_authorizations_for_project? + project && user_ids.present? + end + + def add_authorizations + insert_all_in_batches(authorizations_to_add) + @affected_project_ids += authorizations_to_add.pluck(:project_id) + end + + def delete_authorizations_for_user + delete_all_in_batches(resource: user, + ids_to_remove: project_ids, + column_name_of_ids_to_remove: :project_id) + @affected_project_ids += project_ids + end + + def delete_authorizations_for_project + delete_all_in_batches(resource: project, + ids_to_remove: user_ids, + column_name_of_ids_to_remove: :user_id) + @affected_project_ids << project.id + end + + def delete_all_in_batches(resource:, ids_to_remove:, column_name_of_ids_to_remove:) + add_delay = add_delay_between_batches?(entire_size: ids_to_remove.size, batch_size: BATCH_SIZE) + log_details(entire_size: ids_to_remove.size, batch_size: BATCH_SIZE) if add_delay + + ids_to_remove.each_slice(BATCH_SIZE) do |ids_batch| + resource.project_authorizations.where(column_name_of_ids_to_remove => ids_batch).delete_all + perform_delay if add_delay + end + end + + def insert_all_in_batches(attributes) + add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: BATCH_SIZE) + log_details(entire_size: attributes.size, batch_size: BATCH_SIZE) if add_delay + + attributes.each_slice(BATCH_SIZE) do |attributes_batch| + ProjectAuthorization.insert_all(attributes_batch) + perform_delay if add_delay + end + end + + def add_delay_between_batches?(entire_size:, batch_size:) + # The reason for adding a delay is to give the replica database enough time to + # catch up with the primary when large batches of records are being added/removed. + # Hence, we add a delay only if the GitLab installation has a replica database configured. + entire_size > batch_size && + !::Gitlab::Database::LoadBalancing.primary_only? + end + + def log_details(entire_size:, batch_size:) + Gitlab::AppLogger.info( + entire_size: entire_size, + total_delay: (entire_size / batch_size.to_f).ceil * SLEEP_DELAY, + message: 'Project authorizations refresh performed with delay', + **Gitlab::ApplicationContext.current + ) + end + + def perform_delay + sleep(SLEEP_DELAY) + end + + def user + projects_to_remove&.[](:scope) + end + + def project_ids + projects_to_remove&.[](:project_ids) + end + + def project + users_to_remove&.[](:scope) + end + + def user_ids + users_to_remove&.[](:user_ids) + end + + def publish_events + @affected_project_ids.each do |project_id| + ::Gitlab::EventStore.publish( + ::ProjectAuthorizations::AuthorizationsChangedEvent.new(data: { project_id: project_id }) + ) + end + end + end +end diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index 9f9447c1de2..69d8c0db55b 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -3,6 +3,7 @@ class ProjectGroupLink < ApplicationRecord include Expirable include EachBatch + include AfterCommitQueue belongs_to :project belongs_to :group @@ -16,6 +17,7 @@ class ProjectGroupLink < ApplicationRecord scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) } scope :in_group, -> (group_ids) { where(group_id: group_ids) } + scope :for_projects, -> (project_ids) { where(project_id: project_ids) } alias_method :shared_with_group, :group alias_method :shared_from, :project diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index aeefa5c8dcd..fec951eb7fe 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -99,6 +99,11 @@ class ProjectSetting < ApplicationRecord Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && read_attribute(:runner_registration_enabled) end + def emails_enabled? + super && project.namespace.emails_enabled? + end + strong_memoize_attr :emails_enabled? + private def validates_mr_default_target_self diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 365bb5237c3..942f20f6e5e 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -19,7 +19,7 @@ class ProjectStatistics < ApplicationRecord Namespaces::ScheduleAggregationWorker.perform_async(project_statistics.namespace_id) end - before_save :update_storage_size + after_commit :refresh_storage_size!, on: :update, if: -> { storage_size_components_changed? } COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size, :uploads_size, :container_registry_size].freeze INCREMENTABLE_COLUMNS = [ @@ -67,7 +67,7 @@ class ProjectStatistics < ApplicationRecord end def update_repository_size - self.repository_size = project.repository.size * 1.megabyte + self.repository_size = project.repository.recent_objects_size.megabytes end def update_wiki_size @@ -105,19 +105,14 @@ class ProjectStatistics < ApplicationRecord super.to_i end - def update_storage_size - self.storage_size = storage_size_components.sum { |component| method(component).call } - end - + # Since this incremental update method does not update the storage_size directly, + # we have to update the storage_size separately in an after_commit action. def refresh_storage_size! detect_race_on_record(log_fields: { caller: __method__, attributes: :storage_size }) do - update!(storage_size: storage_size_sum) + self.class.where(id: id).update_all("storage_size = #{storage_size_sum}") end end - # Since this incremental update method does not call update_storage_size above through before_save, - # we have to update the storage_size separately. - # # For counter attributes, storage_size will be refreshed after the counter is flushed, # through counter_attribute_after_commit # @@ -169,6 +164,10 @@ class ProjectStatistics < ApplicationRecord Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id) end end + + def storage_size_components_changed? + (previous_changes.keys & STORAGE_SIZE_COMPONENTS.map(&:to_s)).any? + end end ProjectStatistics.prepend_mod_with('ProjectStatistics') diff --git a/app/models/project_team.rb b/app/models/project_team.rb index fbdc88e7b76..3b9b82ee094 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -141,12 +141,10 @@ class ProjectTeam end ProjectMember.transaction do - source_members.each do |member| - member.save - end + source_members.each(&:save) end - true + source_members rescue StandardError false end diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index 749f4a87818..54b4c9d0fe1 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RedirectRoute < ApplicationRecord +class RedirectRoute < MainClusterwide::ApplicationRecord include CaseSensitivity belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations diff --git a/app/models/release.rb b/app/models/release.rb index f0ba56390ab..6830f6e8480 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -20,6 +20,8 @@ class Release < ApplicationRecord has_many :milestones, through: :milestone_releases has_many :evidences, inverse_of: :release, class_name: 'Releases::Evidence' + has_one :catalog_resource_version, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :release + accepts_nested_attributes_for :links, allow_destroy: true before_create :set_released_at diff --git a/app/models/repository.rb b/app/models/repository.rb index 1321c9da780..b8a46f80bc7 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -47,7 +47,7 @@ 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 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 @@ -363,7 +363,7 @@ class Repository end def expire_statistics_caches - expire_method_caches(%i(size commit_count)) + expire_method_caches(%i(size recent_objects_size commit_count)) end def expire_all_method_caches @@ -579,6 +579,12 @@ class Repository end cache_method :size, fallback: 0.0 + # The recent objects size of this repository in mebibytes. + def recent_objects_size + exists? ? raw_repository.recent_objects_size : 0.0 + end + cache_method :recent_objects_size, fallback: 0.0 + def commit_count root_ref ? raw_repository.commit_count(root_ref) : 0 end @@ -691,7 +697,7 @@ class Repository @head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths) end - def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil) + def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil, rescue_not_found: true) if sha == :head return if empty? || root_ref.nil? @@ -703,7 +709,7 @@ class Repository end end - Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params, ref_type: ref_type) + Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params, ref_type: ref_type, rescue_not_found: rescue_not_found) end def blob_at_branch(branch_name, path) @@ -1242,6 +1248,20 @@ class Repository prohibited_branches.each { |name| raw_repository.delete_branch(name) } end + def get_patch_id(old_revision, new_revision) + raw_repository.get_patch_id(old_revision, new_revision) + end + + def object_pool + gitaly_object_pool = raw.object_pool + + return unless gitaly_object_pool + + source_project = project&.pool_repository&.source_project + + Gitlab::Git::ObjectPool.init_from_gitaly(gitaly_object_pool, source_project&.repository) + end + private def ancestor_cache_key(ancestor_id, descendant_id) diff --git a/app/models/review.rb b/app/models/review.rb index c621da3b03c..d47aaf027ce 100644 --- a/app/models/review.rb +++ b/app/models/review.rb @@ -32,3 +32,5 @@ class Review < ApplicationRecord merge_request.user_mentions.where.not(note_id: nil) end end + +Review.prepend_mod diff --git a/app/models/route.rb b/app/models/route.rb index f2fe1664f9e..652c33a673c 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Route < ApplicationRecord +class Route < MainClusterwide::ApplicationRecord include CaseSensitivity include Gitlab::SQL::Pattern diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index c2fd8b20942..f3a0479d3b7 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class SentNotification < ApplicationRecord - include IgnorableColumns - - ignore_column %i[line_code note_type position], remove_with: '16.3', remove_after: '2023-07-22' - belongs_to :project belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :recipient, class_name: "User" diff --git a/app/models/service_desk/custom_email_verification.rb b/app/models/service_desk/custom_email_verification.rb index 482a10447ed..5099cf4c5bb 100644 --- a/app/models/service_desk/custom_email_verification.rb +++ b/app/models/service_desk/custom_email_verification.rb @@ -26,6 +26,8 @@ module ServiceDesk validates :project, presence: true validates :state, presence: true + scope :overdue, -> { where('triggered_at < ?', TIMEFRAME.ago) } + delegate :service_desk_setting, to: :project state_machine :state do diff --git a/app/models/system/broadcast_message.rb b/app/models/system/broadcast_message.rb new file mode 100644 index 00000000000..332baea4449 --- /dev/null +++ b/app/models/system/broadcast_message.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +module System + class BroadcastMessage < MainClusterwide::ApplicationRecord + include CacheMarkdownField + include Sortable + + ALLOWED_TARGET_ACCESS_LEVELS = [ + Gitlab::Access::GUEST, + Gitlab::Access::REPORTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::MAINTAINER, + Gitlab::Access::OWNER + ].freeze + + cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true + + validates :message, presence: true + validates :starts_at, presence: true + validates :ends_at, presence: true + validates :broadcast_type, presence: true + validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS } + validates :show_in_cli, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } + + validates :color, allow_blank: true, color: true + validates :font, allow_blank: true, color: true + + attribute :color, default: '#E75E40' + attribute :font, default: '#FFFFFF' + + scope :current_and_future_messages, -> { where('ends_at > :now', now: Time.current).order_id_asc } + + CACHE_KEY = 'broadcast_message_current_json' + BANNER_CACHE_KEY = 'broadcast_message_current_banner_json' + NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json' + + after_commit :flush_redis_cache + + enum theme: { + indigo: 0, + 'light-indigo': 1, + blue: 2, + 'light-blue': 3, + green: 4, + 'light-green': 5, + red: 6, + 'light-red': 7, + dark: 8, + light: 9 + }, _default: 0, _prefix: true + + enum broadcast_type: { + banner: 1, + notification: 2 + } + + class << self + def current_banner_messages(current_path: nil, user_access_level: nil) + fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do + current_and_future_messages.banner + end + end + + def current_show_in_cli_banner_messages + current_banner_messages.select(&:show_in_cli?) + end + + def current_notification_messages(current_path: nil, user_access_level: nil) + fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do + current_and_future_messages.notification + end + end + + def current(current_path: nil, user_access_level: nil) + fetch_messages CACHE_KEY, current_path, user_access_level do + current_and_future_messages + end + end + + def cache + ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do + Gitlab::Cache::JsonCaches::JsonKeyed.new + end + end + + def cache_expires_in + 2.weeks + end + + private + + def fetch_messages(cache_key, current_path, user_access_level, &block) + messages = cache.fetch(cache_key, as: System::BroadcastMessage, expires_in: cache_expires_in, &block) + + now_or_future = messages.select(&:now_or_future?) + + # If there are cached entries but they don't match the ones we are + # displaying we'll refresh the cache so we don't need to keep filtering. + cache.expire(cache_key) if now_or_future != messages + + messages = now_or_future.select(&:now?) + messages = messages.select do |message| + message.matches_current_user_access_level?(user_access_level) + end + messages.select do |message| + message.matches_current_path(current_path) + end + end + end + + def active? + started? && !ended? + end + + def started? + Time.current >= starts_at + end + + def ended? + ends_at < Time.current + end + + def now? + (starts_at..ends_at).cover?(Time.current) + end + + def future? + starts_at > Time.current + end + + def now_or_future? + now? || future? + end + + def matches_current_user_access_level?(user_access_level) + return true unless target_access_levels.present? + + target_access_levels.include? user_access_level + end + + def matches_current_path(current_path) + return false if current_path.blank? && target_path.present? + return true if current_path.blank? || target_path.blank? + + # Ensure paths are consistent across callers. + # This fixes a mismatch between requests in the GUI and CLI + # + # This has to be reassigned due to frozen strings being provided. + current_path = "/#{current_path}" unless current_path.start_with?("/") + + escaped = Regexp.escape(target_path).gsub('\\*', '.*') + regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE + + regexp.match(current_path) + end + + def flush_redis_cache + [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key| + self.class.cache.expire(key) + end + end + end +end + +System::BroadcastMessage.prepend_mod_with('System::BroadcastMessage') diff --git a/app/models/todo.rb b/app/models/todo.rb index f202e1a266d..d159b51a0eb 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -24,6 +24,7 @@ class Todo < ApplicationRecord MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature REVIEW_REQUESTED = 9 MEMBER_ACCESS_REQUESTED = 10 + REVIEW_SUBMITTED = 11 # This is an EE-only feature ACTION_NAMES = { ASSIGNED => :assigned, @@ -35,7 +36,8 @@ class Todo < ApplicationRecord UNMERGEABLE => :unmergeable, DIRECTLY_ADDRESSED => :directly_addressed, MERGE_TRAIN_REMOVED => :merge_train_removed, - MEMBER_ACCESS_REQUESTED => :member_access_requested + MEMBER_ACCESS_REQUESTED => :member_access_requested, + REVIEW_SUBMITTED => :review_submitted }.freeze ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze @@ -223,6 +225,10 @@ class Todo < ApplicationRecord action == MEMBER_ACCESS_REQUESTED end + def review_submitted? + action == REVIEW_SUBMITTED + end + def member_access_type target.class.name.downcase end diff --git a/app/models/tree.rb b/app/models/tree.rb index 8622eb793c1..4d62334800d 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -7,7 +7,7 @@ class Tree def initialize( repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil, - ref_type: nil) + ref_type: nil, rescue_not_found: true) path = '/' if path.blank? @repository = repository @@ -18,7 +18,9 @@ class Tree ref = ExtractsRef.qualify_ref(@sha, ref_type) - @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, pagination_params) + @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, rescue_not_found, + pagination_params) + @entries.each do |entry| entry.ref_type = self.ref_type end diff --git a/app/models/user.rb b/app/models/user.rb index 4a57cc2e2e2..9f85d41b133 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,7 +2,7 @@ require 'carrierwave/orm/activerecord' -class User < ApplicationRecord +class User < MainClusterwide::ApplicationRecord extend Gitlab::ConfigHelper include Gitlab::ConfigHelper @@ -403,6 +403,7 @@ class User < ApplicationRecord delegate :location, :location=, to: :user_detail, allow_nil: true delegate :organization, :organization=, to: :user_detail, allow_nil: true delegate :discord, :discord=, to: :user_detail, allow_nil: true + delegate :email_reset_offered_at, :email_reset_offered_at=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true @@ -520,7 +521,11 @@ class User < ApplicationRecord scope :active, -> { with_state(:active).non_internal } scope :active_without_ghosts, -> { with_state(:active).without_ghosts } scope :deactivated, -> { with_state(:deactivated).non_internal } - scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } + scope :without_projects, -> do + joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id') + .where(project_authorizations: { user_id: nil }) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') + end scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } scope :by_name, -> (names) { iwhere(name: Array(names)) } scope :by_login, -> (login) do @@ -1765,13 +1770,7 @@ class User < ApplicationRecord def following_users_allowed?(user) return false if self.id == user.id - following_users_enabled? && user.following_users_enabled? - end - - def following_users_enabled? - return true unless ::Feature.enabled?(:disable_follow_users, self) - - enabled_following + enabled_following && user.enabled_following end def forkable_namespaces @@ -2192,14 +2191,6 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end - def dismissed_callout_before?(feature_name, dismissed_before) - callout = callouts_by_feature_name[feature_name] - - return false unless callout - - callout.dismissed_before?(dismissed_before) - end - def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil) source_feature_name = "#{feature_name}_#{group.id}" callout = group_callouts_by_feature_name[source_feature_name] diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 5c9a73571c0..9ac814eebda 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true -class UserDetail < ApplicationRecord +class UserDetail < MainClusterwide::ApplicationRecord include IgnorableColumns extend ::Gitlab::Utils::Override ignore_column :requires_credit_card_verification, remove_with: '16.1', remove_after: '2023-06-22' - ignore_column :provisioned_by_group_at, remove_with: '16.3', remove_after: '2023-07-22' REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index c263d552d40..eac66905d0c 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserPreference < ApplicationRecord +class UserPreference < MainClusterwide::ApplicationRecord include IgnorableColumns # We could use enums, but Rails 4 doesn't support multiple diff --git a/app/models/user_status.rb b/app/models/user_status.rb index da24ef47a2a..35aa2427442 100644 --- a/app/models/user_status.rb +++ b/app/models/user_status.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserStatus < ApplicationRecord +class UserStatus < MainClusterwide::ApplicationRecord include CacheMarkdownField self.primary_key = :user_id diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb index 6b23bce6406..0856febf3f6 100644 --- a/app/models/user_synced_attributes_metadata.rb +++ b/app/models/user_synced_attributes_metadata.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserSyncedAttributesMetadata < ApplicationRecord +class UserSyncedAttributesMetadata < MainClusterwide::ApplicationRecord belongs_to :user validates :user, presence: true diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 0d02a3b99aa..0d3262b2474 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class Callout < ApplicationRecord + class Callout < MainClusterwide::ApplicationRecord include Users::Calloutable self.table_name = 'user_callouts' diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb index 483d0d785a5..280a819e4d5 100644 --- a/app/models/users/calloutable.rb +++ b/app/models/users/calloutable.rb @@ -13,9 +13,5 @@ module Users def dismissed_after?(dismissed_after) dismissed_at > dismissed_after end - - def dismissed_before?(dismissed_before) - dismissed_at < dismissed_before - end end end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index e1468872f52..a7e2be0eae5 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -284,10 +284,9 @@ class WikiPage def content_changed? if persisted? - # gollum-lib always converts CRLFs to LFs in Gollum::Wiki#normalize, - # so we need to do the same here. - # Also see https://gitlab.com/gitlab-org/gitlab/-/issues/21431 - raw_content.delete("\r") != page&.text_data + # To avoid end-of-line differences depending if Git is enforcing CRLF or not, + # we compare just the Wiki Content. + raw_content.lines(chomp: true) != page&.text_data&.lines(chomp: true) else raw_content.present? end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index adf424a1d94..73156b2f040 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -22,6 +22,18 @@ class WorkItem < Issue foreign_key: :work_item_id, source: :work_item scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) } + scope :in_namespaces, ->(namespaces) { where(namespace: namespaces) } + + scope :with_confidentiality_check, ->(user) { + confidential_query = <<~SQL + issues.confidential = FALSE + OR (issues.confidential = TRUE + AND (issues.author_id = :user_id + OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id))) + SQL + + where(confidential_query, user_id: user.id) + } class << self def assignee_association_name @@ -59,6 +71,11 @@ class WorkItem < Issue includes(:parent_link).order(keyset_order) end + + override :related_link_class + def related_link_class + WorkItems::RelatedWorkItemLink + end end def noteable_target_type_name diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index 5dff9e8e8d5..d9e3690b6fc 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -19,8 +19,10 @@ module WorkItems validate :validate_same_project validate :validate_max_children validate :validate_confidentiality + validate :check_existing_related_link scope :for_parents, ->(parent_ids) { where(work_item_parent_id: parent_ids) } + scope :for_children, ->(children_ids) { where(work_item: children_ids) } class << self def has_public_children?(parent_id) @@ -109,5 +111,14 @@ module WorkItems errors.add :work_item, _('is already present in ancestors') end end + + def check_existing_related_link + return unless work_item && work_item_parent + + existing_link = WorkItems::RelatedWorkItemLink.for_items(work_item, work_item_parent) + return if existing_link.none? + + errors.add(:work_item, _('cannot assign a linked work item as a parent')) + end end end diff --git a/app/models/work_items/related_work_item_link.rb b/app/models/work_items/related_work_item_link.rb new file mode 100644 index 00000000000..4de197d3d35 --- /dev/null +++ b/app/models/work_items/related_work_item_link.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module WorkItems + class RelatedWorkItemLink < ApplicationRecord + include LinkableItem + + self.table_name = 'issue_links' + + belongs_to :source, class_name: 'WorkItem' + belongs_to :target, class_name: 'WorkItem' + + class << self + extend ::Gitlab::Utils::Override + + # Used as issuable table name for calculating blocked and blocking count in IssuableLink + override :issuable_type + def issuable_type + :issue + end + + override :issuable_name + def issuable_name + 'work item' + end + end + end +end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 6a619dbab21..369ffc660aa 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -19,7 +19,9 @@ module WorkItems requirement: 'Requirement', task: 'Task', objective: 'Objective', - key_result: 'Key Result' + key_result: 'Key Result', + epic: 'Epic', + ticket: 'Ticket' }.freeze # Base types need to exist on the DB on app startup @@ -32,7 +34,9 @@ module WorkItems requirement: { name: TYPE_NAMES[:requirement], icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only task: { name: TYPE_NAMES[:task], icon_name: 'issue-type-task', enum_value: 4 }, objective: { name: TYPE_NAMES[:objective], icon_name: 'issue-type-objective', enum_value: 5 }, ## EE-only - key_result: { name: TYPE_NAMES[:key_result], icon_name: 'issue-type-keyresult', enum_value: 6 } ## EE-only + key_result: { name: TYPE_NAMES[:key_result], icon_name: 'issue-type-keyresult', enum_value: 6 }, ## EE-only + epic: { name: TYPE_NAMES[:epic], icon_name: 'issue-type-epic', enum_value: 7 }, ## EE-only + ticket: { name: TYPE_NAMES[:ticket], icon_name: 'issue-type-issue', enum_value: 8 } }.freeze # A list of types user can change between - both original and new @@ -40,7 +44,7 @@ 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].freeze + WI_TYPES_WITH_CREATED_HEADER = %w[issue incident ticket].freeze cache_markdown_field :description, pipeline: :single_line @@ -79,7 +83,7 @@ module WorkItems end def self.allowed_types_for_issues - base_types.keys.excluding('task', 'objective', 'key_result') + base_types.keys.excluding('task', 'objective', 'key_result', 'epic', 'ticket') end def default? diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb index 763b1a79069..f25c951406f 100644 --- a/app/models/work_items/widget_definition.rb +++ b/app/models/work_items/widget_definition.rb @@ -31,7 +31,8 @@ module WorkItems test_reports: 13, # EE-only notifications: 14, current_user_todos: 15, - award_emoji: 16 + award_emoji: 16, + linked_items: 17 } def self.available_widgets diff --git a/app/models/work_items/widgets/linked_items.rb b/app/models/work_items/widgets/linked_items.rb new file mode 100644 index 00000000000..06a0f6db964 --- /dev/null +++ b/app/models/work_items/widgets/linked_items.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class LinkedItems < Base + delegate :related_issues, to: :work_item + end + end +end diff --git a/app/policies/admin/abuse_report_label_policy.rb b/app/policies/admin/abuse_report_label_policy.rb new file mode 100644 index 00000000000..69c877c90b3 --- /dev/null +++ b/app/policies/admin/abuse_report_label_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportLabelPolicy < ::BasePolicy + rule { admin }.policy do + enable :read_label + end + end +end diff --git a/app/policies/ci/bridge_policy.rb b/app/policies/ci/bridge_policy.rb index 37a07ea8aaf..5f9e8eab08a 100644 --- a/app/policies/ci/bridge_policy.rb +++ b/app/policies/ci/bridge_policy.rb @@ -2,6 +2,8 @@ module Ci class BridgePolicy < CommitStatusPolicy + include Ci::DeployablePolicy + condition(:can_update_downstream_branch) do ::Gitlab::UserAccess.new(@user, container: @subject.downstream_project) .can_update_branch?(@subject.target_revision_ref) diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 73e4cbee54a..bce7ceafe17 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -2,6 +2,8 @@ module Ci class BuildPolicy < CommitStatusPolicy + include Ci::DeployablePolicy + delegate { @subject.project } condition(:protected_ref) do @@ -22,15 +24,6 @@ module Ci end end - # overridden in EE - condition(:protected_environment) do - false - end - - condition(:outdated_deployment) do - @subject.outdated_deployment? - end - condition(:owner_of_job) do @subject.triggered_by?(@user) end @@ -73,21 +66,24 @@ module Ci # Use admin_ci_minutes for detailed quota and usage reporting # this is limited to total usage and total quota for a builds namespace - rule { can_read_project_build }.enable :read_ci_minutes_limited_summary + rule { can_read_project_build }.policy do + enable :read_ci_minutes_limited_summary + enable :read_build_trace + end - rule { can_read_project_build }.enable :read_build_trace rule { debug_mode & ~project_update_build }.prevent :read_build_trace # Authorizing the user to access to protected entities. # There is a "jailbreak" mode to exceptionally bypass the authorization, # however, you should NEVER allow it, rather suspect it's a wrong feature/product design. - rule { ~can?(:jailbreak) & (archived | (protected_ref & ~admin) | protected_environment) }.policy do - prevent :update_build + rule { ~can?(:jailbreak) & (archived | (protected_ref & ~admin)) }.policy do prevent :update_commit_status - prevent :erase_build end - rule { outdated_deployment }.prevent :update_build + rule { ~can?(:jailbreak) & (archived | protected_ref) }.policy do + prevent :update_build + prevent :erase_build + end rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build diff --git a/app/policies/ci/deployable_policy.rb b/app/policies/ci/deployable_policy.rb new file mode 100644 index 00000000000..f0105b001f2 --- /dev/null +++ b/app/policies/ci/deployable_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + module DeployablePolicy + extend ActiveSupport::Concern + + included do + prepend_mod_with('Ci::DeployablePolicy') # rubocop: disable Cop/InjectEnterpriseEditionModule + + condition(:outdated_deployment) do + @subject.outdated_deployment? + end + + rule { outdated_deployment }.prevent :update_build + end + end +end diff --git a/app/policies/concerns/find_group_projects.rb b/app/policies/concerns/find_group_projects.rb index aad9081bd7d..914e336b4ab 100644 --- a/app/policies/concerns/find_group_projects.rb +++ b/app/policies/concerns/find_group_projects.rb @@ -3,11 +3,11 @@ module FindGroupProjects extend ActiveSupport::Concern - def group_projects_for(user:, group:, only_owned: true) + def group_projects_for(user:, group:, exclude_shared: true) GroupProjectsFinder.new( group: group, current_user: user, - options: { include_subgroups: true, only_owned: only_owned } + options: { include_subgroups: true, exclude_shared: exclude_shared } ).execute end end diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb index b117bb57921..ccf1bda26bb 100644 --- a/app/policies/deploy_key_policy.rb +++ b/app/policies/deploy_key_policy.rb @@ -3,10 +3,14 @@ class DeployKeyPolicy < BasePolicy with_options scope: :subject, score: 0 condition(:private_deploy_key) { @subject.private? } + condition(:public_deploy_key) { @subject.public? } condition(:has_deploy_key) { @user.project_deploy_keys.any? { |pdk| pdk.id.eql?(@subject.id) } } rule { anonymous }.prevent_all - - rule { admin }.enable :update_deploy_key - rule { private_deploy_key & has_deploy_key }.enable :update_deploy_key + rule { public_deploy_key | admin | has_deploy_key }.policy do + enable :read_deploy_key + end + rule { admin | (private_deploy_key & has_deploy_key) }.policy do + enable :update_deploy_key + end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 29b966b43e2..c50f74f2b35 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -61,7 +61,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy end condition(:design_management_enabled) do - group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? } + group_projects_for(user: @user, group: @subject, exclude_shared: false).any? { |p| p.design_management_enabled? } end condition(:dependency_proxy_available, scope: :subject) do @@ -148,6 +148,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_group_member enable :read_custom_emoji enable :read_counts + enable :read_issue end rule { achievements_enabled }.policy do @@ -230,7 +231,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_usage_quotas enable :read_group_runners - enable :admin_group_runners enable :register_group_runners enable :create_runner diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb index cac8d07811d..1c0d996c7d4 100644 --- a/app/policies/organizations/organization_policy.rb +++ b/app/policies/organizations/organization_policy.rb @@ -2,8 +2,22 @@ module Organizations class OrganizationPolicy < BasePolicy + condition(:organization_user) { @subject.user?(@user) } + + desc 'Organization is public' + condition(:public_organization, scope: :subject, score: 0) { true } + + rule { public_organization }.policy do + enable :read_organization + end + rule { admin }.policy do enable :admin_organization + enable :read_organization + end + + rule { organization_user }.policy do + enable :read_organization end end end diff --git a/app/policies/packages/policies/project_policy.rb b/app/policies/packages/policies/project_policy.rb index 35161fd95f1..deb6d13dd14 100644 --- a/app/policies/packages/policies/project_policy.rb +++ b/app/policies/packages/policies/project_policy.rb @@ -8,7 +8,8 @@ module Packages overrides(:read_package) condition(:packages_enabled_for_everyone, scope: :subject) do - @subject.package_registry_access_level == ProjectFeature::PUBLIC + @subject.package_registry_access_level == ProjectFeature::PUBLIC && + Gitlab::CurrentSettings.package_registry_allow_anyone_to_pull_option end rule { project.packages_disabled }.policy do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index ad6155258ab..564215f6e50 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -44,6 +44,9 @@ class ProjectPolicy < BasePolicy desc "Project is public" condition(:public_project, scope: :subject, score: 0) { project.public? } + desc "project is private" + condition(:private_project, scope: :subject, score: 0) { project.private? } + desc "Project is visible to internal users" condition(:internal_access) do project.internal? && !user.external? @@ -55,6 +58,9 @@ class ProjectPolicy < BasePolicy desc "User is a requester of the group" condition(:group_requester, scope: :subject) { project_group_requester? } + desc "User is external" + condition(:external_user) { user.external? } + desc "Project is archived" condition(:archived, scope: :subject, score: 0) { project.archived? } @@ -913,6 +919,8 @@ class ProjectPolicy < BasePolicy prevent :read_project end + rule { ~private_project & guest & external_user }.enable :read_container_image + private def user_is_user? diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb index 1ccc152bc6b..23b1d54b3bf 100644 --- a/app/policies/work_item_policy.rb +++ b/app/policies/work_item_policy.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true class WorkItemPolicy < IssuePolicy + condition(:is_member) { is_project_member? } condition(:is_member_and_author) { is_project_member? & is_author? } rule { can?(:admin_issue) }.enable :admin_work_item - rule { can?(:destroy_issue) | is_member_and_author }.enable :delete_work_item rule { can?(:update_issue) }.enable :update_work_item + rule { can?(:set_issue_metadata) }.enable :set_work_item_metadata rule { can?(:read_issue) }.enable :read_work_item @@ -20,4 +21,6 @@ class WorkItemPolicy < IssuePolicy rule { can?(:reporter_access) }.policy do enable :admin_parent_link end + + rule { is_member & can?(:read_work_item) }.enable :admin_work_item_link end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index 69d775d8125..42ecbc9988e 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -16,6 +16,10 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated issue.project.emails_disabled? end + def project_emails_enabled? + issue.project.emails_enabled? + end + delegator_override :service_desk_reply_to def service_desk_reply_to return unless super.present? diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 8d2baa6ee99..5c23af6e821 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -266,10 +266,15 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def issues_sentence(project, issues) # Sorting based on the `#123` or `group/project#123` reference will sort - # local issues first. - issues.map do |issue| + # local issues numerically first. + issue_refs = issues.map do |issue| issue.to_reference(project) - end.sort.to_sentence + end + + issue_refs.sort_by do |issue_ref| + path_section = issue_ref.split('#') + [path_section.first, path_section.last.to_i] + end.to_sentence end def user_can_fork_project? diff --git a/app/presenters/ml/model_presenter.rb b/app/presenters/ml/model_presenter.rb new file mode 100644 index 00000000000..1317a13351b --- /dev/null +++ b/app/presenters/ml/model_presenter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ml + class ModelPresenter < Gitlab::View::Presenter::Delegated + presents ::Ml::Model, as: :model + + def latest_version_name + model.latest_version&.version + end + + def latest_package_path + return unless model.latest_version&.package_id.present? + + Gitlab::Routing.url_helpers.project_package_path(model.project, model.latest_version.package_id) + end + end +end diff --git a/app/presenters/ml/models_index_presenter.rb b/app/presenters/ml/models_index_presenter.rb deleted file mode 100644 index e2cb8e2d6c1..00000000000 --- a/app/presenters/ml/models_index_presenter.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Ml - class ModelsIndexPresenter - def initialize(models) - @models = models - end - - def present - data = @models.map do |m| - { - name: m.name, - version: m.version, - path: Gitlab::Routing.url_helpers.project_package_path(m.project, m) - } - end - - Gitlab::Json.generate({ models: data }) - end - end -end diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb deleted file mode 100644 index 42f61182ab8..00000000000 --- a/app/presenters/packages/npm/package_presenter.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Packages - module Npm - class PackagePresenter - def initialize(metadata) - @metadata = metadata - end - - def name - metadata[:name] - end - - def versions - metadata[:versions] - end - - def dist_tags - metadata[:dist_tags] - end - - private - - attr_reader :metadata - end - end -end diff --git a/app/presenters/packages/nuget/v2/metadata_index_presenter.rb b/app/presenters/packages/nuget/v2/metadata_index_presenter.rb new file mode 100644 index 00000000000..0ce7c8956b3 --- /dev/null +++ b/app/presenters/packages/nuget/v2/metadata_index_presenter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module V2 + class MetadataIndexPresenter + def xml + Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml['edmx'].Edmx('xmlns:edmx' => 'http://schemas.microsoft.com/ado/2007/06/edmx', Version: '1.0') do + xml['edmx'].DataServices('xmlns:m' => 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata', + 'm:DataServiceVersion' => '2.0', 'm:MaxDataServiceVersion' => '2.0') do + xml.Schema(xmlns: 'http://schemas.microsoft.com/ado/2006/04/edm', Namespace: 'NuGetGallery.OData') do + xml.EntityType(Name: 'V2FeedPackage', 'm:HasStream' => true) do + xml.Key do + xml.PropertyRef(Name: 'Id') + xml.PropertyRef(Name: 'Version') + end + xml.Property(Name: 'Id', Type: 'Edm.String', Nullable: false) + xml.Property(Name: 'Version', Type: 'Edm.String', Nullable: false) + xml.Property(Name: 'Authors', Type: 'Edm.String') + xml.Property(Name: 'Dependencies', Type: 'Edm.String') + xml.Property(Name: 'Description', Type: 'Edm.String') + xml.Property(Name: 'DownloadCount', Type: 'Edm.Int64', Nullable: false) + xml.Property(Name: 'IconUrl', Type: 'Edm.String') + xml.Property(Name: 'Published', Type: 'Edm.DateTime', Nullable: false) + xml.Property(Name: 'ProjectUrl', Type: 'Edm.String') + xml.Property(Name: 'Tags', Type: 'Edm.String') + xml.Property(Name: 'Title', Type: 'Edm.String') + xml.Property(Name: 'LicenseUrl', Type: 'Edm.String') + end + end + xml.Schema(xmlns: 'http://schemas.microsoft.com/ado/2006/04/edm', Namespace: 'NuGetGallery') do + xml.EntityContainer(Name: 'V2FeedContext', 'm:IsDefaultEntityContainer' => true) do + xml.EntitySet(Name: 'Packages', EntityType: 'NuGetGallery.OData.V2FeedPackage') + xml.FunctionImport(Name: 'FindPackagesById', + ReturnType: 'Collection(NuGetGallery.OData.V2FeedPackage)', EntitySet: 'Packages') do + xml.Parameter(Name: 'id', Type: 'Edm.String', FixedLength: 'false', Unicode: 'false') + end + end + end + end + end + end + end + end + end + end +end diff --git a/app/presenters/packages/nuget/v2/service_index_presenter.rb b/app/presenters/packages/nuget/v2/service_index_presenter.rb new file mode 100644 index 00000000000..a8fc9b673bf --- /dev/null +++ b/app/presenters/packages/nuget/v2/service_index_presenter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module V2 + class ServiceIndexPresenter + include API::Helpers::RelatedResourcesHelpers + + ROOT_ATTRIBUTES = { + xmlns: 'http://www.w3.org/2007/app', + 'xmlns:atom' => 'http://www.w3.org/2005/Atom' + }.freeze + + def initialize(project_or_group) + @project_or_group = project_or_group + end + + def xml + Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml.service(ROOT_ATTRIBUTES.merge('xml:base' => xml_base)) do + xml.workspace do + xml['atom'].title('Default', type: 'text') + xml.collection(href: 'Packages') do + xml['atom'].title('Packages', type: 'text') + end + end + end + end + end + + private + + attr_reader :project_or_group + + def xml_base + base_path = case project_or_group + when Project + api_v4_projects_packages_nuget_v2_path(id: project_or_group.id) + when Group + api_v4_groups___packages_nuget_v2_path(id: project_or_group.id) + end + + expose_url(base_path) + end + end + end + end +end diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index 76cc8242da8..0c16c729e9c 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -52,3 +52,5 @@ module Projects end end end + +Projects::ImportExport::ProjectExportPresenter.prepend_mod_with('Projects::ImportExport::ProjectExportPresenter') diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index f0e84fc44d2..3efb8508e5e 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -79,9 +79,17 @@ module Admin 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 end end end diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb index 58637445e81..22395a2fe91 100644 --- a/app/serializers/admin/abuse_report_entity.rb +++ b/app/serializers/admin/abuse_report_entity.rb @@ -7,6 +7,7 @@ module Admin expose :category expose :created_at expose :updated_at + expose :count expose :reported_user do |report| UserEntity.represent(report.user, only: [:name]) @@ -19,5 +20,11 @@ module Admin expose :report_path do |report| admin_abuse_report_path(report) end + + private + + def count + object.has_attribute?(:count) ? object.count : 1 + end end end diff --git a/app/serializers/base_discussion_entity.rb b/app/serializers/base_discussion_entity.rb index 7d3b9651b8b..0b006078343 100644 --- a/app/serializers/base_discussion_entity.rb +++ b/app/serializers/base_discussion_entity.rb @@ -15,7 +15,6 @@ class BaseDiscussionEntity < Grape::Entity expose :for_commit?, as: :for_commit expose :individual_note?, as: :individual_note expose :resolvable?, as: :resolvable - expose :resolved_by_push?, as: :resolved_by_push expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) } @@ -34,18 +33,23 @@ class BaseDiscussionEntity < Grape::Entity discussion_path(discussion) end - with_options if: -> (d, _) { d.resolvable? } do + with_options if: -> (d, _) { d.noteable.supports_resolvable_notes? } do + expose :resolved?, as: :resolved + expose :resolved_by_push?, as: :resolved_by_push + expose :resolved_by, using: NoteUserEntity + expose :resolved_at + expose :resolve_path do |discussion| - resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) + resolve_project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion.id) end - expose :resolve_with_issue_path do |discussion| + expose :resolve_with_issue_path, if: -> (d, _) { d.noteable.is_a?(MergeRequest) } do |discussion| new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) if discussion&.project&.issues_enabled? end end expose :truncated_diff_lines_path, if: -> (d, _) { !d.expanded? && !render_truncated_diff_lines? } do |discussion| - project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) + project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion) end private diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index f63a1bf094a..7cd913d057e 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -41,8 +41,8 @@ class DeploymentEntity < Grape::Entity expose :commit, using: CommitEntity, if: -> (*) { include_details? } expose :manual_actions, using: Ci::JobEntity, if: -> (*) { include_details? && can_create_deployment? } expose :scheduled_actions, using: Ci::JobEntity, if: -> (*) { include_details? && can_create_deployment? } - expose :playable_build, if: -> (deployment) { include_details? && can_create_deployment? && deployment.playable_build } do |deployment, options| - Ci::JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path])) + expose :playable_job, as: :playable_build, if: -> (deployment) { include_details? && can_create_deployment? && deployment.playable_job } do |deployment, options| + Ci::JobEntity.represent(deployment.playable_job, options.merge(only: [:play_path, :retry_path])) end expose :cluster do |deployment, options| diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index 0dbfe0f0772..9ee2e145cd5 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -19,11 +19,6 @@ class DiscussionEntity < BaseDiscussionEntity discussion.diff_note_positions.map(&:line_code) end - expose :resolved?, as: :resolved - expose :resolved_by_push?, as: :resolved_by_push - expose :resolved_by, using: NoteUserEntity - expose :resolved_at - private def current_user diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 0a3bf4c2a7b..b1f731cdd4d 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -4,7 +4,7 @@ class EnvironmentEntity < Grape::Entity include RequestAwareEntity UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT = - %i[manual_actions scheduled_actions playable_build cluster].freeze + %i[manual_actions scheduled_actions playable_job cluster].freeze expose :id diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index d7820dff6ef..8f3aeea2eed 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -94,7 +94,7 @@ class EnvironmentSerializer < BaseSerializer pipeline: { manual_actions: [:metadata, :deployment], scheduled_actions: [:metadata], - latest_successful_builds: [] + latest_successful_jobs: [] }, project: project_associations } diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb index 8865c030d94..62dc323616e 100644 --- a/app/serializers/environment_status_entity.rb +++ b/app/serializers/environment_status_entity.rb @@ -38,7 +38,7 @@ class EnvironmentStatusEntity < Grape::Entity end expose :deployment, as: :details do |es, options| - DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_build])) + DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_job])) end expose :environment_available do |es| diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb index 1cbd6114581..f7cac23f30c 100644 --- a/app/serializers/integrations/event_entity.rb +++ b/app/serializers/integrations/event_entity.rb @@ -23,7 +23,10 @@ module Integrations integration.event_channel_name(event) end expose :value do |event| - integration.event_channel_value(event) + value = integration.event_channel_value(event) + next BaseChatNotification::SECRET_MASK if value.present? && integration.mask_configurable_channels? + + value end expose :placeholder do |_event| integration.default_channel_placeholder diff --git a/app/serializers/integrations/field_entity.rb b/app/serializers/integrations/field_entity.rb index 1c548cfab78..dc2ec55d073 100644 --- a/app/serializers/integrations/field_entity.rb +++ b/app/serializers/integrations/field_entity.rb @@ -5,12 +5,16 @@ module Integrations include RequestAwareEntity include Gitlab::Utils::StrongMemoize - expose :section, :type, :name, :placeholder, :required, :choices, :checkbox_label + expose :section, :name, :placeholder, :required, :choices, :checkbox_label expose :title do |field| non_empty_password?(field) ? field[:non_empty_password_title] : field[:title] end + expose :type do |field| + field[:type].to_s + end + expose :help do |field| non_empty_password?(field) ? field[:non_empty_password_help] : field[:help] end @@ -20,7 +24,7 @@ module Integrations if non_empty_password?(field) 'true' - elsif field[:type] == 'checkbox' + elsif field[:type] == :checkbox ActiveRecord::Type::Boolean.new.deserialize(value).to_s elsif field[:name] == 'webhook' && integration.chat? BaseChatNotification::SECRET_MASK if value.present? @@ -44,7 +48,7 @@ module Integrations def non_empty_password?(field) strong_memoize(:non_empty_password) do - field[:type] == 'password' && value_for(field).present? + field[:type] == :password && value_for(field).present? end end end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 9fd50c8c51d..6f83978841d 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -27,3 +27,5 @@ class MergeRequestSerializer < BaseSerializer super(merge_request, opts, entity) end end + +MergeRequestSerializer.prepend_mod diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 26dc748ad51..a50d893d244 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -48,6 +48,7 @@ class NoteEntity < API::Entities::Note expose :resolvable?, as: :resolvable expose :resolved_by, using: NoteUserEntity + expose :resolved_by_push?, as: :resolved_by_push expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| SystemNoteHelper.system_note_icon_name(note) @@ -77,10 +78,10 @@ class NoteEntity < API::Entities::Note end expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) + resolve_project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion.id) end - expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? && note.noteable.is_a?(MergeRequest) } do |note| new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) end diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb index b769a80ef58..f3c1a927084 100644 --- a/app/serializers/profile/event_entity.rb +++ b/app/serializers/profile/event_entity.rb @@ -51,7 +51,7 @@ module Profile expose(:id) { |event| event.target.id } expose(:target_type, as: :type) expose(:target_title, as: :title) - expose(:issue_type, if: ->(event) { event.work_item? }) do |event| + expose(:issue_type, if: ->(event) { event.work_item? || event.issue? }) do |event| event.target.issue_type end diff --git a/app/serializers/project_note_entity.rb b/app/serializers/project_note_entity.rb index 3cd34f6af0d..3e004fb5d32 100644 --- a/app/serializers/project_note_entity.rb +++ b/app/serializers/project_note_entity.rb @@ -21,14 +21,6 @@ class ProjectNoteEntity < NoteEntity project_note_path(note.project, note) end - expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) - end - - expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| - new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) - end - expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| delete_attachment_project_note_path(note.project, note) end diff --git a/app/services/admin/abuse_report_update_service.rb b/app/services/admin/abuse_report_update_service.rb deleted file mode 100644 index 12cf8bf14a8..00000000000 --- a/app/services/admin/abuse_report_update_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module Admin - class AbuseReportUpdateService < BaseService - attr_reader :abuse_report, :params, :current_user, :action - - def initialize(abuse_report, current_user, params) - @abuse_report = abuse_report - @current_user = current_user - @params = params - @action = determine_action - end - - def execute - return ServiceResponse.error(message: 'Admin is required') unless current_user&.can_admin_all_resources? - return ServiceResponse.error(message: 'Action is required') unless action.present? - - result = perform_action - if result[:status] == :success - event = close_report_and_record_event - ServiceResponse.success(message: event.success_message) - else - ServiceResponse.error(message: result[:message]) - end - end - - private - - def determine_action - action = params[:user_action] - if action.in?(ResourceEvents::AbuseReportEvent.actions.keys) - action.to_sym - elsif close_report? - :close_report - end - end - - def perform_action - case action - when :ban_user then ban_user - when :block_user then block_user - when :delete_user then delete_user - when :close_report then close_report - end - end - - def ban_user - Users::BanService.new(current_user).execute(abuse_report.user) - end - - def block_user - Users::BlockService.new(current_user).execute(abuse_report.user) - end - - def delete_user - abuse_report.user.delete_async(deleted_by: current_user) - success - end - - def close_report - return error('Report already closed') if abuse_report.closed? - - abuse_report.closed! - success - end - - def close_report_and_record_event - event = action - - if close_report? && action != :close_report - close_report - event = "#{action}_and_close_report" - end - - record_event(event) - end - - def close_report? - params[:close].to_s == 'true' - end - - def record_event(action) - reason = params[:reason] - unless reason.in?(ResourceEvents::AbuseReportEvent.reasons.keys) - reason = ResourceEvents::AbuseReportEvent.reasons[:other] - end - - abuse_report.events.create(action: action, user: current_user, reason: reason, comment: params[:comment]) - 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 new file mode 100644 index 00000000000..da61a4dc8f6 --- /dev/null +++ b/app/services/admin/abuse_reports/moderate_user_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Admin + module AbuseReports + class ModerateUserService < BaseService + attr_reader :abuse_report, :params, :current_user, :action + + def initialize(abuse_report, current_user, params) + @abuse_report = abuse_report + @current_user = current_user + @params = params + @action = determine_action + end + + def execute + return ServiceResponse.error(message: 'Admin is required') unless current_user&.can_admin_all_resources? + return ServiceResponse.error(message: 'Action is required') unless action.present? + + result = perform_action + if result[:status] == :success + event = close_report_and_record_event + ServiceResponse.success(message: event.success_message) + else + ServiceResponse.error(message: result[:message]) + end + end + + private + + def determine_action + action = params[:user_action] + if action.in?(ResourceEvents::AbuseReportEvent.actions.keys) + action.to_sym + elsif close_report? + :close_report + end + end + + def perform_action + case action + when :ban_user then ban_user + when :block_user then block_user + when :delete_user then delete_user + when :close_report then close_report + end + end + + def ban_user + Users::BanService.new(current_user).execute(abuse_report.user) + end + + def block_user + Users::BlockService.new(current_user).execute(abuse_report.user) + end + + def delete_user + abuse_report.user.delete_async(deleted_by: current_user) + success + end + + def close_report + return error('Report already closed') if abuse_report.closed? + + abuse_report.closed! + success + end + + def close_report_and_record_event + event = action + + if close_report? && action != :close_report + close_report + event = "#{action}_and_close_report" + end + + record_event(event) + end + + def close_report? + params[:close].to_s == 'true' + end + + def record_event(action) + reason = params[:reason] + unless reason.in?(ResourceEvents::AbuseReportEvent.reasons.keys) + reason = ResourceEvents::AbuseReportEvent.reasons[:other] + end + + abuse_report.events.create(action: action, user: current_user, reason: reason, comment: params[:comment]) + end + end + end +end diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb index cda9a7e7f8c..24ce3c4095f 100644 --- a/app/services/admin/plan_limits/update_service.rb +++ b/app/services/admin/plan_limits/update_service.rb @@ -15,6 +15,12 @@ module Admin add_history_to_params! + plan_limits.assign_attributes(parsed_params) + + validate_storage_limits + + return error(plan_limits.errors.full_messages, :bad_request) if plan_limits.errors.any? + if plan_limits.update(parsed_params) success else @@ -26,6 +32,8 @@ module Admin attr_accessor :current_user, :params, :plan, :plan_limits + delegate :notification_limit, :storage_size_limit, :enforcement_limit, to: :plan_limits + def can_update? current_user.can_admin_all_resources? end @@ -35,6 +43,39 @@ module Admin parsed_params.merge!(limits_history: formatted_limits_history) unless formatted_limits_history.empty? end + def validate_storage_limits + validate_notification_limit + validate_enforcement_limit + validate_storage_size_limit + end + + def validate_notification_limit + return unless parsed_params.include?(:notification_limit) + return if notification_limit >= storage_size_limit && notification_limit <= enforcement_limit + + plan_limits.errors.add(:notification_limit, "must be greater than or equal to " \ + "storage_size_limit (Dashboard limit): #{storage_size_limit} " \ + "and less than or equal to enforcement_limit: #{enforcement_limit}") + end + + def validate_enforcement_limit + return unless parsed_params.include?(:enforcement_limit) + return if enforcement_limit >= storage_size_limit && enforcement_limit >= notification_limit + + plan_limits.errors.add(:enforcement_limit, "must be greater than or equal to " \ + "storage_size_limit (Dashboard limit): #{storage_size_limit} and " \ + "greater than or equal to notification_limit: #{notification_limit}") + end + + def validate_storage_size_limit + return unless parsed_params.include?(:storage_size_limit) + return if storage_size_limit <= enforcement_limit && storage_size_limit <= notification_limit + + plan_limits.errors.add(:storage_size_limit, "(Dashboard limit) must be less than or equal to " \ + "enforcement_limit: #{enforcement_limit} " \ + "and notification_limit: #{notification_limit}") + end + # Overridden in EE def parsed_params params diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 3827d199325..eaee5ce70fc 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -112,6 +112,28 @@ module Auth token.expire_time = self.class.token_expire_at token[:auth_type] = params[:auth_type] token[:access] = accesses.compact + token[:user] = user_info_token.encoded + end + end + + def user_info_token + info = + if current_user + { + token_type: params[:auth_type], + username: current_user.username, + user_id: current_user.id + } + elsif deploy_token + { + token_type: params[:auth_type], + username: deploy_token.username, + deploy_token_id: deploy_token.id + } + end + + JSONWebToken::RSAToken.new(registry.key).tap do |token| + token[:user_info] = info end end diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb index cb83dc57478..535845b9f94 100644 --- a/app/services/authorized_project_update/project_recalculate_service.rb +++ b/app/services/authorized_project_update/project_recalculate_service.rb @@ -64,13 +64,10 @@ module AuthorizedProjectUpdate end def refresh_authorizations - if user_ids_to_remove.any? - ProjectAuthorization.delete_all_in_batches_for_project( - project: project, - user_ids: user_ids_to_remove) - end - - ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any? + ProjectAuthorizations::Changes.new do |changes| + changes.add(authorizations_to_create) + changes.remove_users_in_project(project, user_ids_to_remove) + end.apply! end def apply_scopes(project_authorizations) diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb index 065ef9dc708..b5aa8f920ff 100644 --- a/app/services/award_emojis/add_service.rb +++ b/app/services/award_emojis/add_service.rb @@ -6,11 +6,11 @@ module AwardEmojis def execute unless awardable.user_can_award?(current_user) - return error('User cannot award emoji to awardable', status: :forbidden) + return error('User cannot add emoji reactions to awardable', status: :forbidden) end unless awardable.emoji_awardable? - return error('Awardable cannot be awarded emoji', status: :unprocessable_entity) + return error('Awardable cannot add emoji reactions', status: :unprocessable_entity) end award = awardable.award_emoji.create(name: name, user: current_user) diff --git a/app/services/batched_git_ref_updates/cleanup_scheduler_service.rb b/app/services/batched_git_ref_updates/cleanup_scheduler_service.rb new file mode 100644 index 00000000000..cf547c0e6b5 --- /dev/null +++ b/app/services/batched_git_ref_updates/cleanup_scheduler_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module BatchedGitRefUpdates + class CleanupSchedulerService + include Gitlab::ExclusiveLeaseHelpers + MAX_PROJECTS = 10_000 + BATCH_SIZE = 100 + LOCK_TIMEOUT = 10.minutes + + def execute + total_projects = 0 + + in_lock(self.class.name, retries: 0, ttl: LOCK_TIMEOUT) do + Deletion.status_pending.distinct_each_batch(column: :project_id, of: BATCH_SIZE) do |deletions| + ProjectCleanupWorker.bulk_perform_async_with_contexts( + deletions, + arguments_proc: ->(deletion) { deletion.project_id }, + context_proc: ->(_) { {} } # No project context because loading the project is wasteful + ) + + total_projects += deletions.count + break if total_projects >= MAX_PROJECTS + end + end + + { total_projects: total_projects } + end + end +end diff --git a/app/services/batched_git_ref_updates/project_cleanup_service.rb b/app/services/batched_git_ref_updates/project_cleanup_service.rb new file mode 100644 index 00000000000..f9518cad975 --- /dev/null +++ b/app/services/batched_git_ref_updates/project_cleanup_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module BatchedGitRefUpdates + class ProjectCleanupService + include ::Gitlab::ExclusiveLeaseHelpers + + LOCK_TIMEOUT = 10.minutes + GITALY_BATCH_SIZE = 100 + QUERY_BATCH_SIZE = 1000 + MAX_DELETES = 10_000 + + def initialize(project_id) + @project_id = project_id + end + + def execute + total_deletes = 0 + + in_lock("#{self.class}/#{@project_id}", retries: 0, ttl: LOCK_TIMEOUT) do + project = Project.find_by_id(@project_id) + break unless project + + Deletion + .status_pending + .for_project(@project_id) + .select_ref_and_identity + .each_batch(of: QUERY_BATCH_SIZE) do |batch| + refs = batch.map(&:ref) + + refs.each_slice(GITALY_BATCH_SIZE) do |refs_to_delete| + project.repository.delete_refs(*refs_to_delete) + end + + total_deletes += refs.count + Deletion.mark_records_processed(batch) + + break if total_deletes >= MAX_DELETES + end + end + + { total_deletes: total_deletes } + end + end +end diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb index a4b1be1e599..7c8846d2fe8 100644 --- a/app/services/boards/base_items_list_service.rb +++ b/app/services/boards/base_items_list_service.rb @@ -13,17 +13,16 @@ module Boards # rubocop: disable CodeReuse/ActiveRecord def metadata(required_fields = [:issue_count, :total_issue_weight]) - # Failing tests in spec/requests/api/graphql/boards/board_lists_query_spec.rb - ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417465") do - fields = metadata_fields(required_fields) - keys = fields.keys - # TODO: eliminate need for SQL literal fragment - columns = Arel.sql(fields.values_at(*keys).join(', ')) - results = item_model.where(id: collection_ids) - results = results.select(columns) - - Hash[keys.zip(results.pluck(columns).flatten)] - end + fields = metadata_fields(required_fields) + keys = fields.keys + columns = fields.values_at(*keys) + + results = item_model + .where(id: collection_ids) + .pluck(*columns) + .flatten + + Hash[keys.zip(results)] end # rubocop: enable CodeReuse/ActiveRecord @@ -34,7 +33,7 @@ module Boards end def metadata_fields(required_fields) - required_fields&.include?(:issue_count) ? { size: 'COUNT(*)' } : {} + required_fields&.include?(:issue_count) ? { size: Arel.sql('COUNT(*)') } : {} end def order(items) diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index ef7e0ae8258..48adb90fb4c 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -5,7 +5,7 @@ # @param configuration [BulkImports::Configuration] Config object containing url and access token # @param relative_url [String] Relative URL to download the file from # @param tmpdir [String] Temp directory to store downloaded file to. Must be located under `Dir.tmpdir`. -# @param file_size_limit [Integer] Maximum allowed file size +# @param file_size_limit [Integer] Maximum allowed file size. If 0, no limit will apply. # @param allowed_content_types [Array<String>] Allowed file content types # @param filename [String] Name of the file to download, if known. Use remote filename if none given. module BulkImports @@ -15,14 +15,13 @@ module BulkImports ServiceError = Class.new(StandardError) - DEFAULT_FILE_SIZE_LIMIT = 5.gigabytes DEFAULT_ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze def initialize( configuration:, relative_url:, tmpdir:, - file_size_limit: DEFAULT_FILE_SIZE_LIMIT, + file_size_limit: default_file_size_limit, allowed_content_types: DEFAULT_ALLOWED_CONTENT_TYPES, filename: nil) @configuration = configuration @@ -118,5 +117,9 @@ module BulkImports schemes: %w(http https) ) end + + def default_file_size_limit + Gitlab::CurrentSettings.current_application_settings.bulk_import_max_download_file_size.megabytes + end end end diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb deleted file mode 100644 index 4fdd65bcdb4..00000000000 --- a/app/services/ci/create_pipeline_schedule_service.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Ci - # This class is deprecated and will be removed with the FF ci_refactoring_pipeline_schedule_create_service - class CreatePipelineScheduleService < BaseService - def execute - project.pipeline_schedules.create(pipeline_schedule_params) - end - - private - - def pipeline_schedule_params - params.merge(owner: current_user) - end - end -end diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 3ac0e83232f..c09b0cf81f1 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -138,6 +138,7 @@ module Ci def parse_artifact(artifact) case artifact.file_type when 'dotenv' then parse_dotenv_artifact(artifact) + when 'annotations' then parse_annotations_artifact(artifact) else success end end @@ -188,6 +189,10 @@ module Ci def parse_dotenv_artifact(artifact) Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact) end + + def parse_annotations_artifact(artifact) + Ci::ParseAnnotationsArtifactService.new(project, current_user).execute(artifact) + end end end end diff --git a/app/services/ci/parse_annotations_artifact_service.rb b/app/services/ci/parse_annotations_artifact_service.rb new file mode 100644 index 00000000000..cbda7e827d4 --- /dev/null +++ b/app/services/ci/parse_annotations_artifact_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Ci + class ParseAnnotationsArtifactService < ::BaseService + include ::Gitlab::Utils::StrongMemoize + include ::Gitlab::EncodingHelper + + SizeLimitError = Class.new(StandardError) + ParserError = Class.new(StandardError) + + def execute(artifact) + return error('Artifact is not annotations file type', :bad_request) unless artifact&.annotations? + + return error("Annotations Artifact Too Big. Maximum Allowable Size: #{annotations_size_limit}", :bad_request) if + artifact.file.size > annotations_size_limit + + annotations = parse!(artifact) + Ci::JobAnnotation.bulk_upsert!(annotations, unique_by: %i[partition_id job_id name]) + + success + rescue SizeLimitError, ParserError, Gitlab::Json.parser_error, ActiveRecord::RecordInvalid => error + error(error.message, :bad_request) + end + + private + + def parse!(artifact) + annotations = [] + + artifact.each_blob do |blob| + # Windows powershell may output UTF-16LE files, so convert the whole file + # to UTF-8 before proceeding. + blob = strip_bom(encode_utf8_with_replacement_character(blob)) + + blob_json = Gitlab::Json.parse(blob) + raise ParserError, 'Annotations files must be a JSON object' unless blob_json.is_a?(Hash) + + blob_json.each do |key, value| + annotations.push(Ci::JobAnnotation.new(job: artifact.job, name: key, data: value)) + + if annotations.size > annotations_num_limit + raise SizeLimitError, + "Annotations files cannot have more than #{annotations_num_limit} annotation lists" + end + end + end + + annotations + end + + def annotations_num_limit + project.actual_limits.ci_job_annotations_num + end + strong_memoize_attr :annotations_num_limit + + def annotations_size_limit + project.actual_limits.ci_job_annotations_size + end + strong_memoize_attr :annotations_size_limit + 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 e197821a0c0..953432a9dd3 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -47,7 +47,7 @@ module Ci loop do # leverage the index_ci_pipelines_on_project_id_and_status_and_created_at index records = project.all_pipelines - .created_after(1.week.ago) + .created_after(pipelines_created_after) .order(:status, :created_at) .page(page) # use offset pagination because there is no other way to loop over the data .per(PAGE_SIZE) @@ -63,10 +63,11 @@ module Ci def parent_auto_cancelable_pipelines(ids = nil) scope = project.all_pipelines - .created_after(1.week.ago) + .created_after(pipelines_created_after) .for_ref(pipeline.ref) .where_not_sha(project.commit(pipeline.ref).try(:id)) .where("created_at < ?", pipeline.created_at) + .for_status(CommitStatus::AVAILABLE_STATUSES) # Force usage of project_id_and_status_and_created_at_index .ci_sources scope = scope.id_in(ids) if ids.present? @@ -103,6 +104,14 @@ module Ci end end + def pipelines_created_after + if Feature.enabled?(:lower_interval_for_canceling_redundant_pipelines, project) + 3.days.ago + else + 1.week.ago + end + end + # Finding the pipelines to cancel is an expensive task that is not well # covered by indexes for all project use-cases and sometimes it might # harm other services. See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/14758 diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 8211507fb95..750272c3ecb 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -144,6 +144,10 @@ module Ci DEFAULT_LEASE_TIMEOUT end + def lease_taken_log_level + :info + end + def log_running_reset_skipped_jobs_service(jobs) Gitlab::AppJsonLogger.info( class: self.class.name.to_s, diff --git a/app/services/ci/pipeline_schedules/base_save_service.rb b/app/services/ci/pipeline_schedules/base_save_service.rb new file mode 100644 index 00000000000..45d70e5a65d --- /dev/null +++ b/app/services/ci/pipeline_schedules/base_save_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedules + class BaseSaveService + include Gitlab::Utils::StrongMemoize + + def execute + schedule.assign_attributes(params) + + return forbidden_to_save unless allowed_to_save? + return forbidden_to_save_variables unless allowed_to_save_variables? + + if schedule.save + ServiceResponse.success(payload: schedule) + else + ServiceResponse.error(payload: schedule, message: schedule.errors.full_messages) + end + end + + private + + attr_reader :project, :user, :params, :schedule + + def allowed_to_save? + user.can?(self.class::AUTHORIZE, schedule) + end + + def forbidden_to_save + # We add the error to the base object too + # because model errors are used in the API responses and the `form_errors` helper. + schedule.errors.add(:base, authorize_message) + + ServiceResponse.error(payload: schedule, message: [authorize_message], reason: :forbidden) + end + + def allowed_to_save_variables? + return true if params[:variables_attributes].blank? + + user.can?(:set_pipeline_variables, project) + end + + def forbidden_to_save_variables + message = _('The current user is not authorized to set pipeline schedule variables') + + # We add the error to the base object too + # because model errors are used in the API responses and the `form_errors` helper. + schedule.errors.add(:base, message) + + ServiceResponse.error(payload: schedule, message: [message], reason: :forbidden) + end + end + end +end diff --git a/app/services/ci/pipeline_schedules/create_service.rb b/app/services/ci/pipeline_schedules/create_service.rb index c1825865bc0..23775e68399 100644 --- a/app/services/ci/pipeline_schedules/create_service.rb +++ b/app/services/ci/pipeline_schedules/create_service.rb @@ -2,46 +2,22 @@ module Ci module PipelineSchedules - class CreateService - def initialize(project, user, params) - @project = project - @user = user - @params = params + class CreateService < BaseSaveService + AUTHORIZE = :create_pipeline_schedule + def initialize(project, user, params) @schedule = project.pipeline_schedules.new - end - - def execute - return forbidden unless allowed? - - schedule.assign_attributes(params.merge(owner: user)) - - if schedule.save - ServiceResponse.success(payload: schedule) - else - ServiceResponse.error(payload: schedule, message: schedule.errors.full_messages) - end + @user = user + @project = project + @params = params.merge(owner: user) end private - attr_reader :project, :user, :params, :schedule - - def allowed? - user.can?(:create_pipeline_schedule, schedule) - end - - def forbidden - # We add the error to the base object too - # because model errors are used in the API responses and the `form_errors` helper. - schedule.errors.add(:base, forbidden_message) - - ServiceResponse.error(payload: schedule, message: [forbidden_message], reason: :forbidden) - end - - def forbidden_message + def authorize_message _('The current user is not authorized to create the pipeline schedule') end + strong_memoize_attr :authorize_message end end end diff --git a/app/services/ci/pipeline_schedules/update_service.rb b/app/services/ci/pipeline_schedules/update_service.rb index 28c22e0a868..2fd1173ecce 100644 --- a/app/services/ci/pipeline_schedules/update_service.rb +++ b/app/services/ci/pipeline_schedules/update_service.rb @@ -2,44 +2,22 @@ module Ci module PipelineSchedules - class UpdateService + class UpdateService < BaseSaveService + AUTHORIZE = :update_pipeline_schedule + def initialize(schedule, user, params) @schedule = schedule @user = user + @project = schedule.project @params = params end - def execute - return forbidden unless allowed? - - schedule.assign_attributes(params) - - if schedule.save - ServiceResponse.success(payload: schedule) - else - ServiceResponse.error(message: schedule.errors.full_messages) - end - end - private - attr_reader :schedule, :user, :params - - def allowed? - user.can?(:update_pipeline_schedule, schedule) - end - - def forbidden - # We add the error to the base object too - # because model errors are used in the API responses and the `form_errors` helper. - schedule.errors.add(:base, forbidden_message) - - ServiceResponse.error(message: [forbidden_message], reason: :forbidden) - end - - def forbidden_message + def authorize_message _('The current user is not authorized to update the pipeline schedule') end + strong_memoize_attr :authorize_message end end end diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index e3cbba6de23..14ea09f17a0 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -39,7 +39,7 @@ module Ci ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job) - ::Deployments::CreateForBuildService.new.execute(new_job) + ::Deployments::CreateForJobService.new.execute(new_job) ::MergeRequests::AddTodoWhenBuildFailsService .new(project: project) diff --git a/app/services/clusters/management/validate_management_project_permissions_service.rb b/app/services/clusters/management/validate_management_project_permissions_service.rb index e89a0afe6d2..e407c159bc7 100644 --- a/app/services/clusters/management/validate_management_project_permissions_service.rb +++ b/app/services/clusters/management/validate_management_project_permissions_service.rb @@ -46,7 +46,7 @@ module Clusters ::GroupProjectsFinder.new( group: group, current_user: current_user, - options: { only_owned: true, include_subgroups: include_subgroups } + options: { exclude_shared: true, include_subgroups: include_subgroups } ).execute end end diff --git a/app/services/concerns/merge_requests/error_logger.rb b/app/services/concerns/merge_requests/error_logger.rb new file mode 100644 index 00000000000..c08525bf413 --- /dev/null +++ b/app/services/concerns/merge_requests/error_logger.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module MergeRequests + module ErrorLogger + def log_error(exception:, message:, save_message_on_model: false) + reference = merge_request.to_reference(full: true) + data = { + class: self.class.name, + message: message, + merge_request_id: merge_request.id, + merge_request: reference, + save_message_on_model: save_message_on_model + } + + if exception + Gitlab::ApplicationContext.with_context(user: current_user) do + Gitlab::ErrorTracking.track_exception(exception, data) + end + + data[:"exception.message"] = exception.message + end + + # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice: + # https://gitlab.com/gitlab-org/gitlab/-/issues/216379 + data[:message] = "#{self.class.name} error (#{reference}): #{message}" + Gitlab::GitLogger.error(data) + + merge_request.update(merge_error: message) if save_message_on_model + end + end +end diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index bb43cab79bb..dca38abf7af 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -24,7 +24,13 @@ module UpdateRepositoryStorageMethods return response if response - mirror_repositories unless same_filesystem? + unless same_filesystem? + mirror_repositories + + repository_storage_move.transaction do + mirror_object_pool(destination_storage_name) + end + end repository_storage_move.transaction do repository_storage_move.finish_replication! @@ -53,6 +59,11 @@ module UpdateRepositoryStorageMethods raise NotImplementedError end + def mirror_object_pool(_destination_shard) + # no-op, redefined for Projects::UpdateRepositoryStorageService + nil + end + def mirror_repository(type:) unless wait_for_pushes(type) raise Error, s_('UpdateRepositoryStorage|Timeout waiting for %{type} repository pushes') % { type: type.name } diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_job_service.rb index b58aa50a66f..e230515ce27 100644 --- a/app/services/deployments/create_for_build_service.rb +++ b/app/services/deployments/create_for_job_service.rb @@ -1,48 +1,48 @@ # frozen_string_literal: true module Deployments - # This class creates a deployment record for a build (a pipeline job). - class CreateForBuildService + # This class creates a deployment record for a pipeline job. + class CreateForJobService DeploymentCreationError = Class.new(StandardError) - def execute(build) - return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present? + def execute(job) + return unless job.is_a?(::Ci::Processable) && job.persisted_environment.present? - environment = build.actual_persisted_environment + environment = job.actual_persisted_environment - deployment = to_resource(build, environment) + deployment = to_resource(job, environment) return unless deployment deployment.save! - build.association(:deployment).target = deployment - build.association(:deployment).loaded! + job.association(:deployment).target = deployment + job.association(:deployment).loaded! deployment rescue ActiveRecord::RecordInvalid => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception( - DeploymentCreationError.new(e.message), build_id: build.id) + DeploymentCreationError.new(e.message), job_id: job.id) end private - def to_resource(build, environment) - return build.deployment if build.deployment - return unless build.deployment_job? + def to_resource(job, environment) + return job.deployment if job.deployment + return unless job.deployment_job? - deployment = ::Deployment.new(attributes(build, environment)) + deployment = ::Deployment.new(attributes(job, environment)) # If there is a validation error on environment creation, such as # the name contains invalid character, the job will fall back to a # non-environment job. return unless deployment.valid? && deployment.environment.persisted? - if cluster = deployment.environment.deployment_platform&.cluster + if cluster = deployment.environment.deployment_platform&.cluster # rubocop: disable Lint/AssignmentInCondition # double write cluster_id until 12.9: https://gitlab.com/gitlab-org/gitlab/issues/202628 deployment.cluster_id = cluster.id deployment.deployment_cluster = ::DeploymentCluster.new( cluster_id: cluster.id, - kubernetes_namespace: cluster.kubernetes_namespace_for(deployment.environment, deployable: build) + kubernetes_namespace: cluster.kubernetes_namespace_for(deployment.environment, deployable: job) ) end @@ -53,16 +53,16 @@ module Deployments deployment end - def attributes(build, environment) + def attributes(job, environment) { - project: build.project, + project: job.project, environment: environment, - deployable: build, - user: build.user, - ref: build.ref, - tag: build.tag, - sha: build.sha, - on_stop: build.on_stop + deployable: job, + user: job.user, + ref: job.ref, + tag: job.tag, + sha: job.sha, + on_stop: job.on_stop } end end diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb deleted file mode 100644 index 15384fb0db1..00000000000 --- a/app/services/deployments/older_deployments_drop_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Deployments - class OlderDeploymentsDropService - attr_reader :deployment - - def initialize(deployment_id) - @deployment = Deployment.find_by_id(deployment_id) - end - - def execute - return unless @deployment&.running? - - older_deployments_builds.each do |build| - next if build.manual? - - Gitlab::OptimisticLocking.retry_lock(build, name: 'older_deployments_drop') do |build| - build.drop(:forward_deployment_failure) - end - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, build_id: build.id) - end - end - - private - - def older_deployments_builds - @deployment - .environment - .active_deployments - .older_than(@deployment) - .builds - end - end -end diff --git a/app/services/environments/create_for_build_service.rb b/app/services/environments/create_for_build_service.rb deleted file mode 100644 index ff4da212002..00000000000 --- a/app/services/environments/create_for_build_service.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Environments - # This class creates an environment record for a build (a pipeline job). - class CreateForBuildService - def execute(build) - return unless build.instance_of?(::Ci::Build) && build.has_environment_keyword? - - environment = to_resource(build) - - if environment.persisted? - build.persisted_environment = environment - build.assign_attributes(metadata_attributes: { expanded_environment_name: environment.name }) - else - build.assign_attributes(status: :failed, failure_reason: :environment_creation_failure) - end - - environment - end - - private - - # rubocop: disable Performance/ActiveRecordSubtransactionMethods - def to_resource(build) - build.project.environments.safe_find_or_create_by(name: build.expanded_environment_name) do |environment| - # Initialize the attributes at creation - environment.auto_stop_in = expanded_auto_stop_in(build) - environment.tier = build.environment_tier_from_options - environment.merge_request = build.pipeline.merge_request - end - end - # rubocop: enable Performance/ActiveRecordSubtransactionMethods - - def expanded_auto_stop_in(build) - return unless build.environment_auto_stop_in - - ExpandVariables.expand(build.environment_auto_stop_in, -> { build.simple_variables.sort_and_expand_all }) - end - end -end diff --git a/app/services/environments/create_for_job_service.rb b/app/services/environments/create_for_job_service.rb new file mode 100644 index 00000000000..02545ce03e0 --- /dev/null +++ b/app/services/environments/create_for_job_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Environments + # This class creates an environment record for a pipeline job. + class CreateForJobService + def execute(job) + return unless job.is_a?(::Ci::Processable) && job.has_environment_keyword? + + environment = to_resource(job) + + if environment.persisted? + job.persisted_environment = environment + job.assign_attributes(metadata_attributes: { expanded_environment_name: environment.name }) + else + job.assign_attributes(status: :failed, failure_reason: :environment_creation_failure) + end + + environment + end + + private + + # rubocop: disable Performance/ActiveRecordSubtransactionMethods + def to_resource(job) + job.project.environments.safe_find_or_create_by(name: job.expanded_environment_name) do |environment| + # Initialize the attributes at creation + environment.auto_stop_in = expanded_auto_stop_in(job) + environment.tier = job.environment_tier_from_options + environment.merge_request = job.pipeline.merge_request + end + end + # rubocop: enable Performance/ActiveRecordSubtransactionMethods + + def expanded_auto_stop_in(job) + return unless job.environment_auto_stop_in + + ExpandVariables.expand(job.environment_auto_stop_in, -> { job.simple_variables.sort_and_expand_all }) + end + end +end diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb index fd78a886e29..25a6d047177 100644 --- a/app/services/environments/create_service.rb +++ b/app/services/environments/create_service.rb @@ -2,7 +2,7 @@ module Environments class CreateService < BaseService - ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent kubernetes_namespace].freeze + ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent kubernetes_namespace flux_resource_path].freeze def execute unless can?(current_user, :create_environment, project) diff --git a/app/services/environments/update_service.rb b/app/services/environments/update_service.rb index 52f6198bada..85b27fe67ce 100644 --- a/app/services/environments/update_service.rb +++ b/app/services/environments/update_service.rb @@ -2,7 +2,7 @@ module Environments class UpdateService < BaseService - ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent kubernetes_namespace].freeze + ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent kubernetes_namespace flux_resource_path].freeze def execute(environment) unless can?(current_user, :update_environment, environment) diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index f9280be7ee2..6a8e4d17859 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -83,7 +83,6 @@ module Git def enqueue_notify_kas return unless Gitlab::Kas.enabled? - return unless Feature.enabled?(:notify_kas_on_git_push, project) Clusters::Agents::NotifyGitPushWorker.perform_async(project.id) end diff --git a/app/services/grafana/proxy_service.rb b/app/services/grafana/proxy_service.rb deleted file mode 100644 index 37272c85638..00000000000 --- a/app/services/grafana/proxy_service.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# Proxies calls to a Grafana-integrated Prometheus instance -# through the Grafana proxy API - -# This allows us to fetch and render metrics in GitLab from a Prometheus -# instance for which dashboards are configured in Grafana -module Grafana - class ProxyService < BaseService - include ReactiveCaching - - self.reactive_cache_key = ->(service) { service.cache_key } - self.reactive_cache_lease_timeout = 30.seconds - self.reactive_cache_refresh_interval = 30.seconds - self.reactive_cache_work_type = :external_dependency - self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } - - SUPPORTED_DATASOURCE_PATTERN = %r{\A\d+\z}.freeze - - SUPPORTED_PROXY_PATH = Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter::PROXY_PATH - - attr_accessor :project, :datasource_id, :proxy_path, :query_params - - # @param project_id [Integer] Project id for which grafana is configured. - # - # See #initialize for other parameters. - def self.from_cache(project_id, datasource_id, proxy_path, query_params) - project = Project.find(project_id) - - new(project, datasource_id, proxy_path, query_params) - end - - # @param project [Project] Project for which grafana is configured. - # @param datasource_id [String] Grafana datasource id for Prometheus instance - # @param proxy_path [String] Path to Prometheus endpoint; EX) 'api/v1/query_range' - # @param query_params [Hash<String, String>] Supported params: [query, start, end, step] - def initialize(project, datasource_id, proxy_path, query_params) - @project = project - @datasource_id = datasource_id - @proxy_path = proxy_path - @query_params = query_params - end - - def execute - return cannot_proxy_response unless can_proxy? - return cannot_proxy_response unless client - - with_reactive_cache(*cache_key) { |result| result } - end - - def calculate_reactive_cache(*) - return cannot_proxy_response unless client - - response = client.proxy_datasource( - datasource_id: datasource_id, - proxy_path: proxy_path, - query: query_params - ) - - success(http_status: response.code, body: response.body) - rescue ::Grafana::Client::Error => error - service_unavailable_response(error) - end - - # Required for ReactiveCaching; Usage overridden by - # self.reactive_cache_worker_finder - def id - nil - end - - def cache_key - [project.id, datasource_id, proxy_path, query_params] - end - - private - - def can_proxy? - SUPPORTED_PROXY_PATH == proxy_path && - SUPPORTED_DATASOURCE_PATTERN.match?(datasource_id) - end - - def client - project.grafana_integration&.client - end - - def service_unavailable_response(exception) - error(exception.message, :service_unavailable) - end - - def cannot_proxy_response - error('Proxy support for this API is not available currently') - end - end -end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 25a1e9a9873..0f74b2d9349 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -60,7 +60,11 @@ module Groups end def remove_unallowed_params - params.delete(:default_branch_protection) unless can?(current_user, :create_group_with_default_branch_protection) + unless can?(current_user, :create_group_with_default_branch_protection) + params.delete(:default_branch_protection) + params.delete(:default_branch_protection_defaults) + end + params.delete(:allow_mfa_for_subgroups) end diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb index e939d27d464..a2238264295 100644 --- a/app/services/groups/participants_service.rb +++ b/app/services/groups/participants_service.rb @@ -13,7 +13,7 @@ module Groups participants_in_noteable + all_members + groups + - group_members + group_hierarchy_users render_participants_as_hash(participants.uniq) end @@ -26,12 +26,10 @@ module Groups [{ username: "all", name: "All Group Members", count: group.users_count }] end - def group_members + def group_hierarchy_users return [] unless group - sorted( - group.direct_and_indirect_users(share_with_groups: group.member?(current_user)) - ) + sorted(Autocomplete::GroupUsersFinder.new(group: group).execute) end end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 81d4dfddaab..64256e43ce3 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -11,19 +11,51 @@ module Groups @error = nil end + def log_group_transfer_success(group, new_parent_group) + log_transfer(group, new_parent_group, nil) + end + + def log_group_transfer_error(group, new_parent_group, error_message) + log_transfer(group, new_parent_group, error_message) + end + def execute(new_parent_group) @new_parent_group = new_parent_group ensure_allowed_transfer proceed_to_transfer + log_group_transfer_success(@group, @new_parent_group) + rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e @group.errors.clear @error = s_("TransferGroup|Transfer failed: %{error_message}") % { error_message: e.message } + + log_group_transfer_error(@group, @new_parent_group, e.message) + false end private + def log_transfer(group, new_namespace, error_message = nil) + action = error_message.nil? ? "was" : "was not" + + log_payload = { + message: "Group #{action} transferred to a new namespace", + group_path: group.full_path, + group_id: group.id, + new_parent_group_path: new_parent_group&.full_path, + new_parent_group_id: new_parent_group&.id, + error_message: error_message + } + + if error_message.nil? + ::Gitlab::AppLogger.info(log_payload) + else + ::Gitlab::AppLogger.error(log_payload) + end + end + def proceed_to_transfer old_root_ancestor_id = @group.root_ancestor.id was_root_group = @group.root? diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index df6ede87ef9..7d0142fc067 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -21,7 +21,7 @@ module Groups return false unless valid_share_with_group_lock_change? - return false unless valid_path_change_with_npm_packages? + return false unless valid_path_change? return false unless update_shared_runners @@ -46,6 +46,29 @@ 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? + return true if !group.has_parent? && group.path == params[:path] + + # we have a path change on a root group: + # check that we don't have any npm package with a scope set to the group path + npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm, preload_pipelines: false) + .execute + .with_npm_scope(group.path) + + return true unless npm_packages.exists? + + group.errors.add(:path, s_('GroupSettings|cannot change when group contains projects with NPM packages')) + 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? @@ -107,7 +130,11 @@ module Groups # overridden in EE def remove_unallowed_params params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, group) - params.delete(:default_branch_protection) unless can?(current_user, :update_default_branch_protection, group) + + unless can?(current_user, :update_default_branch_protection, group) + params.delete(:default_branch_protection) + params.delete(:default_branch_protection_defaults) + end end def handle_changes diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index c01509bc4d1..166452968f4 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -45,7 +45,9 @@ module Issuable def permitted_attrs(type) attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event) - if type == 'issue' || type == 'merge_request' + if type == 'issue' + attrs.push(:assignee_ids, :confidential) + elsif type == 'merge_request' attrs.push(:assignee_ids) else attrs.push(:assignee_id) diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index 1069c9e0915..533e92f6225 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -96,7 +96,7 @@ module IssuableLinks if params[:issuable_references].present? extract_references elsif target_issuable - [target_issuable] + Array.wrap(target_issuable) else [] end diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb index c3d6af952b4..479d971382b 100644 --- a/app/services/issues/import_csv_service.rb +++ b/app/services/issues/import_csv_service.rb @@ -22,8 +22,18 @@ module Issues Issues::CreateService end + def extra_create_service_params + { perform_spam_check: perform_spam_check? } + end + + def perform_spam_check? + !user.can_admin_all_resources? + end + def record_import_attempt Issues::CsvImport.create!(user: user, project: project) end end end + +Issues::ImportCsvService.prepend_mod diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index c1599ceef6e..e26e3d0835b 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -123,10 +123,10 @@ module Issues end def rewrite_related_issues - source_issue_links = IssueLink.for_source_issue(original_entity) + source_issue_links = IssueLink.for_source(original_entity) source_issue_links.update_all(source_id: new_entity.id) - target_issue_links = IssueLink.for_target_issue(original_entity) + target_issue_links = IssueLink.for_target(original_entity) target_issue_links.update_all(target_id: new_entity.id) end diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb index b5c10430e83..a8d0ae01176 100644 --- a/app/services/issues/relative_position_rebalancing_service.rb +++ b/app/services/issues/relative_position_rebalancing_service.rb @@ -10,8 +10,8 @@ module Issues TooManyConcurrentRebalances = Class.new(StandardError) def initialize(projects) - @projects_collection = (projects.is_a?(Array) ? Project.id_in(projects) : projects).projects_order_id_asc - @root_namespace = @projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord + @projects_collection = (projects.is_a?(Array) ? Project.id_in(projects) : projects).select(:id).projects_order_id_asc + @root_namespace = @projects_collection.select(:namespace_id).reorder(nil).take.root_namespace # rubocop:disable CodeReuse/ActiveRecord @caching = ::Gitlab::Issues::Rebalancing::State.new(@root_namespace, @projects_collection) end diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb index ff29358df86..21f92eeaf09 100644 --- a/app/services/labels/available_labels_service.rb +++ b/app/services/labels/available_labels_service.rb @@ -40,6 +40,21 @@ 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 + end + def available_labels @available_labels ||= LabelsFinder.new(current_user, finder_params).execute end diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb index 6c070d15cdb..675439b2f64 100644 --- a/app/services/labels/create_service.rb +++ b/app/services/labels/create_service.rb @@ -13,6 +13,10 @@ 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 + project_or_group.labels.create(params) elsif target_params[:template] label = Label.new(params) diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb index be33947d0eb..4ac54959e84 100644 --- a/app/services/labels/update_service.rb +++ b/app/services/labels/update_service.rb @@ -10,9 +10,19 @@ module Labels def execute(label) params[:name] = params.delete(:new_name) if params.key?(:new_name) params[:color] = convert_color_name_to_hex if params[:color].present? + params.delete(:lock_on_merge) unless allow_lock_on_merge?(label) label.update(params) label end + + private + + def allow_lock_on_merge?(label) + return if label.template? + return unless label.respond_to?(:parent_container) + + Feature.enabled?(:enforce_locked_labels_on_merge, label.parent_container, type: :ops) + end end end diff --git a/app/services/members/import_project_team_service.rb b/app/services/members/import_project_team_service.rb index 6efd65e2575..ef43d8206a9 100644 --- a/app/services/members/import_project_team_service.rb +++ b/app/services/members/import_project_team_service.rb @@ -2,36 +2,83 @@ module Members class ImportProjectTeamService < BaseService - attr_reader :params, :current_user + ImportProjectTeamForbiddenError = Class.new(StandardError) - def target_project_id - @target_project_id ||= params[:id].presence + def initialize(*args) + super + + @errors = {} end - def source_project_id - @source_project_id ||= params[:project_id].presence + def execute + check_target_and_source_projects_exist! + check_user_permissions! + + import_project_team + process_import_result + + result + rescue ArgumentError, ImportProjectTeamForbiddenError => e + ServiceResponse.error(message: e.message, reason: :unprocessable_entity) end - def target_project - @target_project ||= Project.find_by_id(target_project_id) + private + + attr_reader :members, :params, :current_user, :errors, :result + + def import_project_team + @members = target_project.team.import(source_project, current_user) + + if members.is_a?(Array) + members.each { |member| check_member_validity(member) } + else + @result = ServiceResponse.error(message: 'Import failed', reason: :unprocessable_entity) + end end - def source_project - @source_project ||= Project.find_by_id(source_project_id) + def check_target_and_source_projects_exist! + if target_project.blank? + raise ArgumentError, 'Target project does not exist' + elsif source_project.blank? + raise ArgumentError, 'Source project does not exist' + end end - def execute - import_project_team + def check_user_permissions! + return if can?(current_user, :read_project_member, source_project) && + can?(current_user, :import_project_members_from_another_project, target_project) + + raise ImportProjectTeamForbiddenError, 'Forbidden' end - private + def check_member_validity(member) + return if member.valid? - def import_project_team - return false unless target_project.present? && source_project.present? && current_user.present? - return false unless can?(current_user, :read_project_member, source_project) - return false unless can?(current_user, :import_project_members_from_another_project, target_project) + errors[member.user.username] = member.errors.full_messages.to_sentence + end + + def process_import_result + @result ||= if errors.any? + ServiceResponse.error(message: errors, payload: { total_members_count: members.size }) + else + ServiceResponse.success(message: 'Successfully imported') + end + end + + def target_project_id + params[:id] + end - target_project.team.import(source_project, current_user) + def source_project_id + params[:project_id] + end + + def target_project + @target_project ||= Project.find_by_id(target_project_id) + end + + def source_project + @source_project ||= Project.find_by_id(source_project_id) end end end diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b2c0fffc12d..3a3d0e53aae 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -36,6 +36,7 @@ module Members member.attributes = params return unless member.changed? + member.expiry_notified_at = nil if member.expires_at_changed? member.tap(&:save!) end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index aaa91548d19..0fc85675e49 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -4,6 +4,7 @@ module MergeRequests class BaseService < ::IssuableBaseService extend ::Gitlab::Utils::Override include MergeRequests::AssignsMergeParams + include MergeRequests::ErrorLogger delegate :repository, to: :project @@ -175,10 +176,21 @@ 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? @@ -260,32 +272,6 @@ module MergeRequests end end - def log_error(exception:, message:, save_message_on_model: false) - reference = merge_request.to_reference(full: true) - data = { - class: self.class.name, - message: message, - merge_request_id: merge_request.id, - merge_request: reference, - save_message_on_model: save_message_on_model - } - - if exception - Gitlab::ApplicationContext.with_context(user: current_user) do - Gitlab::ErrorTracking.track_exception(exception, data) - end - - data[:"exception.message"] = exception.message - end - - # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice: - # https://gitlab.com/gitlab-org/gitlab/-/issues/216379 - data[:message] = "#{self.class.name} error (#{reference}): #{message}" - Gitlab::GitLogger.error(data) - - merge_request.update(merge_error: message) if save_message_on_model - end - def trigger_merge_request_reviewers_updated(merge_request) GraphqlTriggers.merge_request_reviewers_updated(merge_request) end diff --git a/app/services/merge_requests/create_ref_service.rb b/app/services/merge_requests/create_ref_service.rb new file mode 100644 index 00000000000..e0f10183bac --- /dev/null +++ b/app/services/merge_requests/create_ref_service.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module MergeRequests + # CreateRefService creates or overwrites a ref under "refs/merge-requests/" + # with a commit for the merged result. + class CreateRefService + include Gitlab::Utils::StrongMemoize + + CreateRefError = Class.new(StandardError) + + def initialize( + current_user:, merge_request:, target_ref:, first_parent_ref:, + source_sha: nil, merge_commit_message: nil) + + @current_user = current_user + @merge_request = merge_request + @initial_source_sha = source_sha + @target_ref = target_ref + @merge_commit_message = merge_commit_message + @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 + } + ) + rescue CreateRefError => error + ServiceResponse.error(message: error.message) + end + + private + + attr_reader :current_user, :merge_request, :target_ref, :first_parent_sha, :initial_source_sha + + delegate :target_project, to: :merge_request + delegate :repository, to: :target_project + + def maybe_squash!(commit_sha, source_sha, expected_old_oid) + 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 + end + + # squash does not overwrite target_ref, so expected_old_oid remains the same + [commit_sha, source_sha, expected_old_oid] + end + + def maybe_rebase!(commit_sha, source_sha, expected_old_oid) + if target_project.ff_merge_must_be_possible? + commit_sha = safe_gitaly_operation do + repository.rebase_to_ref( + current_user, + source_sha: source_sha, + target_ref: target_ref, + first_parent_ref: first_parent_sha + ) + end + + source_sha = commit_sha + expected_old_oid = commit_sha + end + + [commit_sha, source_sha, expected_old_oid] + end + + def maybe_merge!(commit_sha, source_sha, expected_old_oid) + unless target_project.merge_requests_ff_only_enabled + commit_sha = safe_gitaly_operation do + repository.merge_to_ref( + current_user, + source_sha: source_sha, + target_ref: target_ref, + message: merge_commit_message, + first_parent_ref: first_parent_sha, + branch: nil, + expected_old_oid: expected_old_oid + ) + end + commit = target_project.commit(commit_sha) + _, source_sha = commit.parent_ids + end + + [commit_sha, source_sha] + end + + def safe_gitaly_operation + yield + rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError, ArgumentError => error + raise CreateRefError, error.message + end + + def squash_commit_message + merge_request.merge_params['squash_commit_message'].presence || + merge_request.default_squash_commit_message(user: current_user) + end + 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.default_merge_commit_message(user: current_user) + ) + end + end +end diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index 2a3c1e8bc26..fa0a4f808e2 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -60,8 +60,11 @@ module MergeRequests end def squash_sha! - params[:merge_request] = merge_request - squash_result = ::MergeRequests::SquashService.new(project: project, current_user: current_user, params: params).execute + 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 diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 5e41375e7a0..1398a6dbb67 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -16,6 +16,8 @@ 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 @@ -45,11 +47,40 @@ module MergeRequests 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 + + merge_strategy_class = options[:merge_strategy] || MergeRequests::MergeStrategies::FromSourceBranch + @merge_strategy = merge_strategy_class.new(merge_request, current_user, merge_params: params, options: options) + + @merge_request = merge_request + @options = options + jid = merge_jid + + validate! + + merge_request.in_locked_state do + if commit_v2 + after_merge + clean_merge_jid + success + end + end + + log_info("Merge process finished on JID #{jid} with state #{state}") + rescue MergeError, MergeRequests::MergeStrategies::StrategyError => e + handle_merge_error(log_message: e.message, save_message_on_model: true) + ensure + exclusive_lease(merge_request.id).cancel + end + private def validate! authorization_check! error_check! + validate_strategy! updated_check! end @@ -59,15 +90,18 @@ 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]) + 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.' @@ -76,6 +110,10 @@ module MergeRequests raise_error(error) if error end + def validate_strategy! + @merge_strategy.validate! if Feature.enabled?(:refactor_merge_service, project) + end + def updated_check! unless source_matches? raise_error('Branch has been updated since the merge was requested. '\ @@ -83,9 +121,28 @@ module MergeRequests end end + def commit_v2 + log_info("Git merge started on JID #{merge_jid}") + + merge_result = try_merge { @merge_strategy.execute_git_merge! } + + commit_sha = merge_result[:commit_sha] + raise_error(GENERIC_ERROR_MESSAGE) unless commit_sha + + 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) + merge_request.update!(new_merge_request_attributes) if new_merge_request_attributes.present? + + commit_sha + ensure + merge_request.update_and_mark_in_progress_merge_commit_sha(nil) + log_info("Merge request marked in progress") + end + def commit log_info("Git merge started on JID #{merge_jid}") - commit_id = try_merge + commit_id = try_merge { execute_git_merge } if commit_id log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}") @@ -113,7 +170,7 @@ module MergeRequests end def try_merge - execute_git_merge + yield rescue Gitlab::Git::PreReceiveError => e raise MergeError, "Something went wrong during merge pre-receive hook. #{e.message}".strip rescue StandardError => e diff --git a/app/services/merge_requests/merge_strategies/from_source_branch.rb b/app/services/merge_requests/merge_strategies/from_source_branch.rb new file mode 100644 index 00000000000..9fe5fc5160b --- /dev/null +++ b/app/services/merge_requests/merge_strategies/from_source_branch.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module MergeRequests + module MergeStrategies + # FromSourceBranch performs a git merge from a merge request's source branch + # to the target branch, including a squash if needed. + class FromSourceBranch + include Gitlab::Utils::StrongMemoize + + delegate :repository, to: :project + + def initialize(merge_request, current_user, merge_params: {}, options: {}) + @merge_request = merge_request + @current_user = current_user + @project = merge_request.project + @merge_params = merge_params + @options = options + end + + def validate! + error_message = + if source_sha.blank? + 'No source for merge' + elsif 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_message) if error_message + end + + def execute_git_merge! + result = + if project.merge_requests_ff_only_enabled + fast_forward! + else + merge_commit! + end + + result[:squash_commit_sha] = source_sha if merge_request.squash_on_merge? + + result + end + + private + + attr_reader :merge_request, :current_user, :merge_params, :options, :project + + def source_sha + if merge_request.squash_on_merge? + squash_sha! + else + merge_request.diff_head_sha + end + end + strong_memoize_attr :source_sha + + def squash_sha! + squash_result = ::MergeRequests::SquashService + .new( + merge_request: merge_request, + current_user: current_user, + commit_message: merge_params[:squash_commit_message] + ).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise_error(squash_result[:message]) + end + end + + def fast_forward! + commit_sha = repository.ff_merge( + current_user, + source_sha, + merge_request.target_branch, + merge_request: merge_request + ) + + { commit_sha: commit_sha } + end + + def merge_commit! + commit_sha = repository.merge( + current_user, + source_sha, + merge_request, + merge_commit_message + ) + + { commit_sha: commit_sha, merge_commit_sha: commit_sha } + end + + def merge_commit_message + merge_params[:commit_message] || + merge_request.default_merge_commit_message(user: current_user) + end + + def raise_error(message) + raise ::MergeRequests::MergeStrategies::StrategyError, message + end + end + end +end diff --git a/app/services/merge_requests/merge_strategies/strategy_error.rb b/app/services/merge_requests/merge_strategies/strategy_error.rb new file mode 100644 index 00000000000..144860f5c93 --- /dev/null +++ b/app/services/merge_requests/merge_strategies/strategy_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module MergeRequests + module MergeStrategies + StrategyError = Class.new(StandardError) + end +end diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index acd3bc36e1d..8b79feb5e0f 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -13,13 +13,12 @@ module MergeRequests class MergeToRefService < MergeRequests::MergeBaseService extend ::Gitlab::Utils::Override - def execute(merge_request, cache_merge_to_ref_calls = false) + def execute(merge_request) @merge_request = merge_request error_check! - commit_id = commit(cache_merge_to_ref_calls) - + commit_id = extracted_merge_to_ref raise_error('Conflicts detected during merge') unless commit_id commit = project.commit(commit_id) @@ -56,16 +55,6 @@ module MergeRequests params[:first_parent_ref] || merge_request.target_branch_ref end - def commit(cache_merge_to_ref_calls = false) - if cache_merge_to_ref_calls - Rails.cache.fetch(cache_key, expires_in: 1.day) do - extracted_merge_to_ref - end - else - extracted_merge_to_ref - end - end - def extracted_merge_to_ref repository.merge_to_ref(current_user, source_sha: source, @@ -76,9 +65,5 @@ module MergeRequests rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error raise MergeError, error.message end - - def cache_key - [:merge_to_ref_service, project.full_path, merge_request.target_branch_sha, merge_request.source_branch_sha] - end end end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index f04682bf08a..0b1aefb30d7 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -1,7 +1,17 @@ # frozen_string_literal: true module MergeRequests - class SquashService < MergeRequests::BaseService + class SquashService + include BaseServiceUtility + include MergeRequests::ErrorLogger + + def initialize(merge_request:, current_user:, commit_message:) + @merge_request = merge_request + @target_project = merge_request.target_project + @current_user = current_user + @commit_message = commit_message + end + def execute # If performing a squash would result in no change, then # immediately return a success message without performing a squash @@ -16,6 +26,8 @@ module MergeRequests private + attr_reader :merge_request, :target_project, :current_user, :commit_message + def squash! squash_sha = repository.squash(current_user, merge_request, message) @@ -34,12 +46,8 @@ module MergeRequests target_project.repository end - def merge_request - params[:merge_request] - end - def message - params[:squash_commit_message].presence || merge_request.default_squash_commit_message(user: current_user) + commit_message.presence || merge_request.default_squash_commit_message(user: current_user) end end end diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb deleted file mode 100644 index 47e9afa36b9..00000000000 --- a/app/services/metrics/dashboard/annotations/create_service.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -# Create Metrics::Dashboard::Annotation entry based on matched dashboard_path, environment, cluster -module Metrics - module Dashboard - module Annotations - class CreateService < ::BaseService - include Stepable - - steps :authorize_environment_access, - :authorize_cluster_access, - :parse_dashboard_path, - :create - - def initialize(user, params) - @user = user - @params = params - end - - def execute - execute_steps - end - - private - - attr_reader :user, :params - - def authorize_environment_access(options) - if environment.nil? || Ability.allowed?(user, :admin_metrics_dashboard_annotation, project) - options[:environment] = environment - success(options) - else - error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected environment')) - end - end - - def authorize_cluster_access(options) - if cluster.nil? || Ability.allowed?(user, :admin_metrics_dashboard_annotation, cluster) - options[:cluster] = cluster - success(options) - else - error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected cluster')) - end - end - - def parse_dashboard_path(options) - dashboard_path = params[:dashboard_path] - - Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: dashboard_path) - options[:dashboard_path] = dashboard_path - - success(options) - rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError - error(s_('MetricsDashboardAnnotation|Dashboard with requested path can not be found')) - end - - def create(options) - annotation = Annotation.new(options.slice(:environment, :cluster, :dashboard_path).merge(params.slice(:description, :starting_at, :ending_at))) - - if annotation.save - success(annotation: annotation) - else - error(annotation.errors) - end - end - - def environment - params[:environment] - end - - def cluster - params[:cluster] - end - - def project - (environment || cluster)&.project - end - end - end - end -end diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb deleted file mode 100644 index 34918c89304..00000000000 --- a/app/services/metrics/dashboard/annotations/delete_service.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -# Delete Metrics::Dashboard::Annotation entry -module Metrics - module Dashboard - module Annotations - class DeleteService < ::BaseService - include Stepable - - steps :authorize_action, - :delete - - def initialize(user, annotation) - @user = user - @annotation = annotation - end - - def execute - execute_steps - end - - private - - attr_reader :user, :annotation - - def authorize_action(_options) - if Ability.allowed?(user, :admin_metrics_dashboard_annotation, annotation) - success - else - error(s_('MetricsDashboardAnnotation|You are not authorized to delete this annotation')) - end - end - - def delete(_options) - if annotation.destroy - success - else - error(s_('MetricsDashboardAnnotation|Annotation has not been deleted')) - end - end - end - end - end -end diff --git a/app/services/metrics/dashboard/base_embed_service.rb b/app/services/metrics/dashboard/base_embed_service.rb deleted file mode 100644 index 4c7fa454460..00000000000 --- a/app/services/metrics/dashboard/base_embed_service.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -# Base class for embed services. Contains a few basic helper -# methods that the embed services share. -module Metrics - module Dashboard - class BaseEmbedService < ::Metrics::Dashboard::BaseService - def self.embedded?(embed_param) - ActiveModel::Type::Boolean.new.cast(embed_param) - end - - def cache_key - "dynamic_metrics_dashboard_#{identifiers}" - end - - protected - - def dashboard_path - params[:dashboard_path].presence || - ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH - end - - def group - params[:group] - end - - def title - params[:title] - end - - def y_label - params[:y_label] - end - - def identifiers - [dashboard_path, group, title, y_label].join('|') - end - end - end -end diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb deleted file mode 100644 index 5975fa28b0b..00000000000 --- a/app/services/metrics/dashboard/base_service.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -# Searches a projects repository for a metrics dashboard and formats the output. -# Expects any custom dashboards will be located in `.gitlab/dashboards` -module Metrics - module Dashboard - class BaseService < ::BaseService - include Gitlab::Metrics::Dashboard::Errors - - STAGES = ::Gitlab::Metrics::Dashboard::Stages - SEQUENCE = [ - STAGES::CommonMetricsInserter, - STAGES::MetricEndpointInserter, - STAGES::VariableEndpointInserter, - STAGES::PanelIdsInserter, - STAGES::TrackPanelType, - STAGES::UrlValidator - ].freeze - - def get_dashboard - return error('Insufficient permissions.', :unauthorized) unless allowed? - - success(dashboard: process_dashboard) - rescue StandardError => e - handle_errors(e) - end - - # Summary of all known dashboards for the service. - # @return [Array<Hash>] ex) [{ path: String, default: Boolean }] - def self.all_dashboard_paths(_project) - raise NotImplementedError - end - - # Returns an un-processed dashboard from the cache. - def raw_dashboard - Gitlab::Metrics::Dashboard::Cache.for(project).fetch(cache_key) { get_raw_dashboard } - end - - # Should return true if this dashboard service is for an out-of-the-box - # dashboard. - # This method is overridden in app/services/metrics/dashboard/predefined_dashboard_service.rb. - # @return Boolean - def self.out_of_the_box_dashboard? - false - end - - private - - # Determines whether users should be able to view - # dashboards at all. - def allowed? - return false unless params[:environment] - - project&.feature_available?(:metrics_dashboard, current_user) - end - - # Returns a new dashboard Hash, supplemented with DB info - def process_dashboard - # Get the dashboard from cache/disk before beginning the benchmark. - dashboard = raw_dashboard - processed_dashboard = nil - - benchmark_processing do - processed_dashboard = ::Gitlab::Metrics::Dashboard::Processor - .new(project, dashboard, sequence, process_params) - .process - end - - processed_dashboard - end - - def benchmark_processing - output = nil - - processing_time_seconds = Benchmark.realtime { output = yield } - - if output - processing_time_metric.observe( - processing_time_metric_labels, - processing_time_seconds * 1_000 - ) - end - end - - def process_params - params - end - - # @return [String] Relative filepath of the dashboard yml - def dashboard_path - params[:dashboard_path] - end - - def load_yaml(data) - ::Gitlab::Config::Loader::Yaml.new(data).load_raw! - rescue Gitlab::Config::Loader::Yaml::NotHashError - # Raise more informative error in app/models/performance_monitoring/prometheus_dashboard.rb. - {} - rescue Gitlab::Config::Loader::Yaml::DataTooLargeError => exception - raise Gitlab::Metrics::Dashboard::Errors::LayoutError, exception.message - rescue Gitlab::Config::Loader::FormatError - raise Gitlab::Metrics::Dashboard::Errors::LayoutError, _('Invalid yaml') - end - - # @return [Hash] an unmodified dashboard - def get_raw_dashboard - raise NotImplementedError - end - - # @return [String] - def cache_key - raise NotImplementedError - end - - def sequence - SEQUENCE - end - - def processing_time_metric - @processing_time_metric ||= ::Gitlab::Metrics.summary( - :gitlab_metrics_dashboard_processing_time_ms, - 'Metrics dashboard processing time in milliseconds' - ) - end - - def processing_time_metric_labels - { - stages: sequence_string, - service: self.class.name - } - end - - # If @sequence is [STAGES::CommonMetricsInserter, STAGES::CustomMetricsInserter], - # this function will output `CommonMetricsInserter-CustomMetricsInserter`. - def sequence_string - sequence.map { |stage_class| stage_class.to_s.split('::').last }.join('-') - end - end - end -end diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb deleted file mode 100644 index 18623ad336d..00000000000 --- a/app/services/metrics/dashboard/clone_dashboard_service.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -# Copies system dashboard definition in .yml file into designated -# .yml file inside `.gitlab/dashboards` -module Metrics - module Dashboard - class CloneDashboardService < ::BaseService - include Stepable - include Gitlab::Utils::StrongMemoize - - ALLOWED_FILE_TYPE = '.yml' - USER_DASHBOARDS_DIR = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT - SEQUENCES = { - ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [ - ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter - ].freeze, - - ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [ - ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter - ].freeze - }.freeze - - steps :check_push_authorized, - :check_branch_name, - :check_file_type, - :check_dashboard_template, - :create_file, - :refresh_repository_method_caches - - def execute - execute_steps - end - - private - - def check_push_authorized(result) - return error(_('You are not allowed to push into this branch. Create another branch or open a merge request.'), :forbidden) unless push_authorized? - - success(result) - end - - def check_branch_name(result) - return error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request) unless valid_branch_name? - return error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request) unless new_or_default_branch? - - success(result) - end - - def check_file_type(result) - return error(_('The file name should have a .yml extension'), :bad_request) unless target_file_type_valid? - - success(result) - end - - # Only allow out of the box metrics dashboards to be cloned. This can be - # changed to allow cloning of any metrics dashboard, if desired. - # However, only metrics dashboards should be allowed. If any file is - # allowed to be cloned, this will become a security risk. - def check_dashboard_template(result) - return error(_('Not found.'), :not_found) unless dashboard_service&.out_of_the_box_dashboard? - - success(result) - end - - def create_file(result) - create_file_response = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute - - if create_file_response[:status] == :success - success(result.merge(create_file_response)) - else - wrap_error(create_file_response) - end - end - - def refresh_repository_method_caches(result) - repository.refresh_method_caches([:metrics_dashboard]) - - success(result.merge(http_status: :created, dashboard: dashboard_details)) - end - - def dashboard_service - strong_memoize(:dashboard_service) do - Gitlab::Metrics::Dashboard::ServiceSelector.call(dashboard_service_options) - end - end - - def dashboard_attrs - { - commit_message: params[:commit_message], - file_path: new_dashboard_path, - file_content: new_dashboard_content, - encoding: 'text', - branch_name: branch, - start_branch: repository.branch_exists?(branch) ? branch : project.default_branch - } - end - - def dashboard_details - { - path: new_dashboard_path, - display_name: ::Metrics::Dashboard::CustomDashboardService.name_for_path(new_dashboard_path), - default: false, - system_dashboard: false - } - end - - def push_authorized? - Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(branch) - end - - def dashboard_template - @dashboard_template ||= params[:dashboard] - end - - def branch - @branch ||= params[:branch] - end - - def new_or_default_branch? - !repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch] - end - - def valid_branch_name? - Gitlab::GitRefValidator.validate(params[:branch]) - end - - def new_dashboard_path - @new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name) - end - - def file_name - @file_name ||= File.basename(params[:file_name]) - end - - def target_file_type_valid? - File.extname(params[:file_name]) == ALLOWED_FILE_TYPE - end - - def wrap_error(result) - if result[:message] == 'A file with this name already exists' - error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request) - else - result - end - end - - def new_dashboard_content - ::Gitlab::Metrics::Dashboard::Processor - .new(project, raw_dashboard, sequence, {}) - .process.deep_stringify_keys.to_yaml - end - - def repository - @repository ||= project.repository - end - - def raw_dashboard - dashboard_service.new(project, current_user, dashboard_service_options).raw_dashboard - end - - def dashboard_service_options - { - embedded: false, - dashboard_path: dashboard_template - } - end - - def sequence - SEQUENCES[dashboard_template] || [] - end - end - end -end diff --git a/app/services/metrics/dashboard/cluster_dashboard_service.rb b/app/services/metrics/dashboard/cluster_dashboard_service.rb deleted file mode 100644 index 4a28e847fdd..00000000000 --- a/app/services/metrics/dashboard/cluster_dashboard_service.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -# Fetches the system metrics dashboard and formats the output. -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class ClusterDashboardService < ::Metrics::Dashboard::PredefinedDashboardService - DASHBOARD_PATH = 'config/prometheus/cluster_metrics.yml' - DASHBOARD_NAME = 'Cluster' - - # SHA256 hash of dashboard content - DASHBOARD_VERSION = 'e1a4f8cc2c044cf32273af2cd775eb484729baac0995db687d81d92686bf588e' - - SEQUENCE = [ - STAGES::ClusterEndpointInserter, - STAGES::PanelIdsInserter - ].freeze - - class << self - def valid_params?(params) - # support selecting this service by cluster id via .find - # Use super to support selecting this service by dashboard_path via .find_raw - (params[:cluster].present? && params[:embedded] != 'true') || super - end - end - - # Permissions are handled at the controller level - def allowed? - true - end - - private - - def dashboard_version - DASHBOARD_VERSION - end - end - end -end diff --git a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb deleted file mode 100644 index 6fb39ed3004..00000000000 --- a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true -# -module Metrics - module Dashboard - class ClusterMetricsEmbedService < Metrics::Dashboard::DynamicEmbedService - class << self - def valid_params?(params) - [ - params[:cluster], - embedded?(params[:embedded]), - params[:group].present?, - params[:title].present?, - params[:y_label].present? - ].all? - end - end - - private - - # Permissions are handled at the controller level - def allowed? - true - end - - def dashboard_path - ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH - end - - def sequence - [ - STAGES::ClusterEndpointInserter, - STAGES::PanelIdsInserter - ] - end - end - end -end diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb deleted file mode 100644 index bde8e86851a..00000000000 --- a/app/services/metrics/dashboard/custom_dashboard_service.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -# Searches a projects repository for a metrics dashboard and formats the output. -# Expects any custom dashboards will be located in `.gitlab/dashboards` -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class CustomDashboardService < ::Metrics::Dashboard::BaseService - class << self - def valid_params?(params) - params[:dashboard_path].present? - end - - def all_dashboard_paths(project) - project.repository.user_defined_metrics_dashboard_paths - .map do |filepath| - { - path: filepath, - display_name: name_for_path(filepath), - default: false, - system_dashboard: false, - out_of_the_box_dashboard: out_of_the_box_dashboard? - } - end - end - - # Grabs the filepath after the base directory. - def name_for_path(filepath) - filepath.delete_prefix("#{Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT}/") - end - end - - private - - # Searches the project repo for a custom-defined dashboard. - def get_raw_dashboard - yml = Gitlab::Metrics::Dashboard::RepoDashboardFinder.read_dashboard(project, dashboard_path) - - load_yaml(yml) - end - - def cache_key - "project_#{project.id}_metrics_dashboard_#{dashboard_path}" - end - - def sequence - [ - ::Gitlab::Metrics::Dashboard::Stages::CustomDashboardMetricsInserter - ] + super - end - end - end -end diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb deleted file mode 100644 index eff1db21aff..00000000000 --- a/app/services/metrics/dashboard/custom_metric_embed_service.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -# Responsible for returning a dashboard containing specified -# custom metrics. Creates panels based on the matching metrics -# stored in the database. -# -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class CustomMetricEmbedService < ::Metrics::Dashboard::BaseEmbedService - extend ::Gitlab::Utils::Override - include Gitlab::Utils::StrongMemoize - include Gitlab::Metrics::Dashboard::Defaults - - class << self - # Determines whether the provided params are sufficient - # to uniquely identify a panel composed of user-defined - # custom metrics from the DB. - def valid_params?(params) - [ - embedded?(params[:embedded]), - valid_dashboard?(params[:dashboard_path]), - valid_group_title?(params[:group]), - params[:title].present?, - params.has_key?(:y_label) - ].all? - end - - private - - # A group title is valid if it is one of the limited - # options the user can select in the UI. - def valid_group_title?(group) - Enums::PrometheusMetric - .custom_group_details - .map { |_, details| details[:group_title] } - .include?(group) - end - - # All custom metrics are displayed on the system dashboard. - # Nil is acceptable as we'll default to the system dashboard. - def valid_dashboard?(dashboard) - dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.matching_dashboard?(dashboard) - end - end - - # Returns a new dashboard with only the matching - # metrics from the system dashboard, stripped of - # group info. - # - # Note: This overrides the method #raw_dashboard, - # which means the result will not be cached. This - # is because we are inserting DB info into the - # dashboard before post-processing. This ensures - # we aren't acting on deleted or out-of-date metrics. - # - # @return [Hash] - override :raw_dashboard - def raw_dashboard - panels_not_found!(identifiers) if metrics.empty? - - { 'panel_groups' => [{ 'panels' => panels }] } - end - - private - - # Generated dashboard panels for each metric which - # matches the provided input. - # - # As the panel is generated - # on the fly, we're using default values for info - # not represented in the DB. - # - # @return [Array<Hash>] - def panels - [{ - type: DEFAULT_PANEL_TYPE, - title: title, - y_label: y_label, - metrics: metrics.map(&:to_metric_hash) - }] - end - - # Metrics which match the provided inputs. - # There may be multiple metrics, but they should be - # displayed in a single panel/chart. - # @return [ActiveRecord::AssociationRelation<PromtheusMetric>] - def metrics - strong_memoize(:metrics) do - PrometheusMetricsFinder.new( - project: project, - group: group_key, - title: title, - y_label: y_label - ).execute - end - end - - # Returns a symbol representing the group that - # the dashboard's group title belongs to. - # It will be one of the keys found under - # Enums::PrometheusMetric.custom_groups. - # - # @return [String] - def group_key - strong_memoize(:group_key) do - Enums::PrometheusMetric - .group_details - .find { |_, details| details[:group_title] == group } - .first - .to_s - end - end - end - end -end diff --git a/app/services/metrics/dashboard/default_embed_service.rb b/app/services/metrics/dashboard/default_embed_service.rb deleted file mode 100644 index 30a8150d6be..00000000000 --- a/app/services/metrics/dashboard/default_embed_service.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -# Responsible for returning a filtered system dashboard -# containing only the default embedded metrics. This class -# operates by selecting metrics directly from the system -# dashboard. -# -# Why isn't this filtering in a processing stage? By filtering -# here, we ensure the dynamically-determined dashboard is cached. -# -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class DefaultEmbedService < ::Metrics::Dashboard::BaseEmbedService - # For the default filtering for embedded metrics, - # uses the 'id' key in dashboard-yml definition for - # identification. - DEFAULT_EMBEDDED_METRICS_IDENTIFIERS = %w( - system_metrics_kubernetes_container_memory_total - system_metrics_kubernetes_container_cores_total - ).freeze - - class << self - def valid_params?(params) - embedded?(params[:embedded]) - end - end - - # Returns a new dashboard with only the matching - # metrics from the system dashboard, stripped of groups. - # @return [Hash] - def get_raw_dashboard - panels = panel_groups.each_with_object([]) do |group, panels| - matched_panels = group['panels'].select { |panel| matching_panel?(panel) } - - panels.concat(matched_panels) - end - - { 'panel_groups' => [{ 'panels' => panels }] } - end - - private - - # Returns an array of the panels groups on the - # system dashboard - def panel_groups - ::Metrics::Dashboard::SystemDashboardService - .new(project, nil) - .raw_dashboard['panel_groups'] - end - - # Identifies a panel as "matching" if any metric ids in - # the panel is in the list of identifiers to collect. - def matching_panel?(panel) - panel['metrics'].any? do |metric| - metric_identifiers.include?(metric['id']) - end - end - - def metric_identifiers - DEFAULT_EMBEDDED_METRICS_IDENTIFIERS - end - - def identifiers - metric_identifiers.join('|') - end - end - end -end diff --git a/app/services/metrics/dashboard/dynamic_embed_service.rb b/app/services/metrics/dashboard/dynamic_embed_service.rb deleted file mode 100644 index a94538668c1..00000000000 --- a/app/services/metrics/dashboard/dynamic_embed_service.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -# Responsible for returning a filtered project dashboard -# containing only the request-provided metrics. The result -# is then cached for future requests. Metrics are identified -# based on a combination of identifiers for now, but the ideal -# would be similar to the approach in DefaultEmbedService, but -# a single unique identifier is not currently available across -# all metric types (custom, project-defined, cluster, or system). -# -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class DynamicEmbedService < ::Metrics::Dashboard::BaseEmbedService - include Gitlab::Utils::StrongMemoize - - class << self - # Determines whether the provided params are sufficient - # to uniquely identify a panel from a yml-defined dashboard. - # - # See https://docs.gitlab.com/ee/operations/metrics/dashboards/index.html - # for additional info on defining custom dashboards. - def valid_params?(params) - [ - embedded?(params[:embedded]), - params[:group].present?, - params[:title].present?, - params[:y_label] - ].all? - end - end - - # Returns a new dashboard with only the matching - # metrics from the system dashboard, stripped of groups. - # @return [Hash] - def get_raw_dashboard - not_found! if panels.empty? - - { 'panel_groups' => [{ 'panels' => panels }] } - end - - private - - def panels - strong_memoize(:panels) do - not_found! unless base_dashboard - not_found! unless groups = base_dashboard['panel_groups'] - not_found! unless matching_group = find_group(groups) - not_found! unless all_panels = matching_group['panels'] - - find_panels(all_panels) - end - end - - def base_dashboard - strong_memoize(:base_dashboard) do - Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: dashboard_path) - end - end - - def find_group(groups) - groups.find do |candidate_group| - candidate_group['group'] == group - end - end - - def find_panels(all_panels) - all_panels.select do |panel| - panel['title'] == title && panel['y_label'] == y_label - end - end - - def not_found! - panels_not_found!(identifiers) - end - end - end -end diff --git a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb deleted file mode 100644 index 33c93b25c71..00000000000 --- a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -# Responsible for returning an embed containing the specified -# metrics chart for an alert. Creates panels based on the -# matching metric stored in the database. -# -# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards. -module Metrics - module Dashboard - class GitlabAlertEmbedService < ::Metrics::Dashboard::BaseEmbedService - include Gitlab::Metrics::Dashboard::Defaults - include Gitlab::Utils::StrongMemoize - - SEQUENCE = [ - STAGES::MetricEndpointInserter, - STAGES::PanelIdsInserter - ].freeze - - class << self - # Determines whether the provided params are sufficient - # to uniquely identify a panel composed of user-defined - # custom metrics from the DB. - def valid_params?(params) - [ - embedded?(params[:embedded]), - params[:prometheus_alert_id].is_a?(Integer) - ].all? - end - end - - def raw_dashboard - panels_not_found!(alert_id: alert_id) unless alert && prometheus_metric - - { 'panel_groups' => [{ 'panels' => [panel] }] } - end - - private - - def allowed? - Ability.allowed?(current_user, :read_prometheus_alerts, project) - end - - def alert_id - params[:prometheus_alert_id] - end - - def alert - strong_memoize(:alert) do - Projects::Prometheus::AlertsFinder.new(id: alert_id).execute.first - end - end - - def process_params - params.merge(environment: alert.environment) - end - - def prometheus_metric - strong_memoize(:prometheus_metric) do - PrometheusMetricsFinder.new(id: alert.prometheus_metric_id).execute.first - end - end - - def panel - { - title: prometheus_metric.title, - y_label: prometheus_metric.y_label, - metrics: [prometheus_metric.to_metric_hash], - type: DEFAULT_PANEL_TYPE - } - end - - def sequence - SEQUENCE - end - end - end -end diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb deleted file mode 100644 index 26ccded45f8..00000000000 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ /dev/null @@ -1,179 +0,0 @@ -# frozen_string_literal: true - -# Responsible for returning a gitlab-compatible dashboard -# containing info based on a grafana dashboard and datasource. -# -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class GrafanaMetricEmbedService < ::Metrics::Dashboard::BaseEmbedService - include ReactiveCaching - - SEQUENCE = [ - ::Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter, - ::Gitlab::Metrics::Dashboard::Stages::PanelIdsInserter - ].freeze - - self.reactive_cache_key = ->(service) { service.cache_key } - self.reactive_cache_lease_timeout = 30.seconds - self.reactive_cache_refresh_interval = 30.minutes - self.reactive_cache_lifetime = 30.minutes - self.reactive_cache_work_type = :external_dependency - self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } - - class << self - # Determines whether the provided params are sufficient - # to uniquely identify a grafana dashboard. - def valid_params?(params) - [ - embedded?(params[:embedded]), - params[:grafana_url] - ].all? - end - - def from_cache(project_id, user_id, grafana_url) - project = Project.find(project_id) - user = User.find(user_id) if user_id.present? - - new(project, user, grafana_url: grafana_url) - end - end - - def get_dashboard - with_reactive_cache(*cache_key) { |result| result } - end - - # Inherits the primary logic from the parent class and - # maintains the service's API while including ReactiveCache - def calculate_reactive_cache(*) - # This is called with explicit parentheses to prevent - # the params passed to #calculate_reactive_cache from - # being passed to #get_dashboard (which accepts none) - ::Metrics::Dashboard::BaseService - .instance_method(:get_dashboard) - .bind_call(self) - end - - def cache_key(*args) - [project.id, current_user&.id, grafana_url] - end - - # Required for ReactiveCaching; Usage overridden by - # self.reactive_cache_worker_finder - def id - nil - end - - private - - def get_raw_dashboard - raise MissingIntegrationError unless client - - grafana_dashboard = fetch_dashboard - datasource = fetch_datasource(grafana_dashboard) - - params.merge!(grafana_dashboard: grafana_dashboard, datasource: datasource) - - {} - end - - def fetch_dashboard - uid = GrafanaUidParser.new(grafana_url, project).parse - raise DashboardProcessingError, _('Dashboard uid not found') unless uid - - response = client.get_dashboard(uid: uid) - - parse_json(response.body) - end - - def fetch_datasource(dashboard) - name = DatasourceNameParser.new(grafana_url, dashboard).parse - raise DashboardProcessingError, _('Datasource name not found') unless name - - response = client.get_datasource(name: name) - - parse_json(response.body) - end - - def grafana_url - params[:grafana_url] - end - - def client - project.grafana_integration&.client - end - - def allowed? - Ability.allowed?(current_user, :read_project, project) - end - - def sequence - SEQUENCE - end - - def parse_json(json) - Gitlab::Json.parse(json, symbolize_names: true) - rescue JSON::ParserError - raise DashboardProcessingError, _('Grafana response contains invalid json') - end - end - - # Identifies the uid of the dashboard based on url format - class GrafanaUidParser - def initialize(grafana_url, project) - @grafana_url = grafana_url - @project = project - end - - def parse - @grafana_url.match(uid_regex) { |m| m.named_captures['uid'] } - end - - private - - # URLs are expected to look like https://domain.com/d/:uid/other/stuff - def uid_regex - base_url = @project.grafana_integration.grafana_url.chomp('/') - - %r{^(#{Regexp.escape(base_url)}\/d\/(?<uid>.+)\/)}x - end - end - - # Identifies the name of the datasource for a dashboard - # based on the panelId query parameter found in the url. - # - # If no panel is specified, defaults to the first valid panel. - class DatasourceNameParser - def initialize(grafana_url, grafana_dashboard) - @grafana_url = grafana_url - @grafana_dashboard = grafana_dashboard - end - - def parse - @grafana_dashboard[:dashboard][:panels] - .find { |panel| panel_id ? matching_panel?(panel) : valid_panel?(panel) } - .try(:[], :datasource) - end - - private - - def panel_id - query_params[:panelId] - end - - def query_params - Gitlab::Metrics::Dashboard::Url.parse_query(@grafana_url) - end - - def matching_panel?(panel) - panel[:id].to_s == panel_id - end - - def valid_panel?(panel) - ::Grafana::Validator - .new(@grafana_dashboard, nil, panel, query_params) - .valid? - end - end - end -end diff --git a/app/services/metrics/dashboard/panel_preview_service.rb b/app/services/metrics/dashboard/panel_preview_service.rb deleted file mode 100644 index 260b49a5b19..00000000000 --- a/app/services/metrics/dashboard/panel_preview_service.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -# Ingest YAML fragment with metrics dashboard panel definition -# https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-panels-properties -# process it and returns renderable json version -module Metrics - module Dashboard - class PanelPreviewService - SEQUENCE = [ - ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::MetricEndpointInserter, - ::Gitlab::Metrics::Dashboard::Stages::PanelIdsInserter, - ::Gitlab::Metrics::Dashboard::Stages::UrlValidator - ].freeze - - HANDLED_PROCESSING_ERRORS = [ - Gitlab::Metrics::Dashboard::Errors::DashboardProcessingError, - Gitlab::Config::Loader::Yaml::NotHashError, - Gitlab::Config::Loader::Yaml::DataTooLargeError, - Gitlab::Config::Loader::FormatError - ].freeze - - def initialize(project, panel_yaml, environment) - @project = project - @panel_yaml = panel_yaml - @environment = environment - end - - def execute - dashboard = ::Gitlab::Metrics::Dashboard::Processor.new(project, dashboard_structure, SEQUENCE, environment: environment).process - ServiceResponse.success(payload: dashboard[:panel_groups][0][:panels][0]) - rescue *HANDLED_PROCESSING_ERRORS => error - ServiceResponse.error(message: error.message) - end - - private - - attr_accessor :project, :panel_yaml, :environment - - def dashboard_structure - { - panel_groups: [ - { - panels: [panel_hash] - } - ] - } - end - - def panel_hash - ::Gitlab::Config::Loader::Yaml.new(panel_yaml).load_raw! - end - end - end -end diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb deleted file mode 100644 index c83f8618460..00000000000 --- a/app/services/metrics/dashboard/pod_dashboard_service.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboard - class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService - DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml' - DASHBOARD_NAME = N_('K8s pod health') - - # SHA256 hash of dashboard content - DASHBOARD_VERSION = '3a91b32f91b2dd3d90275333c0ea3630b3f3f37c4296ede5b5eef59bf523d66b' - - SEQUENCE = [ - STAGES::MetricEndpointInserter, - STAGES::VariableEndpointInserter, - STAGES::PanelIdsInserter - ].freeze - - class << self - def all_dashboard_paths(_project) - [{ - path: DASHBOARD_PATH, - display_name: _(DASHBOARD_NAME), - default: false, - system_dashboard: false, - out_of_the_box_dashboard: out_of_the_box_dashboard? - }] - end - end - - private - - def dashboard_version - DASHBOARD_VERSION - end - end - end -end diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb deleted file mode 100644 index abdef66c2e0..00000000000 --- a/app/services/metrics/dashboard/predefined_dashboard_service.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboard - class PredefinedDashboardService < ::Metrics::Dashboard::BaseService - # These constants should be overridden in the inheriting class. For Ex: - # DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' - # DASHBOARD_NAME = 'Default' - DASHBOARD_PATH = nil - DASHBOARD_NAME = nil - - SEQUENCE = [ - STAGES::MetricEndpointInserter, - STAGES::VariableEndpointInserter, - STAGES::PanelIdsInserter - ].freeze - - class << self - def valid_params?(params) - matching_dashboard?(params[:dashboard_path]) - end - - def matching_dashboard?(filepath) - filepath == self::DASHBOARD_PATH - end - - def out_of_the_box_dashboard? - true - end - end - - # Returns an un-processed dashboard from the cache. - def raw_dashboard - Gitlab::Metrics::Dashboard::Cache.fetch(cache_key) { get_raw_dashboard } - end - - private - - def dashboard_version - raise NotImplementedError - end - - def cache_key - "metrics_dashboard_#{dashboard_path}_#{dashboard_version}" - end - - def dashboard_path - self.class::DASHBOARD_PATH - end - - # Returns the base metrics shipped with every GitLab service. - def get_raw_dashboard - yml = File.read(Rails.root.join(dashboard_path)) - - load_yaml(yml) - end - - def sequence - self.class::SEQUENCE - end - end - end -end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb deleted file mode 100644 index 1bd31b2ba21..00000000000 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -# Fetches the system metrics dashboard and formats the output. -# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards. -module Metrics - module Dashboard - class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService - DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' - DASHBOARD_NAME = N_('Overview') - - # SHA256 hash of dashboard content - DASHBOARD_VERSION = 'ce9ae27d2913f637de851d61099bc4151583eae68b1386a2176339ef6e653223' - - SEQUENCE = [ - STAGES::CommonMetricsInserter, - STAGES::CustomMetricsInserter, - STAGES::CustomMetricsDetailsInserter, - STAGES::MetricEndpointInserter, - STAGES::VariableEndpointInserter, - STAGES::PanelIdsInserter - ].freeze - - class << self - def all_dashboard_paths(_project) - [{ - path: DASHBOARD_PATH, - display_name: _(DASHBOARD_NAME), - default: true, - system_dashboard: true, - out_of_the_box_dashboard: out_of_the_box_dashboard? - }] - end - end - - private - - def dashboard_version - DASHBOARD_VERSION - end - end - end -end diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb deleted file mode 100644 index 29ea9909a36..00000000000 --- a/app/services/metrics/dashboard/transient_embed_service.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -# Acts as a pass-through to allow embeddable dashboards to be -# generated based on external data, but still processed with the -# required attributes that allow the FE to render them appropriately. -# -# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. -module Metrics - module Dashboard - class TransientEmbedService < ::Metrics::Dashboard::BaseEmbedService - extend ::Gitlab::Utils::Override - - class << self - def valid_params?(params) - [ - embedded?(params[:embedded]), - params[:embed_json] - ].all? - end - end - - private - - override :get_raw_dashboard - def get_raw_dashboard - Gitlab::Json.parse(params[:embed_json]) - rescue JSON::ParserError => e - invalid_embed_json!(e.message) - end - - override :sequence - def sequence - [STAGES::MetricEndpointInserter] - end - - override :identifiers - def identifiers - Digest::SHA256.hexdigest(params[:embed_json]) - end - - def invalid_embed_json!(message) - raise DashboardProcessingError, _("Parsing error for param :embed_json. %{message}") % { message: message } - end - end - end -end diff --git a/app/services/metrics/dashboard/update_dashboard_service.rb b/app/services/metrics/dashboard/update_dashboard_service.rb deleted file mode 100644 index 0574cb15e96..00000000000 --- a/app/services/metrics/dashboard/update_dashboard_service.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -# Updates the content of a specified dashboard in .yml file inside `.gitlab/dashboards` -module Metrics - module Dashboard - class UpdateDashboardService < ::BaseService - include Stepable - - ALLOWED_FILE_TYPE = '.yml' - USER_DASHBOARDS_DIR = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT - - steps :check_push_authorized, - :check_branch_name, - :check_file_type, - :update_file, - :create_merge_request - - def execute - execute_steps - end - - private - - def check_push_authorized(result) - return error(_('You are not allowed to push into this branch. Create another branch or open a merge request.'), :forbidden) unless push_authorized? - - success(result) - end - - def check_branch_name(result) - return error(_('There was an error updating the dashboard, branch name is invalid.'), :bad_request) unless valid_branch_name? - return error(_('There was an error updating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request) unless new_or_default_branch? - - success(result) - end - - def check_file_type(result) - return error(_('The file name should have a .yml extension'), :bad_request) unless target_file_type_valid? - - success(result) - end - - def update_file(result) - file_update_response = ::Files::UpdateService.new(project, current_user, dashboard_attrs).execute - - if file_update_response[:status] == :success - success(result.merge(file_update_response, http_status: :created, dashboard: dashboard_details)) - else - error(file_update_response[:message], :bad_request) - end - end - - def create_merge_request(result) - return success(result) if project.default_branch == branch - - merge_request_params = { - source_branch: branch, - target_branch: project.default_branch, - title: params[:commit_message] - } - merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: merge_request_params).execute - - if merge_request.persisted? - success(result.merge(merge_request: Gitlab::UrlBuilder.build(merge_request))) - else - error(merge_request.errors.full_messages.join(','), :bad_request) - end - end - - def push_authorized? - Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(branch) - end - - def valid_branch_name? - Gitlab::GitRefValidator.validate(branch) - end - - def new_or_default_branch? - !repository.branch_exists?(branch) || project.default_branch == branch - end - - def target_file_type_valid? - File.extname(params[:file_name]) == ALLOWED_FILE_TYPE - end - - def dashboard_attrs - { - commit_message: params[:commit_message], - file_path: update_dashboard_path, - file_content: update_dashboard_content, - encoding: 'text', - branch_name: branch, - start_branch: repository.branch_exists?(branch) ? branch : project.default_branch - } - end - - def update_dashboard_path - File.join(USER_DASHBOARDS_DIR, file_name) - end - - def file_name - @file_name ||= File.basename(CGI.unescape(params[:file_name])) - end - - def branch - @branch ||= params[:branch] - end - - def update_dashboard_content - ::PerformanceMonitoring::PrometheusDashboard.from_json(params[:file_content]).to_yaml - end - - def repository - @repository ||= project.repository - end - - def dashboard_details - { - path: update_dashboard_path, - display_name: ::Metrics::Dashboard::CustomDashboardService.name_for_path(update_dashboard_path), - default: false, - system_dashboard: false - } - end - end - end -end diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb deleted file mode 100644 index 0d028f120d3..00000000000 --- a/app/services/metrics/users_starred_dashboards/create_service.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -# Create Metrics::UsersStarredDashboard entry for given user based on matched dashboard_path, project -module Metrics - module UsersStarredDashboards - class CreateService < ::BaseService - include Stepable - - steps :authorize_create_action, - :parse_dashboard_path, - :create - - def initialize(user, project, dashboard_path) - @user = user - @project = project - @dashboard_path = dashboard_path - end - - def execute - keys = %i[status message starred_dashboard] - status, message, dashboards = execute_steps.values_at(*keys) - - if status != :success - ServiceResponse.error(message: message) - else - ServiceResponse.success(payload: dashboards) - end - end - - private - - attr_reader :user, :project, :dashboard_path - - def authorize_create_action(_options) - if Ability.allowed?(user, :create_metrics_user_starred_dashboard, project) - success(user: user, project: project) - else - error(s_('MetricsUsersStarredDashboards|You are not authorized to add star to this dashboard')) - end - end - - def parse_dashboard_path(options) - if dashboard_path_exists? - options[:dashboard_path] = dashboard_path - success(options) - else - error(s_('MetricsUsersStarredDashboards|Dashboard with requested path can not be found')) - end - end - - def create(options) - starred_dashboard = build_starred_dashboard_from(options) - - if starred_dashboard.save - success(starred_dashboard: starred_dashboard) - else - error(starred_dashboard.errors.messages) - end - end - - def build_starred_dashboard_from(options) - Metrics::UsersStarredDashboard.new( - user: options.fetch(:user), - project: options.fetch(:project), - dashboard_path: options.fetch(:dashboard_path) - ) - end - - def dashboard_path_exists? - Gitlab::Metrics::Dashboard::Finder - .find_all_paths(project) - .any? { |dashboard| dashboard[:path] == dashboard_path } - end - end - end -end diff --git a/app/services/metrics/users_starred_dashboards/delete_service.rb b/app/services/metrics/users_starred_dashboards/delete_service.rb deleted file mode 100644 index 229c0e8cfc0..00000000000 --- a/app/services/metrics/users_starred_dashboards/delete_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -# Delete all matching Metrics::UsersStarredDashboard entries for given user based on matched dashboard_path, project -module Metrics - module UsersStarredDashboards - class DeleteService < ::BaseService - def initialize(user, project, dashboard_path = nil) - @user = user - @project = project - @dashboard_path = dashboard_path - end - - def execute - ServiceResponse.success(payload: { deleted_rows: starred_dashboards.delete_all }) - end - - private - - attr_reader :user, :project, :dashboard_path - - def starred_dashboards - # since deleted records are scoped to their owner there is no need to - # check if that user can delete them, also if user lost access to - # project it shouldn't block that user from removing them - dashboards = user.metrics_users_starred_dashboards - - if dashboard_path.present? - dashboards.for_project_dashboard(project, dashboard_path) - else - dashboards.for_project(project) - end - end - end - end -end diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb index 2399da3e182..436f06e3ca5 100644 --- a/app/services/ml/experiment_tracking/candidate_repository.rb +++ b/app/services/ml/experiment_tracking/candidate_repository.rb @@ -5,7 +5,7 @@ module Ml class CandidateRepository attr_accessor :project, :user, :experiment, :candidate - def initialize(project, user) + def initialize(project, user = nil) @project = project @user = user end @@ -103,10 +103,16 @@ module Ml end def candidate_name(name, tags) - return name if name.present? - return unless tags.present? + name.presence || candidate_name_from_tags(tags) || random_candidate_name + end + + def candidate_name_from_tags(tags) + tags&.detect { |t| t[:key] == 'mlflow.runName' }&.dig(:value) + end - tags.detect { |t| t[:key] == 'mlflow.runName' }&.dig(:value) + def random_candidate_name + parts = Array.new(3).map { FFaker::Animal.common_name.downcase.delete(' ') } << rand(10000) + parts.join('-').truncate(255) end end end diff --git a/app/services/ml/find_or_create_experiment_service.rb b/app/services/ml/find_or_create_experiment_service.rb new file mode 100644 index 00000000000..1fe10c7f856 --- /dev/null +++ b/app/services/ml/find_or_create_experiment_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ml + class FindOrCreateExperimentService + def initialize(project, experiment_name, user = nil) + @project = project + @name = experiment_name + @user = user + end + + def execute + Ml::Experiment.find_or_create(project, name, user) + end + + private + + attr_reader :project, :name, :user + end +end diff --git a/app/services/ml/find_or_create_model_service.rb b/app/services/ml/find_or_create_model_service.rb new file mode 100644 index 00000000000..66dec7a6234 --- /dev/null +++ b/app/services/ml/find_or_create_model_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ml + class FindOrCreateModelService + def initialize(project, model_name) + @project = project + @name = model_name + end + + def execute + Ml::Model.find_or_create( + project, + name, + Ml::FindOrCreateExperimentService.new(project, name).execute + ) + end + + private + + attr_reader :name, :project + end +end diff --git a/app/services/ml/find_or_create_model_version_service.rb b/app/services/ml/find_or_create_model_version_service.rb new file mode 100644 index 00000000000..1316b2546b9 --- /dev/null +++ b/app/services/ml/find_or_create_model_version_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ml + class FindOrCreateModelVersionService + def initialize(project, params = {}) + @project = project + @name = params[:model_name] + @version = params[:version] + @package = params[:package] + end + + def execute + model = Ml::FindOrCreateModelService.new(project, name).execute + + Ml::ModelVersion.find_or_create!(model, version, package) + end + + private + + attr_reader :version, :name, :project, :package + end +end diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb index c391320db5e..92766bc0267 100644 --- a/app/services/namespace_settings/update_service.rb +++ b/app/services/namespace_settings/update_service.rb @@ -27,6 +27,10 @@ module NamespaceSettings param_key: :default_branch_protection, user_policy: :update_default_branch_protection ) + validate_settings_param_for_root_group( + param_key: :default_branch_protection_defaults, + user_policy: :update_default_branch_protection + ) handle_default_branch_protection unless settings_params[:default_branch_protection].blank? diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb index 0c6fcee9113..cd5745cfec6 100644 --- a/app/services/namespaces/package_settings/update_service.rb +++ b/app/services/namespaces/package_settings/update_service.rb @@ -10,6 +10,8 @@ module Namespaces generic_duplicates_allowed generic_duplicate_exception_regex maven_package_requests_forwarding + nuget_duplicates_allowed + nuget_duplicate_exception_regex npm_package_requests_forwarding pypi_package_requests_forwarding lock_maven_package_requests_forwarding diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index b93b44ce797..648067e3452 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -345,8 +345,12 @@ class NotificationService def review_requested_of_merge_request(merge_request, current_user, reviewer) recipients = NotificationRecipients::BuildService.build_requested_review_recipients(merge_request, current_user, reviewer) + deliver_option = review_request_deliver_options(merge_request.project, reviewer) + recipients.each do |recipient| - mailer.request_review_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later + mailer + .request_review_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason) + .deliver_later(deliver_option) end end @@ -529,6 +533,12 @@ class NotificationService mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end + def member_about_to_expire(member) + return true unless member.notifiable?(:mention) + + mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later + end + # Group invite def invite_group_member(group_member, token) mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later @@ -723,9 +733,12 @@ class NotificationService # Notify users on new review in system def new_review(review) recipients = NotificationRecipients::BuildService.build_new_review_recipients(review) + deliver_options = new_review_deliver_options(review) recipients.each do |recipient| - mailer.new_review_email(recipient.user.id, review.id).deliver_later + mailer + .new_review_email(recipient.user.id, review.id) + .deliver_later(deliver_options) end end @@ -946,6 +959,16 @@ class NotificationService def warn_skipping_notifications(user, object) Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id) end + + def new_review_deliver_options(review) + # Overridden in EE + {} + end + + def review_request_deliver_options(project, user) + # Overridden in EE + {} + end end NotificationService.prepend_mod_with('NotificationService') diff --git a/app/services/packages/ml_model/create_package_file_service.rb b/app/services/packages/ml_model/create_package_file_service.rb index 574f70940fc..b1e8e814015 100644 --- a/app/services/packages/ml_model/create_package_file_service.rb +++ b/app/services/packages/ml_model/create_package_file_service.rb @@ -5,7 +5,10 @@ module Packages class CreatePackageFileService < BaseService def execute ::Packages::Package.transaction do - create_package_file(find_or_create_package) + package = find_or_create_package + find_or_create_model_version(package) + + create_package_file(package) end end @@ -30,6 +33,16 @@ module Packages package end + def find_or_create_model_version(package) + model_version_params = { + model_name: package.name, + version: package.version, + package: package + } + + Ml::FindOrCreateModelVersionService.new(project, model_version_params).execute + end + def create_package_file(package) file_params = { file: params[:file], 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 d82509fff5e..73a52ea569f 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -14,6 +14,7 @@ module Packages MISSING_MATCHING_PACKAGE_ERROR_MESSAGE = 'symbol package is invalid, matching package does not exist' InvalidMetadataError = Class.new(StandardError) + ZipError = Class.new(StandardError) def initialize(package_file) @package_file = package_file @@ -32,6 +33,8 @@ module Packages end rescue ActiveRecord::RecordInvalid => e raise InvalidMetadataError, e.message + rescue Zip::Error + raise ZipError, 'Could not open the .nupkg file' end private diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb index ca4aaa8fdde..07545495f1b 100644 --- a/app/services/packages/rubygems/process_gem_service.rb +++ b/app/services/packages/rubygems/process_gem_service.rb @@ -9,6 +9,8 @@ module Packages include ExclusiveLeaseGuard ExtractionError = Class.new(StandardError) + InvalidMetadataError = Class.new(StandardError) + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze def initialize(package_file) @@ -20,6 +22,9 @@ module Packages return success if process_gem error('Gem was not processed') + rescue ActiveRecord::StatementInvalid + # TODO: We can remove this rescue block when we fix https://gitlab.com/gitlab-org/gitlab/-/issues/415899 + raise InvalidMetadataError, 'Invalid metadata' end private diff --git a/app/services/personal_access_tokens/revoke_token_family_service.rb b/app/services/personal_access_tokens/revoke_token_family_service.rb index 547ba6c3bdc..e96561cbc1c 100644 --- a/app/services/personal_access_tokens/revoke_token_family_service.rb +++ b/app/services/personal_access_tokens/revoke_token_family_service.rb @@ -7,10 +7,18 @@ module PersonalAccessTokens end def execute - # Despite using #update_all, there should only be a single active token. - # A token family is a chain of rotated tokens. Once rotated, the - # previous token is revoked. - pat_family.active.update_all(revoked: true) + # A token family is a chain of rotated tokens. Once rotated, the previous + # token is revoked. As a result, a single token id should be returned by + # this query. + # rubocop: disable CodeReuse/ActiveRecord + token_ids = pat_family.active.pluck_primary_key + + # We create another query based on the previous if any id exists. An + # alternative is to use a single query, like + # `pat_family.active.update_all(...)`). However, #update_all ignores + # the CTE, and tries to revoke *all* active tokens. + PersonalAccessToken.where(id: token_ids).update_all(revoked: true) if token_ids.any? + # rubocop: enable CodeReuse/ActiveRecord ServiceResponse.success end @@ -25,8 +33,16 @@ module PersonalAccessTokens personal_access_token_table = Arel::Table.new(:personal_access_tokens) cte << PersonalAccessToken + .select( + 'personal_access_tokens.id', + 'personal_access_tokens.revoked', + 'personal_access_tokens.expires_at') .where(personal_access_token_table[:previous_personal_access_token_id].eq(token.id)) cte << PersonalAccessToken + .select( + 'personal_access_tokens.id', + 'personal_access_tokens.revoked', + 'personal_access_tokens.expires_at') .from([personal_access_token_table, cte.table]) .where(personal_access_token_table[:previous_personal_access_token_id].eq(cte.table[:id])) PersonalAccessToken.with.recursive(cte.to_arel).from(cte.alias_to(personal_access_token_table)) diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 5ab5732ecf5..7cf1855988e 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -87,14 +87,14 @@ class PostReceiveService if project scoped_messages = - BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message| + System::BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message| message.target_path.present? && message.matches_current_path(project.full_path) && message.show_in_cli? end banner = scoped_messages.last end - banner ||= BroadcastMessage.current_show_in_cli_banner_messages.last + banner ||= System::BroadcastMessage.current_show_in_cli_banner_messages.last banner&.message end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index e37b6516d21..bca78b88630 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -223,10 +223,14 @@ module Projects end def save_project_and_import_data - Project.transaction do + ApplicationRecord.transaction do @project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data - if @project.save + # 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) @project.create_labels unless @project.gitlab_project_import? diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index a5c12384b59..0ae6fcb4d97 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -114,7 +114,11 @@ module Projects # It's possible that the project was destroyed, but some after_commit # hook failed and caused us to end up here. A destroyed model will be a frozen hash, # which cannot be altered. - project.update(delete_error: message, pending_delete: false) unless project.destroyed? + unless project.destroyed? + # Restrict project visibility if the parent group visibility was made more restrictive while the project was scheduled for deletion. + visibility_level = project.visibility_level_allowed_by_group? ? project.visibility_level : project.group.visibility_level + project.update(delete_error: message, pending_delete: false, visibility_level: visibility_level) + end log_error("Deletion failed on #{project.full_path} with the following message: #{message}") end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 44cd6e9926f..458eaec4e2e 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -18,15 +18,7 @@ module Projects end def project_members - @project_members ||= sorted(get_project_members) - end - - def get_project_members - members = Member.from_union([project_members_through_ancestral_groups, - project_members_through_invited_groups, - individual_project_members]) - - User.id_in(members.select(:user_id)) + @project_members ||= sorted(project.authorized_users) end def all_members @@ -34,33 +26,5 @@ module Projects [{ username: "all", name: "All Project and Group Members", count: project_members.count }] end - - private - - def project_members_through_invited_groups - GroupMember - .active_without_invites_and_requests - .with_source_id(visible_groups.self_and_ancestors.pluck_primary_key) - .select(*GroupMember.cached_column_list) - end - - def visible_groups - visible_groups = project.invited_groups - - unless project.team.member?(current_user) - visible_groups = visible_groups.public_or_visible_to_user(current_user) - end - - visible_groups - end - - def project_members_through_ancestral_groups - members = project.group.present? ? project.group.members_with_parents : Member.none - members.select(*GroupMember.cached_column_list) - end - - def individual_project_members - project.project_members.select(*GroupMember.cached_column_list) - end end end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 22a882c4648..8f1f78beb5b 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -80,8 +80,7 @@ module Projects def valid_alert_manager_token?(token, integration) valid_for_alerts_endpoint?(token, integration) || - valid_for_manual?(token) || - valid_for_cluster?(token) + valid_for_manual?(token) end def valid_for_manual?(token) @@ -109,44 +108,6 @@ module Projects compare_token(token, integration.token) end - def valid_for_cluster?(token) - cluster_integration = find_cluster_integration(project) - return false unless cluster_integration - - cluster_integration_token = cluster_integration.alert_manager_token - - if token - compare_token(token, cluster_integration_token) - else - cluster_integration_token.nil? - end - end - - def find_cluster_integration(project) - alert_id = gitlab_alert_id - return unless alert_id - - alert = find_alert(project, alert_id) - return unless alert - - cluster = alert.environment.deployment_platform&.cluster - return unless cluster&.enabled? - return unless cluster.integration_prometheus_available? - - cluster.integration_prometheus - end - - def find_alert(project, metric) - Projects::Prometheus::AlertsFinder - .new(project: project, metric: metric) - .execute - .first - end - - def gitlab_alert_id - alerts&.first&.dig('labels', 'gitlab_alert_id') - end - def compare_token(expected, actual) return unless expected && actual diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 4a9d96d266c..642ec37619f 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -13,6 +13,14 @@ module Projects include Gitlab::ShellAdapter TransferError = Class.new(StandardError) + def log_project_transfer_success(project, new_namespace) + log_transfer(project, new_namespace, nil) + end + + def log_project_transfer_error(project, new_namespace, error_message) + log_transfer(project, new_namespace, error_message) + end + def execute(new_namespace) @new_namespace = new_namespace @@ -36,10 +44,15 @@ module Projects transfer(project) + log_project_transfer_success(project, @new_namespace) + true rescue Projects::TransferService::TransferError => ex project.reset project.errors.add(:new_namespace, ex.message) + + log_project_transfer_error(project, @new_namespace, ex.message) + false end @@ -47,6 +60,27 @@ module Projects attr_reader :old_path, :new_path, :new_namespace, :old_namespace + def log_transfer(project, new_namespace, error_message = nil) + action = error_message.nil? ? "was" : "was not" + + log_payload = { + message: "Project #{action} transferred to a new namespace", + project_id: project.id, + project_path: project.full_path, + project_namespace: project.namespace.full_path, + namespace_id: project.namespace_id, + new_namespace_id: new_namespace&.id, + new_project_namespace: new_namespace&.full_path, + error_message: error_message + } + + if error_message.nil? + ::Gitlab::AppLogger.info(log_payload) + else + ::Gitlab::AppLogger.error(log_payload) + end + end + # rubocop: disable CodeReuse/ActiveRecord def transfer(project) @old_path = project.full_path @@ -110,7 +144,7 @@ module Projects update_pending_builds - post_update_hooks(project) + post_update_hooks(project, @old_group) rescue Exception # rubocop:disable Lint/RescueException rollback_side_effects raise @@ -119,7 +153,7 @@ module Projects end # Overridden in EE - def post_update_hooks(project) + def post_update_hooks(project, _old_group) ensure_personal_project_owner_membership(project) invalidate_personal_projects_counts diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index cadf3012131..f5f6bb85995 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -9,12 +9,20 @@ module Projects private def track_repository(_destination_storage_name) - project.leave_pool_repository + # Connect project to pool repository from the new shard + project.swap_pool_repository! + + # Connect project to the repository from the new shard project.track_project_repository + + # Link repository from the new shard to pool repository from the new shard + project.link_pool_repository if replicate_object_pool_on_move_ff_enabled? end def mirror_repositories - mirror_repository(type: Gitlab::GlRepository::PROJECT) if project.repository_exists? + if project.repository_exists? + mirror_repository(type: Gitlab::GlRepository::PROJECT) + end if project.wiki.repository_exists? mirror_repository(type: Gitlab::GlRepository::WIKI) @@ -25,6 +33,30 @@ module Projects end end + def mirror_object_pool(destination_storage_name) + return unless replicate_object_pool_on_move_ff_enabled? + return unless project.repository_exists? + + pool_repository = project.pool_repository + return unless pool_repository + + # If pool repository already exists, then we will link the moved project repository to it + return if pool_repository_exists_for?(shard_name: destination_storage_name, pool_repository: pool_repository) + + target_pool_repository = create_pool_repository_for!( + shard_name: destination_storage_name, + pool_repository: pool_repository + ) + + checksum, new_checksum = replicate_object_pool_repository(from: pool_repository, to: target_pool_repository) + + if checksum != new_checksum + raise Error, + format(s_('UpdateRepositoryStorage|Failed to verify %{type} repository checksum from %{old} to %{new}'), + type: 'object_pool', old: checksum, new: new_checksum) + end + end + def remove_old_paths super @@ -46,5 +78,39 @@ module Projects ).remove end end + + def pool_repository_exists_for?(shard_name:, pool_repository:) + PoolRepository.by_source_project_and_shard_name( + pool_repository.source_project, + shard_name + ).exists? + end + + def create_pool_repository_for!(shard_name:, pool_repository:) + # Set state `ready` because we manually replicate object pool + PoolRepository.create!( + shard: Shard.by_name(shard_name), + source_project: pool_repository.source_project, + disk_path: pool_repository.disk_path, + state: 'ready' + ) + end + + def replicate_object_pool_repository(from:, to:) + old_object_pool = from.object_pool + new_object_pool = to.object_pool + + checksum = old_object_pool.repository.checksum + + new_object_pool.repository.replicate(old_object_pool.repository) + + new_checksum = new_object_pool.repository.checksum + + [checksum, new_checksum] + end + + def replicate_object_pool_on_move_ff_enabled? + Feature.enabled?(:replicate_object_pool_on_move, project) + end end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 7f25ab5883f..8639e2f833f 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -51,12 +51,6 @@ module Projects private def add_pages_unique_domain - if Feature.disabled?(:pages_unique_domain, project) - params[:project_setting_attributes]&.delete(:pages_unique_domain_enabled) - - return - end - 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 @@ -119,7 +113,7 @@ module Projects end def remove_unallowed_params - params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project) + params.delete(:emails_enabled) unless can?(current_user, :set_emails_disabled, project) params.delete(:runner_registration_enabled) if Gitlab::CurrentSettings.valid_runner_registrars.exclude?('project') end diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb index 71f5a8e633d..9b979f6ed68 100644 --- a/app/services/projects/update_statistics_service.rb +++ b/app/services/projects/update_statistics_service.rb @@ -5,7 +5,7 @@ module Projects include ::Gitlab::Utils::StrongMemoize STAT_TO_CACHED_METHOD = { - repository_size: :size, + repository_size: [:size, :recent_objects_size], commit_count: :commit_count }.freeze @@ -37,7 +37,7 @@ module Projects def method_caches_to_expire strong_memoize(:method_caches_to_expire) do - statistics.map { |stat| STAT_TO_CACHED_METHOD[stat] }.compact + statistics.flat_map { |stat| STAT_TO_CACHED_METHOD[stat] }.compact end end diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb deleted file mode 100644 index 33635796771..00000000000 --- a/app/services/prometheus/proxy_service.rb +++ /dev/null @@ -1,145 +0,0 @@ -# frozen_string_literal: true - -module Prometheus - class ProxyService < BaseService - include ReactiveCaching - include Gitlab::Utils::StrongMemoize - - self.reactive_cache_key = ->(service) { [] } - self.reactive_cache_lease_timeout = 30.seconds - - # reactive_cache_refresh_interval should be set to a value higher than - # reactive_cache_lifetime. If the refresh_interval is less than lifetime - # then the ReactiveCachingWorker will re-query prometheus for this - # PromQL query even though it's (probably) already been picked up by - # the frontend - # refresh_interval should be set less than lifetime only if this data - # is expected to change *and* be fetched again by the frontend - self.reactive_cache_refresh_interval = 90.seconds - self.reactive_cache_lifetime = 1.minute - self.reactive_cache_work_type = :external_dependency - self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } - - attr_accessor :proxyable, :method, :path, :params - - PROMETHEUS_QUERY_API = 'query' - PROMETHEUS_QUERY_RANGE_API = 'query_range' - PROMETHEUS_SERIES_API = 'series' - - PROXY_SUPPORT = { - PROMETHEUS_QUERY_API => { - method: ['GET'], - params: %w(query time timeout) - }, - PROMETHEUS_QUERY_RANGE_API => { - method: ['GET'], - params: %w(query start end step timeout) - }, - PROMETHEUS_SERIES_API => { - method: %w(GET), - params: %w(match start end) - } - }.freeze - - def self.from_cache(proxyable_class_name, proxyable_id, method, path, params) - proxyable_class = begin - proxyable_class_name.constantize - rescue NameError - nil - end - return unless proxyable_class - - proxyable = proxyable_class.find(proxyable_id) - - new(proxyable, method, path, params) - end - - # proxyable can be any model which responds to .prometheus_adapter - # like Environment. - def initialize(proxyable, method, path, params) - @proxyable = proxyable - @path = path - - # Convert ActionController::Parameters to hash because reactive_cache_worker - # does not play nice with ActionController::Parameters. - @params = filter_params(params, path).to_hash - - @method = method - end - - def id - nil - end - - def execute - return cannot_proxy_response unless can_proxy? - return no_prometheus_response unless can_query? - - with_reactive_cache(*cache_key) do |result| - result - end - end - - def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params) - return no_prometheus_response unless can_query? - - response = prometheus_client_wrapper.proxy(path, params) - - success(http_status: response.code, body: response.body) - rescue Gitlab::PrometheusClient::Error => err - service_unavailable_response(err) - end - - def cache_key - [@proxyable.class.name, @proxyable.id, @method, @path, @params] - end - - private - - def service_unavailable_response(exception) - error(exception.message, :service_unavailable) - end - - def no_prometheus_response - error('No prometheus server found', :service_unavailable) - end - - def cannot_proxy_response - error('Proxy support for this API is not available currently') - end - - def prometheus_adapter - strong_memoize(:prometheus_adapter) do - @proxyable.prometheus_adapter - end - end - - def prometheus_client_wrapper - prometheus_adapter&.prometheus_client - end - - def can_query? - prometheus_adapter&.can_query? - end - - def filter_params(params, path) - params = substitute_params(params) - - params.slice(*PROXY_SUPPORT.dig(path, :params)) - end - - def can_proxy? - PROXY_SUPPORT.dig(@path, :method)&.include?(@method) - end - - def substitute_params(params) - start_time = params[:start_time] - end_time = params[:end_time] - - params['start'] = start_time if start_time - params['end'] = end_time if end_time - - params - end - end -end diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb deleted file mode 100644 index 846dfeb33ce..00000000000 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -module Prometheus - class ProxyVariableSubstitutionService < BaseService - include Stepable - - VARIABLE_INTERPOLATION_REGEX = / - {{ # Variable needs to be wrapped in these chars. - \s* # Allow whitespace before and after the variable name. - (?<variable> # Named capture. - \w+ # Match one or more word characters. - ) - \s* - }} - /x.freeze - - steps :validate_variables, - :add_params_to_result, - :substitute_params, - :substitute_variables - - # @param environment [Environment] - # @param params [Hash<Symbol,Any>] - # @param params - query [String] The Prometheus query string. - # @param params - start [String] (optional) A time string in the rfc3339 format. - # @param params - start_time [String] (optional) A time string in the rfc3339 format. - # @param params - end [String] (optional) A time string in the rfc3339 format. - # @param params - end_time [String] (optional) A time string in the rfc3339 format. - # @param params - variables [ActionController::Parameters] (optional) Variables with their values. - # The keys in the Hash should be the name of the variable. The value should be the value of the - # variable. Ex: `ActionController::Parameters.new(variable1: 'value 1', variable2: 'value 2').permit!` - # @return [Prometheus::ProxyVariableSubstitutionService] - # - # Example: - # Prometheus::ProxyVariableSubstitutionService.new(environment, { - # params: { - # start_time: '2020-07-03T06:08:36Z', - # end_time: '2020-07-03T14:08:52Z', - # query: 'up{instance="{{instance}}"}', - # variables: { instance: 'srv1' } - # } - # }) - def initialize(environment, params = {}) - @environment = environment - @params = params.deep_dup - end - - # @return - params [Hash<Symbol,Any>] Returns a Hash containing a params key which is - # similar to the `params` that is passed to the initialize method with 2 differences: - # 1. Variables in the query string are substituted with their values. - # If a variable present in the query string has no known value (values - # are obtained from the `variables` Hash in `params` or from - # `Gitlab::Prometheus::QueryVariables.call`), it will not be substituted. - # 2. `start` and `end` keys are added, with their values copied from `start_time` - # and `end_time`. - # - # Example output: - # - # { - # params: { - # start_time: '2020-07-03T06:08:36Z', - # start: '2020-07-03T06:08:36Z', - # end_time: '2020-07-03T14:08:52Z', - # end: '2020-07-03T14:08:52Z', - # query: 'up{instance="srv1"}', - # variables: { instance: 'srv1' } - # } - # } - def execute - execute_steps - end - - private - - def validate_variables(_result) - return success unless variables - - unless variables.is_a?(ActionController::Parameters) - return error(_('Optional parameter "variables" must be a Hash. Ex: variables[key1]=value1')) - end - - success - end - - def add_params_to_result(result) - result[:params] = params - - success(result) - end - - def substitute_params(result) - start_time = result[:params][:start_time] - end_time = result[:params][:end_time] - - result[:params][:start] = start_time if start_time - result[:params][:end] = end_time if end_time - - success(result) - end - - def substitute_variables(result) - return success(result) unless query(result) - - result[:params][:query] = gsub(query(result), full_context(result)) - - success(result) - end - - def gsub(string, context) - # Search for variables of the form `{{variable}}` in the string and replace - # them with their value. - string.gsub(VARIABLE_INTERPOLATION_REGEX) do |match| - # Replace with the value of the variable, or if there is no such variable, - # replace the invalid variable with itself. So, - # `up{instance="{{invalid_variable}}"}` will remain - # `up{instance="{{invalid_variable}}"}` after substitution. - context.fetch($~[:variable], match) - end - end - - def predefined_context(result) - Gitlab::Prometheus::QueryVariables.call( - @environment, - start_time: start_timestamp(result), - end_time: end_timestamp(result) - ).stringify_keys - end - - def full_context(result) - @full_context ||= predefined_context(result).reverse_merge(variables_hash) - end - - def variables - params[:variables] - end - - def variables_hash - variables.to_h - end - - def start_timestamp(result) - Time.rfc3339(result[:params][:start]) - rescue ArgumentError - end - - def end_timestamp(result) - Time.rfc3339(result[:params][:end]) - rescue ArgumentError - end - - def query(result) - result[:params][:query] - end - end -end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index d1798ce6fc0..b5f6bff756b 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -188,8 +188,7 @@ module QuickActions next unless definition definition.execute(self, arg) - # summarize_diff will be removed https://gitlab.com/gitlab-org/gitlab/-/issues/407258#note_1385269274 - usage_ping_tracking(definition.name, arg) unless definition.name == :summarize_diff + usage_ping_tracking(definition.name, arg) end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 433e9b0da6d..3d413ed9f7b 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -44,6 +44,10 @@ class SearchService project.blank? && group.blank? end + def search_type + 'basic' + end + def show_snippets? strong_memoize(:show_snippets) do params[:snippets] == 'true' diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb index b60a949fd4e..a205a68532b 100644 --- a/app/services/security/ci_configuration/base_create_service.rb +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -2,6 +2,8 @@ module Security module CiConfiguration + CiContentParseError = Class.new(StandardError) + class BaseCreateService attr_reader :branch_name, :current_user, :project, :name @@ -15,12 +17,13 @@ module Security if project.repository.empty? && !(@params && @params[:initialize_with_sast]) docs_link = ActionController::Base.helpers.link_to _('add at least one file to the repository'), Rails.application.routes.url_helpers.help_page_url('user/project/repository/index.md', - anchor: 'add-files-to-a-repository'), + anchor: 'add-files-to-a-repository'), target: '_blank', rel: 'noopener noreferrer' - raise Gitlab::Graphql::Errors::MutationError, - Gitlab::Utils::ErrorMessage.to_user_facing( - _(format('You must %s before using Security features.', docs_link.html_safe)).html_safe) + + return ServiceResponse.error( + message: _(format('You must %s before using Security features.', docs_link)).html_safe + ) end project.repository.add_branch(current_user, branch_name, project.default_branch) @@ -33,6 +36,10 @@ module Security track_event(attributes_for_commit) ServiceResponse.success(payload: { branch: branch_name, success_path: successful_change_path }) + rescue CiContentParseError => e + Gitlab::ErrorTracking.track_exception(e) + + ServiceResponse.error(message: e.message) rescue Gitlab::Git::PreReceiveError => e ServiceResponse.error(message: e.message) rescue StandardError @@ -58,13 +65,12 @@ module Security @gitlab_ci_yml ||= project.ci_config_for(root_ref) YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml rescue Psych::BadAlias - raise Gitlab::Graphql::Errors::MutationError, - Gitlab::Utils::ErrorMessage.to_user_facing( - _(".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually.")) + raise CiContentParseError, _(".gitlab-ci.yml with aliases/anchors is not supported. " \ + "Please change the CI configuration manually.") rescue Psych::Exception => e Gitlab::AppLogger.error("Failed to process existing .gitlab-ci.yml: #{e.message}") - raise Gitlab::Graphql::Errors::MutationError, - "#{name} merge request creation mutation failed" + + raise CiContentParseError, "#{name} merge request creation failed" end def successful_change_path diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 0527412e9bc..5c510990b2d 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -12,10 +12,9 @@ module Spam end def execute - return ServiceResponse.success(message: 'Skipped spam check because spam_params was not present') unless spam_params return ServiceResponse.success(message: 'Skipped spam check because user was not present') unless user - if target.supports_recaptcha? + if target.supports_recaptcha? && spam_params execute_with_captcha_support else execute_spam_check @@ -105,8 +104,8 @@ module Spam user_id: user.id, title: target.spam_title, description: target.spam_description, - source_ip: spam_params.ip_address, - user_agent: spam_params.user_agent, + source_ip: spam_params&.ip_address, + user_agent: spam_params&.user_agent, noteable_type: noteable_type, # Now, all requests are via the API, so hardcode it to true to simplify the logic and API # of this service. See https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/2266 @@ -127,9 +126,9 @@ module Spam } options = { - ip_address: spam_params.ip_address, - user_agent: spam_params.user_agent, - referer: spam_params.referer + ip_address: spam_params&.ip_address, + user_agent: spam_params&.user_agent, + referer: spam_params&.referer } SpamVerdictService.new(target: target, diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index e0a6d58b904..639d99ad906 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -85,6 +85,10 @@ module Spam # than the override verdict's priority value), then we don't need to override it. return false if SUPPORTED_VERDICTS[verdict][:priority] > SUPPORTED_VERDICTS[OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM][:priority] + allow_possible_spam? + end + + def allow_possible_spam? target.allow_possible_spam?(user) || user.allow_possible_spam? end @@ -101,3 +105,5 @@ module Spam end end end + +Spam::SpamVerdictService.prepend_mod diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb index 4e971246185..ed5c4df85b1 100644 --- a/app/services/todos/destroy/base_service.rb +++ b/app/services/todos/destroy/base_service.rb @@ -6,7 +6,10 @@ module Todos def execute return unless todos_to_remove? - without_authorized(todos).delete_all + ::Gitlab::Database.allow_cross_joins_across_databases(url: + 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') do + without_authorized(todos).delete_all + end end private diff --git a/app/services/todos/destroy/group_private_service.rb b/app/services/todos/destroy/group_private_service.rb index d7ecbb952aa..60599ca9ca4 100644 --- a/app/services/todos/destroy/group_private_service.rb +++ b/app/services/todos/destroy/group_private_service.rb @@ -24,7 +24,10 @@ module Todos override :authorized_users def authorized_users - group.direct_and_indirect_users.select(:id) + User.from_union([ + group.project_users_with_descendants.select(:id), + group.members_with_parents.select(:user_id) + ], remove_duplicates: false) end override :todos_to_remove? diff --git a/app/services/users/email_verification/base_service.rb b/app/services/users/email_verification/base_service.rb index 721290fe056..174626ac2f9 100644 --- a/app/services/users/email_verification/base_service.rb +++ b/app/services/users/email_verification/base_service.rb @@ -21,7 +21,7 @@ module Users end def digest - Devise.token_generator.digest(User, user.email, token) + Devise.token_generator.digest(User, user.email.downcase.strip, token) end end end diff --git a/app/services/users/email_verification/update_email_service.rb b/app/services/users/email_verification/update_email_service.rb new file mode 100644 index 00000000000..3f9b90b2960 --- /dev/null +++ b/app/services/users/email_verification/update_email_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class UpdateEmailService + include ActionView::Helpers::DateHelper + + RATE_LIMIT = :email_verification_code_send + + def initialize(user:) + @user = user + end + + def execute(email:) + return failure(:rate_limited) if rate_limited? + return failure(:already_offered) if already_offered? + return failure(:no_change) if no_change?(email) + return failure(:validation_error) unless update_email + + success + end + + private + + attr_reader :user + + def rate_limited? + Gitlab::ApplicationRateLimiter.throttled?(RATE_LIMIT, scope: user) + end + + def already_offered? + user.email_reset_offered_at.present? + end + + def no_change?(email) + user.email = email + !user.will_save_change_to_email? + end + + def update_email + user.skip_confirmation_notification! + user.save + end + + def success + { status: :success } + end + + def failure(reason) + { + status: :failure, + reason: reason, + message: failure_message(reason) + } + end + + def failure_message(reason) + case reason + when :rate_limited + interval = distance_of_time_in_words(Gitlab::ApplicationRateLimiter.rate_limits[RATE_LIMIT][:interval]) + format( + s_("IdentityVerification|You've reached the maximum amount of tries. Wait %{interval} and try again."), + interval: interval + ) + when :already_offered + s_('IdentityVerification|Email update is only offered once.') + when :no_change + s_('IdentityVerification|A code has already been sent to this email address. ' \ + 'Check your spam folder or enter another email address.') + when :validation_error + user.errors.full_messages.join(' ') + end + end + end + end +end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index b1ffd006795..197260a80ca 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -67,8 +67,10 @@ module Users def update_authorizations(remove = [], add = []) log_refresh_details(remove, add) - ProjectAuthorization.delete_all_in_batches_for_user(user: user, project_ids: remove) if remove.any? - ProjectAuthorization.insert_all_in_batches(add) if add.any? + ProjectAuthorizations::Changes.new do |changes| + changes.add(add) + changes.remove_projects_for_user(user, remove) + end.apply! # Since we batch insert authorization rows, Rails' associations may get # out of sync. As such we force a reload of the User object. diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index 36c41c03303..cc179ba964a 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -120,7 +120,7 @@ module Users def after_update(user_exists) notify_success(user_exists) - remove_followers_and_followee! if ::Feature.enabled?(:disable_follow_users, user) + remove_followers_and_followee! success end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 6837bc47035..5bad2a1583c 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -57,6 +57,11 @@ class WebHookService end def execute + if Gitlab::SilentMode.enabled? + log_silent_mode_enabled + return ServiceResponse.error(message: 'Silent mode enabled') + end + return ServiceResponse.error(message: 'Hook disabled') if disabled? if recursion_blocked? @@ -98,6 +103,7 @@ class WebHookService def async_execute Gitlab::ApplicationContext.with_context(hook.application_context) do + break log_silent_mode_enabled if Gitlab::SilentMode.enabled? break log_rate_limited if rate_limit! break log_recursion_blocked if recursion_blocked? @@ -237,6 +243,10 @@ class WebHookService ) end + def log_silent_mode_enabled + log_auth_error('GitLab is in silent mode') + end + def log_auth_error(message, params = {}) Gitlab::AuthLogger.error( params.merge( 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 new file mode 100644 index 00000000000..6a9ddd5c83d --- /dev/null +++ b/app/services/work_items/related_work_item_links/create_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module WorkItems + module RelatedWorkItemLinks + class CreateService < IssuableLinks::CreateService + extend ::Gitlab::Utils::Override + + def execute + return error(_('No matching work item found.'), 404) unless can?(current_user, :admin_work_item_link, issuable) + + response = super + + if response[:status] == :success + response[:message] = format( + _('Successfully linked ID(s): %{item_ids}.'), + item_ids: linked_ids(response[:created_references]).to_sentence + ) + end + + response + end + + def linkable_issuables(work_items) + @linkable_issuables ||= work_items.select { |work_item| can_link_item?(work_item) } + end + + def previous_related_issuables + @related_issues ||= issuable.related_issues(current_user).to_a + end + + private + + def link_class + WorkItems::RelatedWorkItemLink + end + + def can_link_item?(work_item) + return true if can?(current_user, :admin_work_item_link, work_item) + + @errors << format( + _("Item with ID: %{id} cannot be added. You don't have permission to perform this action."), + id: work_item.id + ) + + false + end + + def linked_ids(created_links) + created_links.collect(&:target_id) + end + + override :issuables_already_assigned_message + def issuables_already_assigned_message + _('Work items are already linked') + end + + override :issuables_not_found_message + def issuables_not_found_message + _('No matching work item found. Make sure you are adding a valid ID and you have access to the item.') + end + end + end +end + +WorkItems::RelatedWorkItemLinks::CreateService.prepend_mod_with('WorkItems::RelatedWorkItemLinks::CreateService') diff --git a/app/validators/import/gitlab_projects/remote_file_validator.rb b/app/validators/import/gitlab_projects/remote_file_validator.rb index 67bf102e928..c82e1e77a37 100644 --- a/app/validators/import/gitlab_projects/remote_file_validator.rb +++ b/app/validators/import/gitlab_projects/remote_file_validator.rb @@ -5,7 +5,6 @@ module Import # Validates the given object's #content_type and #content_length accordingly # with the Project Import requirements class RemoteFileValidator < ActiveModel::Validator - FILE_SIZE_LIMIT = 10.gigabytes ALLOWED_CONTENT_TYPES = [ 'application/gzip', # S3 uses different file types @@ -23,8 +22,8 @@ module Import def validate_content_length(record) if record.content_length.to_i <= 0 record.errors.add(:content_length, :size_too_small, file_size: humanize(1.byte)) - elsif record.content_length > FILE_SIZE_LIMIT - record.errors.add(:content_length, :size_too_big, file_size: humanize(FILE_SIZE_LIMIT)) + elsif file_size_limit > 0 && record.content_length > file_size_limit + record.errors.add(:content_length, :size_too_big, file_size: humanize(file_size_limit)) end end @@ -40,6 +39,10 @@ module Import allowed: ALLOWED_CONTENT_TYPES.join(', ') }) end + + def file_size_limit + Gitlab::CurrentSettings.current_application_settings.max_import_remote_file_size.megabytes + end end end end diff --git a/app/validators/json_schemas/application_setting_database_apdex_settings.json b/app/validators/json_schemas/application_setting_database_apdex_settings.json deleted file mode 100644 index 8b58dd44586..00000000000 --- a/app/validators/json_schemas/application_setting_database_apdex_settings.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "description": "Database Apdex Settings", - "type": "object", - "properties": { - "prometheus_api_url": { - "type": "string" - }, - "apdex_sli_query": { - "type": "object", - "properties": { - "main": { - "type": "string" - }, - "ci": { - "type": "string" - } - } - }, - "apdex_slo": { - "type": "object", - "properties": { - "main": { - "type": "number", - "format": "float" - }, - "ci": { - "type": "number", - "format": "float" - } - } - } - }, - "additionalProperties": false -} diff --git a/app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json b/app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json new file mode 100644 index 00000000000..9b7e34ed5ea --- /dev/null +++ b/app/validators/json_schemas/application_setting_prometheus_alert_db_indicators_settings.json @@ -0,0 +1,54 @@ +{ + "description": "Prometheus Alert Based Db indicators Settings", + "type": "object", + "properties": { + "prometheus_api_url": { + "type": "string" + }, + "apdex_sli_query": { + "type": "object", + "properties": { + "main": { + "type": "string" + }, + "ci": { + "type": "string" + } + } + }, + "apdex_slo": { + "type": "object", + "properties": { + "main": { + "type": "number" + }, + "ci": { + "type": "number" + } + } + }, + "wal_rate_sli_query": { + "type": "object", + "properties": { + "main": { + "type": "string" + }, + "ci": { + "type": "string" + } + } + }, + "wal_rate_slo": { + "type": "object", + "properties": { + "main": { + "type": "integer" + }, + "ci": { + "type": "integer" + } + } + } + }, + "additionalProperties": true +} diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json index 5dcd33a2cf0..ac34af3f107 100644 --- a/app/validators/json_schemas/build_metadata_secrets.json +++ b/app/validators/json_schemas/build_metadata_secrets.json @@ -24,9 +24,30 @@ }, "additionalProperties": false }, + "^azure_key_vault$": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "version": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, "^file$": { "type": "boolean" }, "^token$": { "type": "string" } }, + "anyOf": [ + { + "required": [ + "vault" + ] + }, + { + "required": [ + "azure_key_vault" + ] + } + ], "additionalProperties": false } } diff --git a/app/validators/json_schemas/catalog_resource_component_inputs.json b/app/validators/json_schemas/catalog_resource_component_inputs.json new file mode 100644 index 00000000000..014a52d4f1b --- /dev/null +++ b/app/validators/json_schemas/catalog_resource_component_inputs.json @@ -0,0 +1,24 @@ +{ + "description": "Catalog Resource Component Inputs", + "type": "object", + "patternProperties": { + ".*": { + "type": [ + "object", + "null" + ], + "patternProperties": { + "default": { + "type": [ + "string", + "integer", + "boolean" + ] + }, + "^type$": { + "type": "string" + } + } + } + } +} diff --git a/app/validators/json_schemas/default_branch_protection_defaults.json b/app/validators/json_schemas/default_branch_protection_defaults.json index d93527ad0a4..02491d2708b 100644 --- a/app/validators/json_schemas/default_branch_protection_defaults.json +++ b/app/validators/json_schemas/default_branch_protection_defaults.json @@ -27,7 +27,11 @@ "additionalProperties": false, "properties": { "access_level": { - "type": "integer" + "type": "integer", + "enum": [ + 30, + 40 + ] } } } @@ -52,7 +56,11 @@ "additionalProperties": false, "properties": { "access_level": { - "type": "integer" + "type": "integer", + "enum": [ + 30, + 40 + ] } } } 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 af67ed28309..d8d6af606ac 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -27,6 +27,17 @@ = 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.') diff --git a/app/views/admin/application_settings/_ai_access.html.haml b/app/views/admin/application_settings/_ai_access.html.haml deleted file mode 100644 index 97f46adef51..00000000000 --- a/app/views/admin/application_settings/_ai_access.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -- return if Gitlab.org_or_com? - -- expanded = integration_expanded?('ai_access') -- token_is_present = @application_setting.ai_access_token.present? -- token_label = token_is_present ? s_('CodeSuggestionsSM|Enter new personal access token') : s_('CodeSuggestionsSM|Personal access token') -- token_value = token_is_present ? ApplicationSettingMaskedAttrs::MASK : '' - -%section.settings.no-animate#js-ai-access-settings{ class: ('expanded' if expanded) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = s_('CodeSuggestionsSM|Code Suggestions') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p - = code_suggestions_description - - .settings-content - = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-ai-access-settings'), html: { class: 'fieldset-form', id: 'ai-access-settings' } do |f| - = form_errors(@application_setting) - - %fieldset - .form-group - = f.gitlab_ui_checkbox_component :instance_level_code_suggestions_enabled, - s_('CodeSuggestionsSM|Enable Code Suggestions for this instance %{beta}').html_safe % { beta: gl_badge_tag(_('Beta'), variant: :neutral, size: :sm) }, - help_text: code_suggestions_agreement - = f.label :ai_access_token, token_label, class: 'label-bold' - = f.password_field :ai_access_token, value: token_value, autocomplete: 'on', class: 'form-control gl-form-input', aria: { describedby: 'code_suggestions_token_explanation' } - %p.form-text.text-muted{ id: 'code_suggestions_token_explanation' } - = code_suggestions_token_explanation - - = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 0125c83dc72..c08270a8522 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -62,48 +62,48 @@ = f.submit _('Save changes'), pajamas_button: true -.settings-content - %h4 - = s_('AdminSettings|CI/CD limits') - %p - = s_('AdminSettings|By default, set a limit to 0 to have no limit.') - .scrolling-tabs-container.inner-page-scroll-tabs - - if @plans.size > 1 - %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.gl-mb-5 + .gl-mt-7 + %h4 + = s_('AdminSettings|CI/CD limits') + %p + = s_('AdminSettings|By default, set a limit to 0 to have no limit.') + .scrolling-tabs-container.inner-page-scroll-tabs + - if @plans.size > 1 + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.gl-mb-5 + - @plans.each_with_index do |plan, index| + %li + = link_to admin_plan_limits_path(anchor: 'js-ci-cd-settings'), data: { target: "div#plan#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do + = plan.name.capitalize + .tab-content.gl-tab-content - @plans.each_with_index do |plan, index| - %li - = link_to admin_plan_limits_path(anchor: 'js-ci-cd-settings'), data: { target: "div#plan#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do - = plan.name.capitalize - .tab-content.gl-tab-content - - @plans.each_with_index do |plan, index| - .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' } - = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f| - = form_errors(plan) - %fieldset - = f.hidden_field(:plan_id, value: plan.id) - .form-group - = f.label :ci_pipeline_size, plan_limit_setting_description(:ci_pipeline_size) - = f.number_field :ci_pipeline_size, class: 'form-control gl-form-input' - .form-group - = f.label :ci_active_jobs, plan_limit_setting_description(:ci_active_jobs) - = f.number_field :ci_active_jobs, class: 'form-control gl-form-input' - .form-group - = f.label :ci_project_subscriptions, plan_limit_setting_description(:ci_project_subscriptions) - = f.number_field :ci_project_subscriptions, class: 'form-control gl-form-input' - .form-group - = f.label :ci_pipeline_schedules, plan_limit_setting_description(:ci_pipeline_schedules) - = f.number_field :ci_pipeline_schedules, class: 'form-control gl-form-input' - .form-group - = f.label :ci_needs_size_limit, plan_limit_setting_description(:ci_needs_size_limit) - = f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input' - .form-text.text-muted= s_('AdminSettings|This limit cannot be disabled. Set to 0 to block all DAG dependencies.') - .form-group - = f.label :ci_registered_group_runners, plan_limit_setting_description(:ci_registered_group_runners) - = f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input' - .form-group - = f.label :ci_registered_project_runners, plan_limit_setting_description(:ci_registered_project_runners) - = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input' - .form-group - = f.label :pipeline_hierarchy_size, plan_limit_setting_description(:pipeline_hierarchy_size) - = f.number_field :pipeline_hierarchy_size, class: 'form-control gl-form-input' - = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true + .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' } + = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f| + = form_errors(plan) + %fieldset + = f.hidden_field(:plan_id, value: plan.id) + .form-group + = f.label :ci_pipeline_size, plan_limit_setting_description(:ci_pipeline_size) + = f.number_field :ci_pipeline_size, class: 'form-control gl-form-input' + .form-group + = f.label :ci_active_jobs, plan_limit_setting_description(:ci_active_jobs) + = f.number_field :ci_active_jobs, class: 'form-control gl-form-input' + .form-group + = f.label :ci_project_subscriptions, plan_limit_setting_description(:ci_project_subscriptions) + = f.number_field :ci_project_subscriptions, class: 'form-control gl-form-input' + .form-group + = f.label :ci_pipeline_schedules, plan_limit_setting_description(:ci_pipeline_schedules) + = f.number_field :ci_pipeline_schedules, class: 'form-control gl-form-input' + .form-group + = f.label :ci_needs_size_limit, plan_limit_setting_description(:ci_needs_size_limit) + = f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input' + .form-text.text-muted= s_('AdminSettings|This limit cannot be disabled. Set to 0 to block all DAG dependencies.') + .form-group + = f.label :ci_registered_group_runners, plan_limit_setting_description(:ci_registered_group_runners) + = f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input' + .form-group + = f.label :ci_registered_project_runners, plan_limit_setting_description(:ci_registered_project_runners) + = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input' + .form-group + = f.label :pipeline_hierarchy_size, plan_limit_setting_description(:pipeline_hierarchy_size) + = f.number_field :pipeline_hierarchy_size, class: 'form-control gl-form-input' + = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true diff --git a/app/views/admin/application_settings/_diagramsnet.html.haml b/app/views/admin/application_settings/_diagramsnet.html.haml index e493110a9dc..0cf44938881 100644 --- a/app/views/admin/application_settings/_diagramsnet.html.haml +++ b/app/views/admin/application_settings/_diagramsnet.html.haml @@ -5,7 +5,7 @@ = _('Diagrams.net') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Render diagrams in your documents using diagrams.net.') = link_to _('Learn more.'), help_page_path('administration/integration/diagrams_net.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 62c61ad356f..57846edde05 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -5,7 +5,7 @@ = _('Amazon EKS') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Amazon EKS integration allows you to provision EKS clusters from GitLab.') .settings-content diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml index b57371286d5..6754dd99bbc 100644 --- a/app/views/admin/application_settings/_error_tracking.html.haml +++ b/app/views/admin/application_settings/_error_tracking.html.haml @@ -6,7 +6,7 @@ = _('GitLab Error Tracking') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Allows projects to track errors using an Opstrace integration.').html_safe % { link: help_page_path('operations/error_tracking.md') } = link_to _('Learn more.'), help_page_path('operations/error_tracking.md'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 1b62083849b..463d6b24fdd 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -4,9 +4,9 @@ = s_('ExternalAuthorization|External authorization') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('ExternalAuthorization|External classification policy authorization.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/external_authorization'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/external_authorization'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f| diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml index cb8b2d3dfcd..e1576e84e66 100644 --- a/app/views/admin/application_settings/_floc.html.haml +++ b/app/views/admin/application_settings/_floc.html.haml @@ -6,8 +6,8 @@ = s_('FloC|Federated Learning of Cohorts (FLoC)') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p - - floc_link_url = help_page_path('user/admin_area/settings/floc.md') + %p.gl-text-secondary + - floc_link_url = help_page_path('administration/settings/floc.md') - floc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: floc_link_url } = html_escape(s_('FloC|Configure whether you want to participate in FLoC. %{floc_link_start}What is FLoC?%{floc_link_end}')) % { floc_link_start: floc_link_start, floc_link_end: '</a>'.html_safe } diff --git a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml index 4bd44b922fa..64549b97bd1 100644 --- a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml +++ b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml @@ -4,9 +4,9 @@ = s_('ShellOperations|Git SSH operations rate limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('ShellOperations|Limit the number of Git operations a user can perform per minute, per repository.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-gitlab-shell-operation-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index 09817a9172f..988153d45a4 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -6,11 +6,10 @@ = _('Gitpod') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + .gl-text-secondary #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') - .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f| = form_errors(@application_setting) @@ -23,7 +22,7 @@ = f.label :gitpod_url, s_('Gitpod|Gitpod URL'), class: 'label-bold' = f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|https://gitpod.example.com') .form-text.text-muted + - help_link = link_to('', help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings', target: '_blank', rel: 'noopener noreferrer')) = s_('Gitpod|The URL to your Gitpod instance configured to read your GitLab projects, such as https://gitpod.example.com.') - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings') } - = s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{link_start}How do I enable it?%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = safe_format(s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{help_link_start}How do I enable it?%{help_link_end}'), tag_pair(help_link, :help_link_start, :help_link_end)) = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_jira_connect.html.haml b/app/views/admin/application_settings/_jira_connect.html.haml index 235b6855123..23ad85334cb 100644 --- a/app/views/admin/application_settings/_jira_connect.html.haml +++ b/app/views/admin/application_settings/_jira_connect.html.haml @@ -6,7 +6,7 @@ = s_('JiraConnect|GitLab for Jira App') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('JiraConnect|Configure your Jira Connect Application ID.') = link_to sprite_icon('question-o'), help_page_path('integration/jira/connect-app', @@ -18,19 +18,13 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-jira-connect-application-id-settings'), html: { class: 'fieldset-form', id: 'jira-connect-application-id-settings' } do |f| = form_errors(@application_setting) - - %fieldset - .form-group - = f.label :jira_connect_application_key, s_('JiraConnect|Jira Connect Application ID'), class: 'label-bold' - = f.text_field :jira_connect_application_key, class: 'form-control gl-form-input' - - %fieldset - .form-group - = f.label :jira_connect_proxy_url, s_('JiraConnect|Jira Connect Proxy URL'), class: 'label-bold' - = f.text_field :jira_connect_proxy_url, class: 'form-control gl-form-input' - - %fieldset - .form-group - = f.gitlab_ui_checkbox_component :jira_connect_public_key_storage_enabled, s_('JiraConnect|Enable public key storage') + .gl-form-group + = f.label :jira_connect_application_key, s_('JiraConnect|Jira Connect Application ID'), class: 'label-bold' + = f.text_field :jira_connect_application_key, class: 'form-control gl-form-input' + .gl-form-group + = f.label :jira_connect_proxy_url, s_('JiraConnect|Jira Connect Proxy URL'), class: 'label-bold' + = f.text_field :jira_connect_proxy_url, class: 'form-control gl-form-input' + .gl-form-group + = f.gitlab_ui_checkbox_component :jira_connect_public_key_storage_enabled, s_('JiraConnect|Enable public key storage') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml index e1f5802a407..4dbca235a73 100644 --- a/app/views/admin/application_settings/_kroki.html.haml +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -5,7 +5,7 @@ = _('Kroki') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Users can render diagrams in AsciiDoc, Markdown, reStructuredText, and Textile documents using Kroki.') = link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index 19d321ca205..4002aa076f7 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -7,7 +7,7 @@ = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' .form-text.text-muted = _('Default first day of the week in calendars and date pickers.') - = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'change-the-default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :time_tracking, _('Time tracking'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml index fb15f6e79a5..a01303db789 100644 --- a/app/views/admin/application_settings/_mailgun.html.haml +++ b/app/views/admin/application_settings/_mailgun.html.haml @@ -5,7 +5,7 @@ = _('Mailgun') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') } .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f| diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index 66b04006beb..23251c8f5c9 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -5,52 +5,53 @@ = _('Package Registry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('PackageRegistry|Configure package forwarding and package file size limits.') - = render_if_exists 'admin/application_settings/ee_package_registry' - .settings-content - %h4 - = _('Package file size limits') - %p - = _('Set limit to 0 to allow any file size.') - .scrolling-tabs-container.inner-page-scroll-tabs - - if @plans.size > 1 - %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.mb-3 + = render_if_exists 'admin/application_settings/ee_package_registry' + + .gl-mt-7 + %h4 + = _('Package file size limits') + %p + = _('Set limit to 0 to allow any file size.') + .scrolling-tabs-container.inner-page-scroll-tabs + - if @plans.size > 1 + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.mb-3 + - @plans.each_with_index do |plan, index| + %li + = link_to admin_plan_limits_path(anchor: 'js-package-settings'), data: { target: "div#plan-package#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do + = plan.name.capitalize + .tab-content - @plans.each_with_index do |plan, index| - %li - = link_to admin_plan_limits_path(anchor: 'js-package-settings'), data: { target: "div#plan-package#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do - = plan.name.capitalize - .tab-content - - @plans.each_with_index do |plan, index| - .tab-pane{ :id => "plan-package#{index}", class: index == 0 ? 'active': '' } - = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f| - = form_errors(plan) - %fieldset - = f.hidden_field(:plan_id, value: plan.id) - .form-group - = f.label :conan_max_file_size, _('Maximum Conan package file size in bytes'), class: 'label-bold' - = f.number_field :conan_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :helm_max_file_size, _('Maximum Helm chart file size in bytes'), class: 'label-bold' - = f.number_field :helm_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold' - = f.number_field :maven_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :npm_max_file_size, _('Maximum npm package file size in bytes'), class: 'label-bold' - = f.number_field :npm_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :nuget_max_file_size, _('Maximum NuGet package file size in bytes'), class: 'label-bold' - = f.number_field :nuget_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :pypi_max_file_size, _('Maximum PyPI package file size in bytes'), class: 'label-bold' - = f.number_field :pypi_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :terraform_module_max_file_size, _('Maximum Terraform Module package file size in bytes'), class: 'label-bold' - = f.number_field :terraform_module_max_file_size, class: 'form-control gl-form-input' - .form-group - = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold' - = f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input' - = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true + .tab-pane{ :id => "plan-package#{index}", class: index == 0 ? 'active': '' } + = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f| + = form_errors(plan) + %fieldset + = f.hidden_field(:plan_id, value: plan.id) + .form-group + = f.label :conan_max_file_size, _('Maximum Conan package file size in bytes'), class: 'label-bold' + = f.number_field :conan_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :helm_max_file_size, _('Maximum Helm chart file size in bytes'), class: 'label-bold' + = f.number_field :helm_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold' + = f.number_field :maven_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :npm_max_file_size, _('Maximum npm package file size in bytes'), class: 'label-bold' + = f.number_field :npm_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :nuget_max_file_size, _('Maximum NuGet package file size in bytes'), class: 'label-bold' + = f.number_field :nuget_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :pypi_max_file_size, _('Maximum PyPI package file size in bytes'), class: 'label-bold' + = f.number_field :pypi_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :terraform_module_max_file_size, _('Maximum Terraform Module package file size in bytes'), class: 'label-bold' + = f.number_field :terraform_module_max_file_size, class: 'form-control gl-form-input' + .form-group + = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold' + = f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input' + = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 42f289d87b2..a8b758f7324 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -5,7 +5,7 @@ = _('PlantUML') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Render diagrams in your documents using PlantUML.') = link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/_projects_api_limits.html.haml b/app/views/admin/application_settings/_projects_api_limits.html.haml index 4efab4d77a9..dde8ab07958 100644 --- a/app/views/admin/application_settings/_projects_api_limits.html.haml +++ b/app/views/admin/application_settings/_projects_api_limits.html.haml @@ -4,9 +4,9 @@ = _('Projects API rate limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set the per-IP address rate limit applicable to unauthenticated requests for getting a list of projects via the API.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_projects_api.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_projects_api.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-projects-api-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) diff --git a/app/views/admin/application_settings/_slack.html.haml b/app/views/admin/application_settings/_slack.html.haml index e4f46fdf7f2..a4cd0a27baa 100644 --- a/app/views/admin/application_settings/_slack.html.haml +++ b/app/views/admin/application_settings/_slack.html.haml @@ -7,9 +7,9 @@ = s_('Integrations|GitLab for Slack app') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('SlackIntegration|Configure your GitLab for Slack app.') - = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app'), target: '_blank', rel: 'noopener noreferrer') + = link_to(_('Learn more.'), help_page_path('administration/settings/slack_app'), target: '_blank', rel: 'noopener noreferrer') .settings-content - unless gitlab_com @@ -27,7 +27,7 @@ - tag_pair_slack_apps = tag_pair(link_to('', 'https://api.slack.com/apps', target: '_blank', rel: 'noopener noreferrer'), :link_start, :link_end) - tag_pair_strong = tag_pair(tag.strong, :strong_open, :strong_close) = safe_format(s_('SlackIntegration|Copy the %{link_start}settings%{link_end} from %{strong_open}%{settings_heading}%{strong_close} in your GitLab for Slack app.'), tag_pair_slack_apps, tag_pair_strong, settings_heading: 'App Credentials') - = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'configure-the-settings'), target: '_blank', rel: 'noopener noreferrer') + = link_to(_('Learn more.'), help_page_path('administration/settings/slack_app', anchor: 'configure-the-settings'), target: '_blank', rel: 'noopener noreferrer') = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-slack-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) if expanded %fieldset @@ -59,7 +59,7 @@ = s_('SlackIntegration|Update your Slack app') %p = s_('SlackIntegration|When GitLab releases new features for the GitLab for Slack app, you might have to manually update your copy to use the new features.') - = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'update-the-gitlab-for-slack-app'), target: '_blank', rel: 'noopener noreferrer') + = link_to(_('Learn more.'), help_page_path('administration/settings/slack_app', anchor: 'update-the-gitlab-for-slack-app'), target: '_blank', rel: 'noopener noreferrer') %p = render Pajamas::ButtonComponent.new(href: slack_app_manifest_download_admin_application_settings_path, icon: 'download') do = s_("SlackIntegration|Download latest manifest file") diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 4e7d9b8ab21..6f9aad56ce8 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -5,9 +5,10 @@ = _('Snowplow') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('development/snowplow/index') } - = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank', rel: 'noopener noreferrer').html_safe, link_start: link_start, link_end: '</a>'.html_safe } + %p.gl-text-secondary + - help_link = link_to('', help_page_path('development/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 = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index b56ca12baec..61ec841bb83 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -7,7 +7,7 @@ = _('Sourcegraph') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://sourcegraph.com/' } - link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe = s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end } diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index ed809c6db52..4521f5bc0d9 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -5,7 +5,7 @@ = _('Customer experience improvement and third-party offers') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Control whether to display customer experience improvement content and third-party offers in GitLab.') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f| diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 91cd6fe7ca0..0455394444c 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -36,7 +36,7 @@ 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('user/admin_area/email_from_gitlab') + - 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 } diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index fb5c320268e..672af002e5e 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -3,146 +3,143 @@ = gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f| = form_errors(@appearance) - .row - .col-lg-4 - %h4.gl-mt-0= _('Navigation bar') - - .col-lg-8 - .form-group - = f.label :header_logo, _('Header logo'), class: 'col-form-label gl-pt-0' - %p - - if @appearance.header_logo? - = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: header_logos_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') } }) do - = _('Remove header logo') - %hr - = f.hidden_field :header_logo_cache - = f.file_field :header_logo, class: "", accept: 'image/*' - .form-text.text-muted - = _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo') - %hr - .row - .col-lg-4 - %h4.gl-mt-0 Favicon - - .col-lg-8 - .form-group - = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0' - %p - - if @appearance.favicon? - = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do - = _('Remove favicon') - %hr - = f.hidden_field :favicon_cache - = f.file_field :favicon, class: '', accept: 'image/*' - .form-text.text-muted - = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist } + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= _('Navigation bar') + + .form-group + = f.label :header_logo, _('Header logo'), class: 'col-form-label gl-pt-0' + %p + - if @appearance.header_logo? + = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: header_logos_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') } }) do + = _('Remove header logo') + %hr + = f.hidden_field :header_logo_cache + = f.file_field :header_logo, class: "", accept: 'image/*' + .form-text.text-muted + = _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo') + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 Favicon + + .form-group + = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0' + %p + - if @appearance.favicon? + = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? %br - = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") + = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do + = _('Remove favicon') + %hr + = f.hidden_field :favicon_cache + = f.file_field :favicon, class: '', accept: 'image/*' + .form-text.text-muted + = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist } + %br + = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") = render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f } - %hr - .row - .col-lg-4 - %h4.gl-mt-0= _('Sign in/Sign up pages') - - .col-lg-8 - .form-group - = f.label :title, class: 'col-form-label' - = f.text_field :title, class: "form-control gl-form-input" - .form-group - = f.label :description, class: 'col-form-label' - = f.text_area :description, class: "form-control gl-form-input", rows: 10 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= _('Sign in/Sign up pages') + + .form-group + = f.label :title, class: 'col-form-label' + = f.text_field :title, class: "form-control gl-form-input" + .form-group + = f.label :description, class: 'col-form-label' + = f.text_area :description, class: "form-control gl-form-input", rows: 10 + .form-text.text-muted + = parsed_with_gfm + .form-group + = f.label :logo, class: 'col-form-label gl-pt-0' + %p + - if @appearance.logo? + = image_tag @appearance.logo_path, class: 'appearance-logo-preview' + - if @appearance.persisted? + %br + = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do + = _('Remove logo') + %hr + = f.hidden_field :logo_cache + = f.file_field :logo, class: "", accept: 'image/*' + .form-text.text-muted + = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.') + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= _('Progressive Web App (PWA)') + + .form-group + = f.label _("Name"), class: 'col-form-label' + = f.text_field :pwa_name, class: "form-control gl-form-input" + .form-group + = f.label _("Short name"), class: 'col-form-label' + = f.text_field :pwa_short_name, class: "form-control gl-form-input" + .form-group + = f.label _("Description"), class: 'col-form-label' + = f.text_area :pwa_description, class: "form-control gl-form-input", rows: 10 + .form-text.text-muted + = parsed_with_gfm + .form-group + = f.label :pwa_icon, class: 'col-form-label gl-pt-0' + %p + - if @appearance.pwa_icon? + = image_tag @appearance.pwa_icon_path, class: 'appearance-pwa-icon-preview' + - if @appearance.persisted? + %br + = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: pwa_icon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') } }) do + = _('Remove icon') + %hr + = f.hidden_field :pwa_icon_cache + = f.file_field :pwa_icon, class: "", accept: 'image/*' + .form-text.text-muted + = _('Maximum file size is 1MB.') + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= _('New project pages') + + .form-group + = f.label :new_project_guidelines, class: 'col-form-label' + %p + = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10 .form-text.text-muted = parsed_with_gfm - .form-group - = f.label :logo, class: 'col-form-label gl-pt-0' - %p - - if @appearance.logo? - = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - - if @appearance.persisted? - %br - = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do - = _('Remove logo') - %hr - = f.hidden_field :logo_cache - = f.file_field :logo, class: "", accept: 'image/*' - .form-text.text-muted - = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.') - - %hr - .row - .col-lg-4 - %h4.gl-mt-0= _('Progressive Web App (PWA)') - - .col-lg-8 - .form-group - = f.label _("Name"), class: 'col-form-label' - = f.text_field :pwa_name, class: "form-control gl-form-input" - .form-group - = f.label _("Short name"), class: 'col-form-label' - = f.text_field :pwa_short_name, class: "form-control gl-form-input" - .form-group - = f.label _("Description"), class: 'col-form-label' - = f.text_area :pwa_description, class: "form-control gl-form-input", rows: 10 + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= _('Profile image guideline') + + .form-group + = f.label :profile_image_guidelines, class: 'col-form-label' + %p + = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10 .form-text.text-muted = parsed_with_gfm - .form-group - = f.label :pwa_icon, class: 'col-form-label gl-pt-0' - %p - - if @appearance.pwa_icon? - = image_tag @appearance.pwa_icon_path, class: 'appearance-pwa-icon-preview' - - if @appearance.persisted? - %br - = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: pwa_icon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') } }) do - = _('Remove icon') - %hr - = f.hidden_field :pwa_icon_cache - = f.file_field :pwa_icon, class: "", accept: 'image/*' - .form-text.text-muted - = _('Maximum file size is 1MB.') - - %hr - .row - .col-lg-4 - %h4.gl-mt-0= _('New project pages') - - .col-lg-8 - .form-group - = f.label :new_project_guidelines, class: 'col-form-label' - %p - = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10 - .form-text.text-muted - = parsed_with_gfm - - %hr - .row - .col-lg-4 - %h4.gl-mt-0= _('Profile image guideline') - - .col-lg-8 - .form-group - = f.label :profile_image_guidelines, class: 'col-form-label' - %p - = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10 - .form-text.text-muted - = parsed_with_gfm - - .gl-mt-3.gl-mb-3 - = f.submit _('Update appearance settings'), pajamas_button: true - - if @appearance.persisted? || @appearance.updated_at - .mt-4 - - if @appearance.persisted? - Preview last save: - = link_to _('Sign-in page'), preview_sign_in_admin_application_settings_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - = link_to _('New project page'), new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - - - if @appearance.updated_at - %span.float-right - Last edit #{time_ago_with_tooltip(@appearance.updated_at)} + + - if @appearance.persisted? || @appearance.updated_at + .settings-section + - if @appearance.persisted? + Preview last save: + = link_to _('Sign-in page'), preview_sign_in_admin_application_settings_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to _('New project page'), new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + + - if @appearance.updated_at + %span.float-right + Last edit #{time_ago_with_tooltip(@appearance.updated_at)} + + .settings-sticky-footer + = f.submit _('Update appearance settings'), pajamas_button: true diff --git a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml index 2ca037db532..61df5f5fd0d 100644 --- a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml +++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml @@ -1,30 +1,29 @@ - form = local_assigns.fetch(:form) -%hr -.row - .col-lg-4 - %h4.gl-mt-0 - = _('System header and footer') +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('System header and footer') - .col-lg-8 - .form-group - = form.label :header_message, _('Header message'), class: 'col-form-label label-bold' - = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize" - .form-group - = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold' - = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize" - .form-group - = form.gitlab_ui_checkbox_component :email_header_and_footer_enabled, - _('Enable header and footer in emails'), - help_text: _('Add header and footer to emails. Please note that color settings will only be applied within the application interface'), - label_options: { class: 'gl-font-weight-bold!' } + .form-group + = form.label :header_message, _('Header message'), class: 'col-form-label label-bold' + = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize" + .form-group + = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold' + = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize" + .form-group + = form.gitlab_ui_checkbox_component :email_header_and_footer_enabled, + _('Enable header and footer in emails'), + help_text: _('Add header and footer to emails. Please note that color settings will only be applied within the application interface'), + label_options: { class: 'gl-font-weight-bold!' } - .form-group.js-toggle-colors-container - = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-toggle-colors-link' }) do - = _('Customize colors') - .form-group.js-toggle-colors-container.hide - = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold' - = form.color_field :message_background_color, class: "form-control gl-form-input" - .form-group.js-toggle-colors-container.hide - = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold' - = form.color_field :message_font_color, class: "form-control gl-form-input" + .form-group.js-toggle-colors-container + = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-toggle-colors-link' }) do + = _('Customize colors') + .form-group.js-toggle-colors-container.hide + = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold' + = form.color_field :message_background_color, class: "form-control gl-form-input" + .form-group.js-toggle-colors-container.hide + = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold' + = form.color_field :message_font_color, class: "form-control gl-form-input" diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 9e8caf0e0b7..1124277d5b3 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -6,15 +6,6 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') -%p +%p.gl-text-secondary = _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.') = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'for-an-instance'), target: '_blank', rel: 'noopener noreferrer' -%p - = _('Variables can be:') -%ul - %li - = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' - %li - = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index a9a16f72ebe..addd23688b4 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -7,10 +7,20 @@ .settings-header = render 'admin/application_settings/ci/header', expanded: expanded_by_default? .settings-content + %p + = _('Variables can be:') + %ul + %li + = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' + %li + = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' + - if ci_variable_protected_by_default? - %p.settings-message.text-center - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') } - = s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + %p.settings-message.text-center.gl-mb-0 + - help_link = link_to('', help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable', target: '_blank', rel: 'noopener noreferrer')) + = safe_format(s_('Environment variables on this GitLab instance are configured to be %{help_link_start}protected%{help_link_end} by default.'), tag_pair(help_link, :help_link_start, :help_link_end)) #js-instance-variables{ data: { endpoint: admin_ci_variables_path, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} } %section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) } @@ -19,7 +29,7 @@ = _('Continuous Integration and Deployment') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts.') = render 'ci_cd' @@ -34,7 +44,7 @@ = _('Container Registry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Various container registry settings.') .settings-content = render 'registry' diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 2d56e9dd0dd..6ae9c58ffcd 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -9,7 +9,7 @@ = _('Visibility and access controls') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set visibility of project contents. Configure import sources and Git access protocols.') .settings-content = render 'visibility_and_access' @@ -20,20 +20,18 @@ = _('Account and limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set projects and maximum size limits, session duration, user options, and check feature availability for namespace plan.') .settings-content = render 'account_and_limit' -= render_if_exists 'admin/application_settings/free_user_cap' - %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 = _('Diff limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set size limits for displaying diffs in the browser.') .settings-content = render 'diff_limits' @@ -44,7 +42,7 @@ = _('Sign-up restrictions') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure the way a user creates a new account.') .settings-content = render 'signup' @@ -55,9 +53,9 @@ = _('Sign-in restrictions') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set sign-in restrictions for all users.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'signin' @@ -67,14 +65,15 @@ = _('Terms of Service and Privacy Policy') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Add a Terms of Service agreement and Privacy Policy for users of this GitLab instance.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/terms.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/terms.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terms' = render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default? += render_if_exists 'admin/application_settings/microsoft_application' = render_if_exists 'admin/application_settings/scim' %section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) } @@ -83,7 +82,7 @@ = _('Web terminal') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set the maximum session time for a web terminal.') = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals-deprecated'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 8bc5d5cbaa6..4739a204147 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -11,7 +11,7 @@ = _('Metrics - Prometheus') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Monitor GitLab with Prometheus.') .settings-content = render 'prometheus' @@ -22,7 +22,7 @@ = _('Metrics - Grafana') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Link to your Grafana instance.') = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer' @@ -35,7 +35,7 @@ = _('Profiling - Performance bar') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Enable access to the performance bar for non-administrators in a given group.') = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -47,7 +47,7 @@ = _('Usage statistics') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Enable or disable version check and Service Ping.') .settings-content = render 'usage' @@ -59,7 +59,7 @@ = _('Sentry') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure Sentry integration for error tracking') .settings-content = render 'sentry' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 3b9fb930fd7..9ccfc6cbc0a 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -9,7 +9,7 @@ = _('Performance optimization') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Various settings that affect GitLab performance.') .settings-content = render 'performance' @@ -20,9 +20,9 @@ = _('User and IP rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set limits for web and API requests.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/user_and_ip_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/user_and_ip_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'ip_limits' @@ -32,9 +32,9 @@ = _('Package registry rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set rate limits for package registry API requests that supersede the general user and IP rate limits.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' } @@ -44,7 +44,7 @@ = _('Files API Rate Limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.') .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' } @@ -55,7 +55,7 @@ = _('Search rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set rate limits for searches performed by web or API requests.') .settings-content = render 'search_limits' @@ -66,9 +66,9 @@ = _('Deprecated API rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure specific limits for deprecated API requests that supersede the general user and IP rate limits.') - = link_to _('Which API requests are affected?'), help_page_path('user/admin_area/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Which API requests are affected?'), help_page_path('administration/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-deprecated-limits-settings', setting_fragment: 'deprecated_api' } @@ -78,9 +78,9 @@ = _('Git LFS Rate Limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure specific limits for Git LFS requests that supersede the general user and IP rate limits.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/git_lfs_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/git_lfs_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'git_lfs_limits' @@ -94,7 +94,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('OutboundRequests|Allow requests to the local network from hooks and integrations.') = link_to _('Learn more.'), help_page_path('security/webhooks.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -106,9 +106,9 @@ = _('Protected paths') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Rate limit access to specified paths.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/protected_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/protected_paths.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'protected_paths' @@ -119,9 +119,9 @@ = _('Issues Rate Limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Limit the number of issues and epics per minute a user can create through web and API requests.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_issues_creation.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_issues_creation.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'issue_limits' @@ -131,9 +131,9 @@ = _('Notes rate limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set the per-user rate limit for notes created by web or API requests.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_notes_creation.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_notes_creation.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'note_limits' @@ -143,9 +143,9 @@ = _('Users API rate limit') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set the per-user rate limit for getting a user by ID via the API.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_users_api.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_users_api.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'users_api_limits' @@ -157,9 +157,9 @@ = _('Import and export rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set per-user rate limits for imports and exports of projects and groups.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/import_export_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/import_export_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'import_export_limits' @@ -169,9 +169,9 @@ = _('Pipeline creation rate limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Limit the number of pipeline creation requests per minute. This limit includes pipelines created through the UI, the API, and by background processing.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_pipelines_creation.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_pipelines_creation.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'pipeline_limits' diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index ab59e05c10f..bea399ee926 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -9,7 +9,7 @@ = _('Email') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Various email settings.') .settings-content = render 'email' @@ -20,7 +20,7 @@ = _("What's new") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("Configure %{italic_start}What's new%{italic_end} drawer and content.").html_safe % { italic_start: '<i>'.html_safe, italic_end: '</i>'.html_safe } .settings-content = render 'whats_new' @@ -31,9 +31,9 @@ = _('Sign-in and Help page') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Additional text for the sign-in and Help page.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'help_page' @@ -43,7 +43,7 @@ = _('Pages') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('AdminSettings|Size and domain settings for Pages static sites.') .settings-content = render 'pages' @@ -54,7 +54,7 @@ = _('Polling interval multiplier') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Adjust how frequently the GitLab UI polls for updates.') = link_to _('Learn more.'), help_page_path('administration/polling.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -66,10 +66,10 @@ = _('Gitaly timeouts') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure Gitaly timeouts.') %span - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/gitaly_timeouts.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/gitaly_timeouts.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'gitaly' @@ -79,7 +79,7 @@ = _('Localization') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure the default first day of the week, time tracking units, and default language.') .settings-content = render 'localization' @@ -90,10 +90,10 @@ = _('Sidekiq job size limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Limit the size of Sidekiq jobs stored in Redis.') %span - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sidekiq_job_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/sidekiq_job_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'sidekiq_job_limits' @@ -104,8 +104,8 @@ = s_('TerraformLimits|Terraform limits') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('TerraformLimits|Limits for Terraform features') - = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('user/admin_area/settings/terraform_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('administration/settings/terraform_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terraform_limits' diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index 6ea2fb80505..91fabb505c2 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -9,7 +9,7 @@ = _('Spam and Anti-bot Protection') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure CAPTCHAs, IP address limits, and other anti-spam measures.') .settings-content = render 'spam' @@ -23,9 +23,9 @@ = _('Abuse reports') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Receive notification of abuse reports by email.') - = link_to _('Learn more.'), help_page_path('user/admin_area/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'abuse' diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 1907544ea14..c7a2fca00ef 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -9,7 +9,7 @@ = _('Default branch') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('AdminSettings|Set the initial name and protections for the default branch of new repositories created in the instance.') .settings-content = render 'default_branch' @@ -20,7 +20,7 @@ = _('Repository mirroring') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? 'Collapse' : 'Expand' - %p + %p.gl-text-secondary = _('Configure repository mirroring.') = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -32,7 +32,7 @@ = _('Repository storage') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure repository storage.') = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -44,7 +44,7 @@ = _('Repository maintenance') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary - repository_checks_link_url = help_page_path('administration/repository_checks.md') - repository_checks_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_checks_link_url } - housekeeping_link_url = help_page_path('administration/housekeeping.md') @@ -59,7 +59,7 @@ = _('External storage for repository static objects') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Serve repository static objects (for example, archives and blobs) from external storage.') = link_to _('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml index 634d006e736..9f73099465c 100644 --- a/app/views/admin/application_settings/service_usage_data.html.haml +++ b/app/views/admin/application_settings/service_usage_data.html.haml @@ -23,9 +23,7 @@ title: _('Service Ping payload not found in the application cache')) do |c| - c.with_body do - - enable_service_ping_link_url = help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics') - - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url } - - generate_manually_link_url = help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping') - - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url } + - enable_service_ping_link = link_to('', help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics'), target: '_blank', rel: 'noopener noreferrer') + - generate_manually_link = link_to('', help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping'), target: '_blank', rel: 'noopener noreferrer') - = html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe } + = safe_format(s_('%{enable_service_ping_link_start}Enable%{enable_service_ping_link_end} or %{generate_manually_link_start}generate%{generate_manually_link_end} Service Ping to preview and download service usage data payload.'), tag_pair(enable_service_ping_link, :enable_service_ping_link_start, :enable_service_ping_link_end), tag_pair(generate_manually_link, :generate_manually_link_start, :generate_manually_link_end)) diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index e32a50e252d..6846fe8f4aa 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -1,52 +1,51 @@ - page_title s_('AdminArea|Instance OAuth applications') -%h1.page-title.gl-font-size-h-display - = s_('AdminArea|Instance OAuth applications') -%p.light - - docs_link_path = help_page_path('integration/oauth_provider') - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: docs_link_path } - = s_('AdminArea|Manage applications for your instance that can use GitLab as an %{docs_link_start}OAuth provider%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper.gl-flex-direction-column + %h3.gl-new-card-title + = s_('AdminArea|Instance OAuth applications') + .gl-new-card-count + = sprite_icon('applications', css_class: 'gl-mr-2') + = @applications.size + %p.gl-new-card-description + - docs_link_path = help_page_path('integration/oauth_provider') + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: docs_link_path } + = s_('AdminArea|Manage applications for your instance that can use GitLab as an %{docs_link_start}OAuth provider%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } -- if @applications.empty? - %section.empty-state.gl-text-center.gl-display-flex.gl-flex-direction-column - .svg-content.svg-150 - = image_tag 'illustrations/empty-state/empty-admin-apps-md.svg', class: 'gl-max-w-full' + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, href: new_admin_application_path, button_options: { data: { qa_selector: 'new_application_button' } }) do + = _('Add new application') + - c.with_body do + - if @applications.empty? + %section.empty-state.gl-my-5.gl-text-center.gl-display-flex.gl-flex-direction-column + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-admin-apps-md.svg', class: 'gl-max-w-full' - .gl-max-w-full.gl-m-auto - %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found') - = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do - = _('New application') - -- else - %hr - = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do - = _('New application') - - .table-responsive - %table.b-table.gl-table.gl-w-full{ role: 'table' } - %thead - %tr - %th - = _('Name') - %th - = _('Callback URL') - %th - = _('Trusted') - %th - = _('Confidential') - %th - %th - %tbody.oauth-applications - - @applications.each do |application| - %tr{ id: "application_#{application.id}" } - %td= link_to application.name, admin_application_path(application) - %td= application.redirect_uri - %td= application.trusted? ? _('Yes'): _('No') - %td= application.confidential? ? _('Yes'): _('No') - %td - = render Pajamas::ButtonComponent.new(href: edit_admin_application_path(application), variant: :link) do - = _('Edit') - %td= render 'delete_form', application: application + .gl-max-w-full.gl-m-auto + %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found') + %p.gl-text-secondary.gl-mt-3= s_('AdminArea|Manage applications for your instance that can use GitLab as an OAuth provider, start by creating a new one above.') + - else + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' } + %thead + %tr + %th= _('Name') + %th= _('Callback URL') + %th= _('Trusted') + %th= _('Confidential') + %th= _('Actions') + %tbody.oauth-applications + - @applications.each do |application| + %tr{ id: "application_#{application.id}" } + %td{ data: { label: _('Name') } }= link_to application.name, admin_application_path(application) + %td{ data: { label: _('Callback URL') } }= application.redirect_uri + %td{ data: { label: _('Trusted') } }= application.trusted? ? _('Yes'): _('No') + %td{ data: { label: _('Confidential') } }= application.confidential? ? _('Yes'): _('No') + %td{ data: { label: _('Actions') } } + = render Pajamas::ButtonComponent.new(href: edit_admin_application_path(application), size: :small, button_options: { class: 'gl-mr-3' }) do + = _('Edit') + = render 'delete_form', application: application = paginate @applications, theme: 'gitlab' diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml index a03d6cb5a94..3d73b255a5e 100644 --- a/app/views/admin/deploy_keys/new.html.haml +++ b/app/views/admin/deploy_keys/new.html.haml @@ -1,11 +1,9 @@ - page_title _('New Deploy Key') %h1.page-title.gl-font-size-h-display= _('New public deploy key') -%hr -%div - = gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| - = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } - .form-actions - = f.submit 'Create', data: { qa_selector: "add_deploy_key_button" }, pajamas_button: true - = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do - = _('Cancel') += gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| + = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } + .gl-display-flex.gl-mt-6.gl-gap-3 + = f.submit 'Create', data: { qa_selector: "add_deploy_key_button" }, pajamas_button: true + = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do + = _('Cancel') diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 662234bf56a..728c748d01a 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -12,7 +12,7 @@ = _("Reset health check access token") %p.light #{ _('Health information can be retrieved from the following endpoints. More information is available') } - = link_to s_('More information is available|here'), help_page_path('user/admin_area/monitoring/health_check') + = link_to s_('More information is available|here'), help_page_path('administration/monitoring/health_check') %ul %li %code= readiness_url(token: Gitlab::CurrentSettings.health_check_access_token) diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 0208b8ad836..37dd0a68f47 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -6,11 +6,24 @@ = render 'admin/users/head' -.row.gl-mt-3 - .col-lg-12 - #js-new-access-token-app{ data: { access_token_type: type } } +#js-new-access-token-app{ data: { access_token_type: type } } - = render 'shared/access_tokens/form', += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper.gl-flex-direction-column + %h3.gl-new-card-title + = _('Impersonation tokens') + .gl-new-card-count + = sprite_icon('token', css_class: 'gl-mr-2') + %span.js-token-count= @active_impersonation_tokens.size + .gl-new-card-description + = _("To see all the user's personal access tokens you must impersonate them first.") + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do + = _('Add new token') + - c.with_body do + .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form + = render 'shared/access_tokens/form', ajax: true, type: type, title: _('Add an impersonation token'), @@ -20,4 +33,4 @@ scopes: @scopes, help_path: help_page_path('api/rest/index', anchor: 'impersonation-tokens') - #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_impersonation_tokens.to_json, information: _("To see all the user's personal access tokens you must impersonate them first.") } } +#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_impersonation_tokens.to_json } } diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index e643ec040a1..3d392a86566 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,20 +1,23 @@ - page_title _("Labels") -.gl-sm-display-flex.gl-border-bottom-0.gl-mt-4.gl-lg-align-items-center - .gl-text-gray-600.gl-flex-grow-1 - = s_('AdminLabels|Labels created here will be automatically added to new projects.') - .nav-controls.gl-mt-2.gl-sm-mt-0.gl-display-flex.gl-align-items-center - = render Pajamas::ButtonComponent.new(variant: :confirm, - href: new_admin_label_path) do - = _('New label') - -.labels.labels-container.admin-labels.js-admin-labels-container.gl-mt-4 - .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10 - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b - %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24 += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card labels other-labels js-toggle-container js-admin-labels-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper.gl-flex-direction-column + %h5.gl-new-card-title = _('Labels') - %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base - - if @labels.present? + .gl-new-card-count + = sprite_icon('label', css_class: 'gl-mr-2') + %span.js-admin-labels-count= @labels.count + .gl-new-card-description + = s_('AdminLabels|Labels created here will be automatically added to new projects.') + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(variant: :default, + size: :small, + href: new_admin_label_path) do + = _('New label') + - c.with_body do + - if @labels.present? + %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base = render @labels .js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) } %section.row.empty-state.gl-text-center @@ -25,10 +28,7 @@ .gl-mx-auto.gl-my-0.gl-p-5 %h1.gl-font-size-h-display.gl-line-height-36.h4 = s_('AdminLabels|Define your default set of project labels') - %p + %p.gl-text-secondary = s_('AdminLabels|They can be used to categorize issues and merge requests.') - .gl-display-flex.gl-flex-wrap.gl-justify-content-center - = render Pajamas::ButtonComponent.new(href: new_admin_label_path) do - = _('New label') - = paginate @labels, theme: 'gitlab' +.gl-mt-5= paginate @labels, theme: 'gitlab' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 0637b0eae47..85dce00752b 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -81,14 +81,20 @@ - if @project.repository.exists? %li{ class: 'gl-px-5!' } %span.light - = _('Gitaly storage name:') + = s_('ProjectSettings|Storage name:') %strong = @project.repository.storage + %br + %small.gl-text-secondary + = s_('ProjectSettings|For Gitaly, name of the storage that stores the repository. For Gitaly Cluster, name of the virtual storage that stores the repository.') %li{ class: 'gl-px-5!' } %span.light - = _('Gitaly relative path:') + = s_('ProjectSettings|Relative path:') %strong = @project.repository.relative_path + %br + %small.gl-text-secondary + = s_('ProjectSettings|For Gitaly, location of data on the storage. For Gitaly Cluster, location of data on the virtual storage.') %li{ class: 'gl-px-5!' } = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics @@ -125,8 +131,10 @@ %span.light = _('access:') %strong - %span{ class: visibility_level_color(@project.visibility_level) } - = visibility_level_icon(@project.visibility_level) + = visibility_level_content(@project, css_class: visibility_level_color(@project.visibility_level)) + - if @project.created_and_owned_by_banned_user? && Feature.enabled?(:hide_projects_of_banned_users) + = _('This project is hidden because its creator has been banned') + - else = visibility_level_label(@project.visibility_level) = render 'shared/custom_attributes', custom_attributes: @project.custom_attributes diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 472ba2f84a0..4979f7e28e7 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,49 +1,49 @@ -.gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = s_('AdminUsers|Access') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :projects_limit, class: 'gl-display-block col-form-label' - = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input' - - .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 :private_profile, s_('AdminUsers|Private profile') - - %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label - = s_('AdminUsers|Access level') - - editing_current_user = (current_user == @user) - - = f.gitlab_ui_radio_component :access_level, :regular, - s_('AdminUsers|Regular'), - radio_options: { disabled: editing_current_user }, - help_text: s_('AdminUsers|Regular users have access to their groups and projects.') - - = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user - - - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') - - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user - = f.gitlab_ui_radio_component :access_level, :admin, - s_('AdminUsers|Administrator'), - radio_options: { disabled: editing_current_user }, - help_text: help_text - - .form-group.gl-form-group{ role: 'group' } - = f.gitlab_ui_checkbox_component :external, - s_('AdminUsers|External'), - help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') - .hidden{ data: user_internal_regex_data } - .gl-display-flex.gl-align-items-baseline - %row.hidden#warning_external_automatically_set - = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning - - .form-group.gl-form-group{ role: 'group' } - - @user.credit_card_validation || @user.build_credit_card_validation - = f.fields_for :credit_card_validation do |ff| - = ff.gitlab_ui_checkbox_component :credit_card_validated_at, - s_('AdminUsers|Validate user account'), - help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), - checkbox_options: { checked: @user.credit_card_validated_at.present? } + + .form-group.gl-form-group{ role: 'group' } + = f.label :projects_limit, class: 'gl-display-block col-form-label' + = 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 :private_profile, s_('AdminUsers|Private profile') + + %fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_('AdminUsers|Access level') + - editing_current_user = (current_user == @user) + + = f.gitlab_ui_radio_component :access_level, :regular, + s_('AdminUsers|Regular'), + radio_options: { disabled: editing_current_user }, + help_text: s_('AdminUsers|Regular users have access to their groups and projects.') + + = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user + + - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') + - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user + = f.gitlab_ui_radio_component :access_level, :admin, + s_('AdminUsers|Administrator'), + radio_options: { disabled: editing_current_user }, + help_text: help_text + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :external, + s_('AdminUsers|External'), + help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') + .hidden{ data: user_internal_regex_data } + .gl-display-flex.gl-align-items-baseline + %row.hidden#warning_external_automatically_set + = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning + + .form-group.gl-form-group{ role: 'group' } + - @user.credit_card_validation || @user.build_credit_card_validation + = f.fields_for :credit_card_validation do |ff| + = ff.gitlab_ui_checkbox_component :credit_card_validated_at, + s_('AdminUsers|Validate user account'), + help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), + checkbox_options: { checked: @user.credit_card_validated_at.present? } diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml index dce008afb26..85796246c83 100644 --- a/app/views/admin/users/_admin_notes.html.haml +++ b/app/views/admin/users/_admin_notes.html.haml @@ -1,9 +1,9 @@ -.gl-mb-3 - .row - .col-lg-4 - %h4.gl-mt-0 +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Admin notes') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :note, s_('Admin|Note') - = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' + + .form-group.gl-form-group{ role: 'group' } + = f.label :note, s_('Admin|Note') + = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 8822d52c3c0..ffe7e128d60 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -2,42 +2,42 @@ = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| = form_errors(@user) - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Account') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :name, _('Name'), class: 'gl-display-block col-form-label' - = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.label :username, _('Username'), class: 'gl-display-block col-form-label' - = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.label :email, _('Email'), class: 'gl-display-block col-form-label' - = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 + + .form-group.gl-form-group{ role: 'group' } + = f.label :name, _('Name'), class: 'gl-display-block col-form-label' + = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg' + + .form-group.gl-form-group{ role: 'group' } + = f.label :username, _('Username'), class: 'gl-display-block col-form-label' + = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input gl-form-input-lg' + + .form-group.gl-form-group{ role: 'group' } + = f.label :email, _('Email'), class: 'gl-display-block col-form-label' + = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg' + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Password') - .col-lg-8 - - if @user.new_record? - = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - - c.with_body do - = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') - - else - .form-group.gl-form-group{ role: 'group' } - = f.label :password, _('Password'), class: 'gl-display-block col-form-label' - = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation' - = render_if_exists 'shared/password_requirements_list' - .form-group.gl-form-group{ role: 'group' } - = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' - = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' + + - if @user.new_record? + = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') + - else + .form-group.gl-form-group{ role: 'group' } + = f.label :password, _('Password'), class: 'gl-display-block col-form-label' + = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-form-input-lg' + = render_if_exists 'shared/password_requirements_list' + .form-group.gl-form-group{ role: 'group' } + = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' + = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input gl-form-input-lg' = render partial: 'access_levels', locals: { f: f } @@ -45,42 +45,42 @@ = render_if_exists 'admin/users/limits', f: f - .gl-border-b.gl-pb-6.gl-mb-6 - .row - .col-lg-4 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner %h4.gl-mt-0 = _('Profile') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label' - = f.file_field :avatar - .form-group.gl-form-group{ role: 'group' } - = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' - = f.text_field :skype, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label gl-form-input-lg' + = f.file_field :avatar + + .form-group.gl-form-group{ role: 'group' } + = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' + = f.text_field :skype, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' - = f.text_field :linkedin, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' + = f.text_field :linkedin, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' - = f.text_field :twitter, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' + = f.text_field :twitter, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' - = f.text_field :website_url, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' + = f.text_field :website_url, class: 'form-control gl-form-input gl-form-input-lg' = render_if_exists 'admin/users/custom_attributes', f: f = render 'admin/users/admin_notes', f: f - %div + .settings-sticky-footer - if @user.new_record? - = f.submit _('Create user'), pajamas_button: true + = f.submit _('Create user'), pajamas_button: true, class: 'gl-mr-3' = render Pajamas::ButtonComponent.new(href: admin_users_path) do = _('Cancel') - else - = f.submit _('Save changes'), pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-3' = render Pajamas::ButtonComponent.new(href: admin_user_path(@user)) do = _('Cancel') diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index dfcf8f39533..886edbd0687 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -6,5 +6,5 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') -%p +%p.gl-text-secondary = render "ci/variables/content", entity: @entity, variable_limit: @variable_limit diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 5eed4e92386..65f9e6c2342 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -23,16 +23,10 @@ maskable_raw_regex: ci_variable_maskable_raw_regex, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s, - aws_logo_svg_path: image_path('aws_logo.svg'), - aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-ecs'), - aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'use-an-image-to-run-aws-commands'), - aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md'), contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'prevent-cicd-variable-expansion'), masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), environment_scope_link: help_page_path('ci/environments/index', anchor: 'limit-the-environment-scope-of-a-cicd-variable') } } - if !@group && @project.group - .settings-header.border-top.gl-mt-6 - = render 'ci/group_variables/header' - .settings-content.pr-0 - = render 'ci/group_variables/index' + = render 'ci/group_variables/header' + = render 'ci/group_variables/index' diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index 88da252f2bb..49b9c4c9ca6 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -1,11 +1,8 @@ - logo_path = local_assigns.fetch(:logo_path) - help_path = local_assigns.fetch(:help_path) - label = local_assigns.fetch(:label) -- last = local_assigns.fetch(:last, false) -- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-third "] -- conditional_classes = [("gl-mr-5" unless last)] -= link_to help_path, class: classes + conditional_classes do += render Pajamas::ButtonComponent.new(variant: :confirm, category: :secondary, href: help_path, button_options: { class: "gl-flex-direction-column gl-flex-basis-third" }) do %span.gl-display-flex.gl-align-items-center.gl-m-3.gl-h-64 = image_tag logo_path, alt: label, class: "gl-w-15 gl-max-h-full gl-max-w-full" %span.gl-white-space-normal diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index 7039ce57bd9..49dab193da8 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -9,7 +9,7 @@ .gl-py-5.gl-md-pl-5.gl-md-pr-5 %h4.gl-mb-5 = create_cluster_label - .gl-display-flex + .gl-display-flex.gl-gap-5 = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', locals: { label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg', help_path: eks_help_path } = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index ed169b2bfd1..4ecef4b76ce 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -47,7 +47,7 @@ .form-group .form-check - = platform_kubernetes_field.check_box :authorization_type, { data: { qa_selector: 'rbac_checkbox'}, inline: true, class: 'form-check-input' }, 'rbac', 'abac' + = platform_kubernetes_field.check_box :authorization_type, { inline: true, class: 'form-check-input' }, 'rbac', 'abac' = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' %small.form-text.text-muted = '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } @@ -73,4 +73,4 @@ = render('clusters/clusters/namespace', platform_field: platform_kubernetes_field) .form-group - = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_kubernetes_cluster_button' } + = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), pajamas_button: true diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 62ca4a3bab6..2737dede0e9 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -4,7 +4,7 @@ .page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5 = link_to _("Explore groups"), explore_groups_path - if current_user.can_create_group? - = render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { qa_selector: "new_group_button", testid: "new-group-button" } }) do + = render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { testid: "new-group-button" } }) do = _("New group") .top-area.gl-py-3.gl-justify-content-end.gl-border-bottom-0 diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml index dafa3b4dc8d..f49960695f6 100644 --- a/app/views/dashboard/projects/_starred_empty_state.html.haml +++ b/app/views/dashboard/projects/_starred_empty_state.html.haml @@ -1,9 +1,5 @@ -.row.empty-state - .col-12 - .svg-content.svg-150 - = image_tag 'illustrations/empty-state/empty-projects-starred-md.svg' - .text-content - %h4.gl-text-center - = s_("StarredProjectsEmptyState|You don't have starred projects yet.") - %p.gl-text-gray-500 - = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.") += render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/empty-state/empty-projects-starred-md.svg', + title: s_("StarredProjectsEmptyState|You don't have starred projects yet.")) do |c| + + - c.with_description do + = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.") diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index e20fccc218a..1cd8015934e 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -48,6 +48,7 @@ = first_line_in_markdown(todo, :body, 125, is_todo: true, project: todo.project, group: todo.group) = render_if_exists "dashboard/todos/diff_summary", local_assigns: { todo: todo } + = render_if_exists "dashboard/todos/review_summary", local_assigns: { todo: todo } .todo-timestamp.gl-white-space-nowrap.gl-sm-ml-3.gl-mt-2.gl-mb-2.gl-sm-my-0.gl-px-2.gl-sm-px-0 %span.todo-timestamp.gl-font-sm.gl-text-secondary diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index c22eeba2f01..1760e6e0f84 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -8,6 +8,8 @@ = render "layouts/bizible" = render "layouts/google_tag_manager_body" += render_if_exists 'devise/shared/delete_unconfirmed_users_flash' + .well-confirmation.gl-text-center.gl-mb-6 %h1.gl-mt-0 = _("Almost there...") diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml index c1655818770..ff5027b8464 100644 --- a/app/views/devise/mailer/_confirmation_instructions_account.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml @@ -1,9 +1,11 @@ - confirmation_link = confirmation_url(@resource, confirmation_token: @token) + - if @resource.unconfirmed_email.present? || !@resource.created_recently? #content = email_default_heading(@email) %p= _('Click the link below to confirm your email address.') #cta + = render_if_exists 'devise/shared/delete_unconfirmed_users' = link_to _('Confirm your email address'), confirmation_link - else #content @@ -13,4 +15,5 @@ = email_default_heading(_("Welcome, %{name}!") % { name: @resource.name }) %p= _("To get started, click the link below to confirm your account.") #cta + = render_if_exists 'devise/shared/delete_unconfirmed_users' = link_to _('Confirm your account'), confirmation_link diff --git a/app/views/devise/mailer/_confirmation_instructions_account.text.erb b/app/views/devise/mailer/_confirmation_instructions_account.text.erb index 7e4f38885f6..7436da66e63 100644 --- a/app/views/devise/mailer/_confirmation_instructions_account.text.erb +++ b/app/views/devise/mailer/_confirmation_instructions_account.text.erb @@ -10,4 +10,6 @@ <%= _('To get started, use the link below to confirm your account.') %> <% end %> +<%= render_if_exists 'devise/shared/delete_unconfirmed_users_text' %> + <%= confirmation_url(@resource, confirmation_token: @token) %> diff --git a/app/views/devise/mailer/email_changed_gitlab_com.html.haml b/app/views/devise/mailer/email_changed_gitlab_com.html.haml new file mode 100644 index 00000000000..de91418860d --- /dev/null +++ b/app/views/devise/mailer/email_changed_gitlab_com.html.haml @@ -0,0 +1,11 @@ += email_default_heading("Hello, #{@resource.name}!") + +- if @resource.try(:unconfirmed_email?) + %p + We're contacting you to notify you that your email is being changed to #{@resource.reset.unconfirmed_email}. +- else + %p + We're contacting you to notify you that your email has been changed to #{@resource.email}. + +%p + If you did not initiate this change, please contact your group owner immediately. If you have a Premium or Ultimate tier subscription, you can also contact GitLab support. diff --git a/app/views/devise/mailer/email_changed_gitlab_com.text.erb b/app/views/devise/mailer/email_changed_gitlab_com.text.erb new file mode 100644 index 00000000000..c978d666180 --- /dev/null +++ b/app/views/devise/mailer/email_changed_gitlab_com.text.erb @@ -0,0 +1,9 @@ +Hello, <%= @resource.name %>! + +<% if @resource.try(:unconfirmed_email?) %> +We're contacting you to notify you that your email is being changed to <%= @resource.reset.unconfirmed_email %>. +<% else %> +We're contacting you to notify you that your email has been changed to <%= @resource.email %>. +<% end %> + +If you did not initiate this change, please contact your group owner immediately. If you have a Premium or Ultimate tier subscription, you can also contact GitLab support. diff --git a/app/views/devise/sessions/_broadcast.html.haml b/app/views/devise/sessions/_broadcast.html.haml new file mode 100644 index 00000000000..96e2e2d776d --- /dev/null +++ b/app/views/devise/sessions/_broadcast.html.haml @@ -0,0 +1 @@ += render "layouts/broadcast" diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 4825f192d4d..345a1cc0225 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -21,7 +21,8 @@ = recaptcha_tags nonce: content_security_policy_nonce - if remember_me_enabled? - = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } + .form-group + = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do = _('Sign in') diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml index e0b5a266961..085204fb6bf 100644 --- a/app/views/devise/sessions/email_verification.haml +++ b/app/views/devise/sessions/email_verification.haml @@ -2,18 +2,7 @@ = render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account') .login-box.gl-p-5 .login-body - = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f| - %p - = s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "<strong>#{sanitize(obfuscated_email(resource.email))}</strong>".html_safe } - %div - = f.label :verification_token, s_('IdentityVerification|Verification code') - = f.text_field :verification_token, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: s_('IdentityVerification|Please enter a valid code'), inputmode: 'numeric', maxlength: 6, pattern: '[0-9]{6}' - %p.gl-field-error.gl-mt-2 - = resource.errors.full_messages.to_sentence - .gl-mt-5 - = f.submit s_('IdentityVerification|Verify code'), class: 'gl-w-full', pajamas_button: true - - unless send_rate_limited?(resource) - = link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0' + .js-email-verification{ data: verification_data(resource) } %p.gl-p-5.gl-text-secondary - support_link_start = '<a href="https://about.gitlab.com/support/" target="_blank" rel="noopener noreferrer">'.html_safe = s_("IdentityVerification|If you've lost access to the email associated to this account or having trouble with the code, %{link_start}here are some other steps you can take.%{link_end}").html_safe % { link_start: support_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index d5f15a72c34..0fd27f7f7e7 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -5,8 +5,7 @@ = render "layouts/one_trust" - content_for :sessions_broadcast do - - unless Gitlab.com? - = render "layouts/broadcast" + = render "devise/sessions/broadcast" = render "layouts/google_tag_manager_body" diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index 60c37316c62..e8c82e456ae 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -4,7 +4,7 @@ = _("Register with:") .gl-text-center.gl-ml-auto.gl-mr-auto - providers.each do |provider| - = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do + = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text @@ -14,7 +14,7 @@ = _("Create an account using:") .gl-display-flex.gl-justify-content-between.gl-flex-wrap - providers.each do |provider| - = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do + = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml index 5ec3c7a4150..399c23741a9 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -1,4 +1,6 @@ - if Feature.disabled?(:restyle_login_page, @project) .omniauth-divider.gl-display-flex.gl-align-items-center = _("or") -= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers, tracking_label: "free_registration" += render 'devise/shared/signup_omniauth_provider_list', + providers: enabled_button_based_providers, + tracking_label: ::Onboarding::Status.tracking_label[:free] diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index cd0c9a016a5..db122fe82b1 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ - diff_data = {} - expanded = discussion.expanded? || local_assigns.fetch(:expanded, nil) - unless expanded - - diff_data = { lines_path: project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) } + - diff_data = { lines_path: project_discussion_path(discussion.project, discussion.noteable_collection_name, discussion.noteable, discussion) } .diff-file.file-holder.js-lazy-load-discussion{ class: diff_file_class, data: diff_data } .js-file-title.file-title.file-title-flex-parent @@ -29,7 +29,8 @@ %td.line_content.js-success-lazy-load .js-code-placeholder %td.js-error-lazy-load-diff.hidden.diff-loading-error-block - - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button") + - button = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button' }) do + = _("Try again") = _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button} = render "discussions/diff_discussion", discussions: [discussion], expanded: true - else diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index bc69da5775f..fd5088e04b0 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -38,7 +38,8 @@ = hidden_field_tag :nonce, @pre_auth.nonce = hidden_field_tag :code_challenge, @pre_auth.code_challenge = hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method - = submit_tag _("Deny"), class: "btn btn-default gl-button" + = render Pajamas::ButtonComponent.new(type: :submit) do + = _("Deny") = form_tag oauth_authorization_path, method: :post, class: 'inline' do = hidden_field_tag :client_id, @pre_auth.client.uid = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri @@ -48,4 +49,7 @@ = hidden_field_tag :nonce, @pre_auth.nonce = hidden_field_tag :code_challenge, @pre_auth.code_challenge = hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method - = submit_tag _("Authorize"), class: "btn btn-danger gl-button gl-ml-3", data: { qa_selector: 'authorization_button' } + = render Pajamas::ButtonComponent.new(type: :submit, + variant: :danger, + button_options: { id: 'commit-changes', class: 'gl-ml-3', qa_selector: 'authorization_button'}) do + = _("Authorize") diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index fac0fd3d2a4..ca7798257cb 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -8,7 +8,7 @@ .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' } = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') %div - %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex{ itemprop: 'name' } + %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ itemprop: 'name' } = @group.name %span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@group) } = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) 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 9fbb7f3c9ed..8c2434ca4a0 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 @@ -37,7 +37,7 @@ required: true, title: s_('GroupsNew|Enter the URL for the source instance.'), id: 'import_gitlab_url', - data: { qa_selector: 'import_gitlab_url' } + data: { testid: 'import-gitlab-url' } .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 @@ -50,6 +50,6 @@ autocomplete: 'off', title: s_('GroupsNew|Please fill in your personal access token.'), id: 'import_gitlab_token', - data: { qa_selector: 'import_gitlab_token' } + data: { testid: 'import-gitlab-token' } .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 - = f.submit s_('GroupsNew|Connect instance'), disabled: bulk_imports_disabled, pajamas_button: true, data: { qa_selector: 'connect_instance_button' } + = f.submit s_('GroupsNew|Connect instance'), disabled: bulk_imports_disabled, pajamas_button: true, data: { testid: 'connect-instance-button' } diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml index 91f7b574dbf..e3d54e52aab 100644 --- a/app/views/groups/_import_group_from_file_panel.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -20,6 +20,6 @@ - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/import/index') } = s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe } .gl-mt-3 - = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-confirm-secondary gl-mr-2' + = render 'shared/file_picker_button', f: f, field: :file, help_text: nil .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 = f.submit _('Import'), pajamas_button: true diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index ddf6e52796f..49cc6e66ab8 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -30,6 +30,6 @@ = recaptcha_tags nonce: content_security_policy_nonce .row .col-sm-12 - = f.submit submit_label, pajamas_button: true, data: { qa_selector: 'create_group_button' } + = f.submit submit_label, pajamas_button: true, data: { testid: 'create-group-button' } = render Pajamas::ButtonComponent.new(href: @parent_group || dashboard_groups_path) do = _('Cancel') diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index dedff502a87..c11154cbd75 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -11,7 +11,7 @@ = _('Naming, visibility') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = _('Collapse') - %p + %p.gl-text-secondary = _('Update your group name, description, avatar, and visibility.') = link_to _('Learn more about groups.'), help_page_path('user/group/index') .settings-content @@ -23,7 +23,7 @@ = _('Permissions and group features') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Configure advanced permissions, Large File Storage, two-factor authentication, and customer relations settings.') .settings-content = render 'groups/settings/permissions' @@ -38,7 +38,7 @@ = s_('GroupSettings|Badges') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('GroupSettings|Customize this group\'s badges.') = link_to s_('GroupSettings|What are badges?'), help_page_path('user/project/badges') .settings-content @@ -47,6 +47,7 @@ = render_if_exists 'groups/compliance_frameworks', expanded: expanded = render_if_exists 'groups/custom_project_templates_setting' = render_if_exists 'groups/templates_setting', expanded: expanded += render_if_exists 'shared/groups/max_pages_size_setting' %section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } } .settings-header @@ -54,10 +55,7 @@ = _('Advanced') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Perform advanced options such as changing path, transferring, exporting, or removing the group.') .settings-content = render 'groups/settings/advanced' - -= render_if_exists 'shared/groups/max_pages_size_setting' - diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml index 6d0f24bf08c..4fda4e2b447 100644 --- a/app/views/groups/packages/index.html.haml +++ b/app/views/groups/packages/index.html.haml @@ -6,7 +6,7 @@ full_path: @group.full_path, endpoint: group_packages_path(@group), page_type: 'groups', - empty_list_illustration: image_path('illustrations/no-packages.svg'), + empty_list_illustration: image_path('illustrations/empty-state/empty-package-md.svg'), npm_instance_url: package_registry_instance_url(:npm), project_list_url: '', settings_path: show_group_package_registry_settings(@group) ? group_settings_packages_and_registries_path(@group) : '', diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index ed078230349..22e9f9f5071 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -2,16 +2,20 @@ - page_title _("Projects") - @force_desktop_expanded_sidebar = true -= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 js-search-settings-section' }, header_options: { class: 'gl-display-flex' }, body_options: { class: 'gl-py-0' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-search-settings-section' }, header_options: { class: 'gl-new-card-header gl-display-flex' }, body_options: { class: 'gl-new-card-body' }) do |c| - c.with_header do - .gl-flex-grow-1 - = html_escape(_("%{strong_open}%{group_name}%{strong_close} projects:")) % { strong_open: '<strong>'.html_safe, group_name: @group.name, strong_close: '</strong>'.html_safe } - - if can? current_user, :admin_group, @group - .controls - = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small, variant: :confirm) do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Projects') + .gl-new-card-count + = sprite_icon('project', css_class: 'gl-mr-2') + = @projects.size + .gl-new-card-actions + - if can? current_user, :admin_group, @group + = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small) do = _("New project") - c.with_body do - %ul.content-list + %ul.content-list{ class: 'gl-px-3!' } - @projects.each_with_index do |project, idx| %li.project-row.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'project_row_container', qa_index: idx } } .avatar-container.rect-avatar.s40.gl-flex-shrink-0 @@ -26,22 +30,22 @@ \/ %span.project-name{ data: { qa_selector: 'project_name_content', qa_project_name: project.name } } = project.name - %span{ class: visibility_level_color(project.visibility_level) } - = visibility_level_icon(project.visibility_level) + = visibility_level_content(project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon') - if project.description.present? .description = markdown_field(project, :description) - .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex + .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex.gl-gap-3 = gl_badge_tag storage_counter(project.statistics&.storage_size) = render 'project_badges', project: project - .controls.gl-flex-shrink-0.gl-ml-5 = render Pajamas::ButtonComponent.new(href: project_project_members_path(project), - button_options: { data: { qa_selector: 'project_members_button' } }) do - = _('Members') + variant: :link, + button_options: { class: 'gl-mr-2', data: { qa_selector: 'project_members_button' } }) do + = _('View members') = render Pajamas::ButtonComponent.new(href: edit_project_path(project), + size: :small, button_options: { data: { qa_selector: 'project_edit_button' } }) do = _('Edit') = render 'delete_project_button', project: project, data: { qa_selector: 'project_delete_button' } diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index d92a6b08b60..45ee6ea6ad7 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -1,29 +1,32 @@ - remove_form_id = 'js-remove-group-form' = render 'groups/settings/export', group: @group -.sub-section - %h4.warning-title= s_('GroupSettings|Change group URL') - = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| - = form_errors(@group) - .form-group - %p - = s_("GroupSettings|Changing a group's URL can have unintended side effects.") - = link_to _('Learn more.'), help_page_path('user/group/manage', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer' += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.warning-title= s_('GroupSettings|Change group URL') + %p.gl-new-card-description + = s_("GroupSettings|Changing a group's URL can have unintended side effects.") + = link_to _('Learn more.'), help_page_path('user/group/manage', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer' - .input-group.gl-field-error-anchor - .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' } - .input-group-text - %span>= root_url - - if @group.parent - %strong= @group.parent.full_path + '/' - = f.hidden_field :parent_id - = f.text_field :path, placeholder: 'open-source', class: 'form-control', - autofocus: local_assigns[:autofocus] || false, required: true, - pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, - title: group_url_error_message, - maxlength: ::Namespace::URL_MAX_LENGTH, - "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - = f.submit s_('GroupSettings|Change group URL'), class: 'btn-danger', pajamas_button: true + - c.with_body do + = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = form_errors(@group) + .form-group + .input-group.gl-field-error-anchor + .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' } + .input-group-text + %span>= root_url + - if @group.parent + %strong= @group.parent.full_path + '/' + = f.hidden_field :parent_id + = f.text_field :path, placeholder: 'open-source', class: 'form-control', + autofocus: local_assigns[:autofocus] || false, required: true, + pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, + title: group_url_error_message, + maxlength: ::Namespace::URL_MAX_LENGTH, + "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" + = f.submit s_('GroupSettings|Change group URL'), class: 'btn-danger', pajamas_button: true = render 'groups/settings/transfer', group: @group = render 'groups/settings/remove', group: @group, remove_form_id: remove_form_id diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index 1e80c1846a4..8eb9f8fc5f1 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -1,33 +1,38 @@ - group = local_assigns.fetch(:group) -.sub-section - %h4= s_('GroupSettings|Export group') - %p= _('Export this group with all related data.') - = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| - - c.with_body do - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') } - - docs_link_end = '</a>'.html_safe - = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } - %p - - export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe} - = export_information.html_safe - = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - - c.with_body do - %p.gl-mb-0 - %p= _('The following items will be exported:') - %ul - - group_export_descriptions.each do |description| - %li= description - %p= _('The following items will NOT be exported:') - %ul - %li= _('Projects') - %li= _('Runner tokens') - %li= _('SAML discovery tokens') - - if group.export_file_exists? - = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do - = _('Download export') - = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'regenerate_export_group_link' } }) do - = _('Regenerate export') - - else - = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'export_group_link' } }) do - = _('Export group') += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title= s_('GroupSettings|Export group') + %p.gl-new-card-description + = _('Export this group with all related data.') + + - c.with_body do + = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| + - c.with_body do + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') } + - docs_link_end = '</a>'.html_safe + = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } + %p + - export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe} + = export_information.html_safe + = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + %p.gl-mb-0 + %p= _('The following items will be exported:') + %ul + - group_export_descriptions.each do |description| + %li= description + %p= _('The following items will NOT be exported:') + %ul + %li= _('Projects') + %li= _('Runner tokens') + %li= _('SAML discovery tokens') + - if group.export_file_exists? + = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do + = _('Download export') + = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'regenerate_export_group_link' } }) do + = _('Regenerate export') + - else + = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'export_group_link' } }) do + = _('Export group') diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml index 152cdfc1411..ae440636294 100644 --- a/app/views/groups/settings/_permanent_deletion.html.haml +++ b/app/views/groups/settings/_permanent_deletion.html.haml @@ -1,11 +1,15 @@ - remove_form_id = local_assigns.fetch(:remove_form_id, nil) -.sub-section - %h4.danger-title= _('Remove group') - = form_tag(group, method: :delete, id: remove_form_id) do - %p - = _('Removing this group also removes all child projects, including archived projects, and their resources.') - %br - %strong= _('Removed group can not be restored!') += 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-bg-red-50 gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.danger-title= _('Remove group') - = render 'groups/settings/remove_button', group: group, remove_form_id: remove_form_id + - c.with_body do + = form_tag(group, method: :delete, id: remove_form_id) do + %p + = _('Removing this group also removes all child projects, including archived projects, and their resources.') + %br + %strong= _('Removed group can not be restored!') + + = render 'groups/settings/remove_button', group: group, remove_form_id: remove_form_id diff --git a/app/views/groups/settings/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml index acf11fd8858..6085bcc149f 100644 --- a/app/views/groups/settings/_remove_button.html.haml +++ b/app/views/groups/settings/_remove_button.html.haml @@ -1,7 +1,7 @@ - remove_form_id = local_assigns.fetch(:remove_form_id, nil) - if group.prevent_delete? - = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5', data: { testid: 'group-has-linked-subscription-alert' }}) do |c| + = render Pajamas::AlertComponent.new(variant: :tip, dismissible: false, alert_options: { class: 'gl-mb-5', data: { testid: 'group-has-linked-subscription-alert' }}) do |c| - c.with_body do = html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } diff --git a/app/views/groups/settings/_subgroup_creation_level.html.haml b/app/views/groups/settings/_subgroup_creation_level.html.haml index d92610367ae..9f0a206312e 100644 --- a/app/views/groups/settings/_subgroup_creation_level.html.haml +++ b/app/views/groups/settings/_subgroup_creation_level.html.haml @@ -1,3 +1,4 @@ .form-group = f.label s_('SubgroupCreationLevel|Roles allowed to create subgroups'), class: 'label-bold' - = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, group.subgroup_creation_level), {}, class: 'form-control' + - ::Gitlab::Access.subgroup_creation_options.each do |label, action| + = f.gitlab_ui_radio_component :subgroup_creation_level, action, label diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index 9ebe3a740b3..368e4a981bc 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -1,20 +1,25 @@ - form_id = "transfer-group-form" - initial_data = { button_text: s_('GroupSettings|Transfer group'), group_full_path: @group.full_path, group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s } -.sub-section{ data: { qa_selector: 'transfer_group_content' } } - %h4.warning-title= s_('GroupSettings|Transfer group') - %p= _('Transfer group to another parent group.') - = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f| - %ul - - learn_more_link = help_page_url('user/project/repository/index', anchor: 'what-happens-when-a-repository-path-changes') - - learn_more_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learn_more_link } - - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe } - %li= warning_text.html_safe - %li= s_('GroupSettings|You must have the Owner role in the target group') - %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.') - %li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") - - if group.paid? - = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - - c.with_body do - = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } - .js-transfer-group-form{ data: initial_data } += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'transfer_group_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.warning-title= s_('GroupSettings|Transfer group') + %p.gl-new-card-description + = _('Transfer group to another parent group.') + + - c.with_body do + = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f| + %ul + - learn_more_link = help_page_url('user/project/repository/index', anchor: 'what-happens-when-a-repository-path-changes') + - learn_more_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learn_more_link } + - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe } + %li= warning_text.html_safe + %li= s_('GroupSettings|You must have the Owner role in the target group') + %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.') + %li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") + - if group.paid? + = render Pajamas::AlertComponent.new(variant: :tip, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } + .js-transfer-group-form{ data: initial_data } diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml index ac3be429461..ef85eab6778 100644 --- a/app/views/groups/settings/access_tokens/index.html.haml +++ b/app/views/groups/settings/access_tokens/index.html.haml @@ -25,18 +25,31 @@ #js-new-access-token-app{ data: { access_token_type: type } } - - if current_user.can?(:create_resource_access_tokens, @group) - = render 'shared/access_tokens/form', - ajax: true, - type: type, - path: group_settings_access_tokens_path(@group), - resource: @group, - token: @resource_access_token, - scopes: @scopes, - access_levels: GroupMember.access_level_roles, - default_access_level: Gitlab::Access::GUEST, - prefix: :resource_access_token, - help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Active group access tokens') + .gl-new-card-count + = sprite_icon('token', css_class: 'gl-mr-2') + %span.js-token-count= @active_access_tokens.size + - if current_user.can?(:create_resource_access_tokens, @group) + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do + = _('Add new token') + - c.with_body do + - if current_user.can?(:create_resource_access_tokens, @group) + .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form + = render 'shared/access_tokens/form', + ajax: true, + type: type, + path: group_settings_access_tokens_path(@group), + resource: @group, + token: @resource_access_token, + scopes: @scopes, + access_levels: GroupMember.access_level_roles, + default_access_level: Gitlab::Access::GUEST, + prefix: :resource_access_token, + help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') - #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true - } } + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } } diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 7b6e50ffd36..f9ade00a300 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -14,7 +14,7 @@ = _("General pipelines") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("Customize your pipeline configuration.") .settings-content = render 'groups/settings/ci_cd/form', group: @group @@ -31,7 +31,7 @@ = _('Runners') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("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 @@ -43,7 +43,7 @@ = _('Auto DevOps') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %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 } diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml index 3c1a38d9997..d0d3b1bf137 100644 --- a/app/views/groups/settings/integrations/index.html.haml +++ b/app/views/groups/settings/integrations/index.html.haml @@ -6,5 +6,5 @@ %h3= s_('Integrations|Group-level integration management') - integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path } - %p= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe } + %p.gl-text-secondary= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe } = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml index e8aa809a6ca..b04a3fe50ae 100644 --- a/app/views/groups/settings/repository/_default_branch.html.haml +++ b/app/views/groups/settings/repository/_default_branch.html.haml @@ -4,7 +4,7 @@ = _('Default branch') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.') .settings-content = gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| diff --git a/app/views/groups/work_items/index.html.haml b/app/views/groups/work_items/index.html.haml new file mode 100644 index 00000000000..2e3d3dda941 --- /dev/null +++ b/app/views/groups/work_items/index.html.haml @@ -0,0 +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 } } diff --git a/app/views/help/instance_configuration/_size_limits.html.haml b/app/views/help/instance_configuration/_size_limits.html.haml index 90501450385..add484feac9 100644 --- a/app/views/help/instance_configuration/_size_limits.html.haml +++ b/app/views/help/instance_configuration/_size_limits.html.haml @@ -41,3 +41,9 @@ %tr %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= instance_configuration_human_size_cell(size_limits[:max_import_remote_file_size]) + %tr + %td= s_('BulkImport|Direct transfer maximum download file size (MB)') + %td= instance_configuration_human_size_cell(size_limits[:bulk_import_max_download_file_size]) diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml index 80eb0c7a764..38196f97030 100644 --- a/app/views/import/bulk_imports/history.html.haml +++ b/app/views/import/bulk_imports/history.html.haml @@ -3,4 +3,4 @@ - add_page_specific_style 'page_bundles/import' - page_title _('Import history') -#import-history-mount-element +#import-history-mount-element{ data: { realtime_changes_path: realtime_changes_import_bulk_imports_path(format: :json) } } diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 9d4c0f62134..6aac7aa65af 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -40,4 +40,5 @@ .js-gitlab-user{ data: { name: "users[#{id}][gitlab_user]" } } .form-actions - = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm' + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do + = _('Continue to the next step') diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index 4a293bb6f4e..f76e9f3f6ed 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -1,24 +1,25 @@ -- page_title _("Gitea Import") +- page_title _("Gitea import") - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h1.page-title.gl-font-size-h-display = custom_icon('gitea_logo') - = _('Import Projects from Gitea') + = _('Import projects from Gitea') %p - - link_to_personal_token = link_to(_('Personal Access Token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api') - = _('To get started, please enter your Gitea Host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token } + - link_to_personal_token = link_to(_('personal access token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api') + = _('To get started, please enter your Gitea host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token } = form_tag personal_access_token_import_gitea_path do = hidden_field_tag(:namespace_id, params[:namespace_id]) .form-group.row - = label_tag :gitea_host_url, _('Gitea Host URL'), class: 'col-form-label col-sm-2' + = label_tag :gitea_host_url, _('Gitea host URL'), class: 'col-form-label col-sm-2' .col-sm-4 = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control gl-form-input' .form-group.row - = label_tag :personal_access_token, _('Personal Access Token'), class: 'col-form-label col-sm-2' + = label_tag :personal_access_token, _('Personal access token'), class: 'col-form-label col-sm-2' .col-sm-4 = text_field_tag :personal_access_token, nil, class: 'form-control gl-form-input' .form-actions - = submit_tag _('List Your Gitea Repositories'), class: 'gl-button btn btn-confirm' + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do + = _('List your Gitea repositories') diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml index c717d4848f4..2dde642d8f0 100644 --- a/app/views/import/gitea/status.html.haml +++ b/app/views/import/gitea/status.html.haml @@ -1,6 +1,6 @@ -- page_title _("Gitea Import") +- page_title _("Gitea import") %h1.page-title.gl-font-size-h-display = custom_icon('gitea_logo') - = _('Import Projects from Gitea') + = _('Import projects from Gitea') = render 'import/githubish_status', provider: 'gitea', default_namespace: @namespace diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 3bb59db32aa..95627c2884a 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -7,7 +7,7 @@ - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization) - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json - %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } + %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_path, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } - if display_whats_new? #whats-new-app{ data: { version_digest: whats_new_version_digest } } @@ -51,4 +51,4 @@ -# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/jh-team/gitlab-cn/-/issues/81) = render_if_exists "shared/footer/global_footer" -= render "layouts/nav/top_nav_responsive", class: 'layout-page' unless show_super_sidebar? += render "layouts/nav/top_nav_responsive", class: 'layout-page' if !show_super_sidebar? || !current_user diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 53e88d95893..28cbdf0a7a1 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -13,10 +13,13 @@ = header_message - if show_super_sidebar? # TODO: Move this CSS to a better place - :css - body { - --header-height: 0px; - } + - if current_user + :css + body { + --header-height: 0px; + } + - else + = render partial: "layouts/header/super_sidebar_logged_out" - else = render partial: "layouts/header/default", locals: { project: @project, group: @group } = render 'layouts/page', sidebar: sidebar, nav: nav diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index c75b02aa6a6..83641fbb184 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_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 e04ffc2e88a..7ce914cf660 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -11,7 +11,7 @@ %li.divider - if can?(current_user, :update_user_status, current_user) %li - %button.gl-button.btn.btn-link.menu-item.js-set-status-modal-trigger{ type: 'button' } + = render Pajamas::ButtonComponent.new(button_options: { class: 'menu-item js-set-status-modal-trigger' }) do - if current_user.status&.busy? || current_user.status&.customized? = s_('SetStatusModal|Edit status') - else diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 1c22a853dd0..993094c6889 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,19 +1,12 @@ - has_impersonation_link = header_link?(:admin_impersonation) - user_status_data = user_status_properties(current_user) -%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { testid: 'navbar' } } +%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar.legacy-top-bar{ data: { testid: 'navbar' } } %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid .header-content.js-header-content .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 - .title - %span.gl-sr-only GitLab - = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do - = brand_header_logo - .gl-display-flex.gl-align-items-center - - if Gitlab.com_and_canary? - = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do - = _('Next') + = render 'layouts/header/title' - if current_user .gl-display-none.gl-sm-display-block @@ -92,7 +85,7 @@ - if header_link?(:todos) = nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos js-prefetch-document', - data: { testid: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom', + data: { testid: 'todos-shortcut-button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_to_do_link', track_property: 'navigation_top', diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml new file mode 100644 index 00000000000..67322aced74 --- /dev/null +++ b/app/views/layouts/header/_super_sidebar_logged_out.haml @@ -0,0 +1,47 @@ +%header.navbar.navbar-gitlab.super-sidebar-logged-out{ data: { testid: 'navbar' } } + %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content + .container-fluid + .header-content.gl-displax-flex + .title-container.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 + = render 'layouts/header/title' + + %ul.nav.navbar-sub-nav.gl-align-items-center.gl-display-flex.gl-flex-direction-row.gl-flex-grow-1 + - if Gitlab.com? + %li.nav-item.dropdown.gl-mr-3.gl-md-display-none + %button{ type: "button", data: { toggle: "dropdown" } } + %span.gl-sr-only + = _('Menu') + = sprite_icon('hamburger', size: 16) + .dropdown-menu + %ul + %li + = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do + = s_('LoggedOutMarketingHeader|Why GitLab') + %li + = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do + = s_('LoggedOutMarketingHeader|Pricing') + %li + = link_to Gitlab::Utils.append_path(promo_url, 'sales') do + = s_('LoggedOutMarketingHeader|Contact Sales') + %li + = link_to _("Explore"), explore_root_path + %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block + = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do + = s_('LoggedOutMarketingHeader|Why GitLab') + %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block + = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do + = s_('LoggedOutMarketingHeader|Pricing') + %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block + = link_to Gitlab::Utils.append_path(promo_url, 'sales') do + = s_('LoggedOutMarketingHeader|Contact Sales') + %li.nav-item{ class: ('gl-display-none gl-md-display-inline-block' if Gitlab.com?) } + = link_to _("Explore"), explore_root_path, class: '' + + - if header_link?(:sign_in) + %ul.nav.navbar-nav.gl-align-items-center.gl-justify-content-end.gl-flex-direction-row + %li.nav-item.gl-mr-3 + = link_to _('Sign in'), new_session_path(:user, redirect_to_referer: 'yes') + - if allow_signup? + %li + = render Pajamas::ButtonComponent.new(href: new_user_registration_path, variant: :confirm) do + = _('Register') diff --git a/app/views/layouts/header/_title.html.haml b/app/views/layouts/header/_title.html.haml new file mode 100644 index 00000000000..0e57c6809c2 --- /dev/null +++ b/app/views/layouts/header/_title.html.haml @@ -0,0 +1,8 @@ +.title + %span.gl-sr-only GitLab + = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do + = brand_header_logo + .gl-display-flex.gl-align-items-center + - if Gitlab.com_and_canary? + = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do + = _('Next') diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 4ecae875056..18ae3353f4d 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_alert", project: @project = dispensable_render_if_exists "projects/code_suggestions_third_party_alert", project: @project = dispensable_render_if_exists "projects/free_user_cap_alert", project: @project = dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml index ed7a3285f45..f080a5798f1 100644 --- a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml +++ b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml @@ -1,2 +1,4 @@ += render_if_exists 'notify/address_new_reviewer_with_diff_summary' + %p = change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers, :strong) diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb index b6824966bb9..8db626548d7 100644 --- a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb +++ b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb @@ -1 +1,2 @@ +<%= render_if_exists 'notify/address_new_reviewer_with_diff_summary' -%> <%= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers) %> diff --git a/app/views/notify/member_about_to_expire_email.html.haml b/app/views/notify/member_about_to_expire_email.html.haml new file mode 100644 index 00000000000..a9f92d90ae6 --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.html.haml @@ -0,0 +1,6 @@ += email_default_heading(say_hi(@member.user)) + +%p + = member_about_to_expire_text(@member_source, @days_to_expire, format: :html) +%p + = member_about_to_expire_link(@member, @member_source, format: :html) diff --git a/app/views/notify/member_about_to_expire_email.text.erb b/app/views/notify/member_about_to_expire_email.text.erb new file mode 100644 index 00000000000..0c6e78bf501 --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.text.erb @@ -0,0 +1,5 @@ +<%= say_hi(@member.user) %> + +<%= member_about_to_expire_text(@member_source, @days_to_expire) %> + +<%= member_about_to_expire_link(@member, @member_source) %> diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml index afc1bd68215..8a184aa9696 100644 --- a/app/views/notify/new_review_email.html.haml +++ b/app/views/notify/new_review_email.html.haml @@ -22,3 +22,4 @@ - discussion.first_note.project = @project if discussion&.first_note - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author + = render_if_exists 'notify/review_summary' diff --git a/app/views/notify/new_review_email.text.erb b/app/views/notify/new_review_email.text.erb index 69cb33b05df..e974c8b6be8 100644 --- a/app/views/notify/new_review_email.text.erb +++ b/app/views/notify/new_review_email.text.erb @@ -12,3 +12,5 @@ -- <% end %> <% end %> + +<%= render_if_exists 'notify/review_summary' %> diff --git a/app/views/notify/request_review_merge_request_email.html.haml b/app/views/notify/request_review_merge_request_email.html.haml index a8c7df79ff3..d5f5d155f3d 100644 --- a/app/views/notify/request_review_merge_request_email.html.haml +++ b/app/views/notify/request_review_merge_request_email.html.haml @@ -1,2 +1,3 @@ %p = html_escape(s_('Notify|%{name} requested a new review on %{mr_link}.')) % {name: sanitize_name(@updated_by.name), mr_link: merge_request_reference_link(@merge_request).html_safe} + = render_if_exists 'notify/diff_summary' diff --git a/app/views/notify/request_review_merge_request_email.text.erb b/app/views/notify/request_review_merge_request_email.text.erb index 9ab15332c51..dc1746d3a8c 100644 --- a/app/views/notify/request_review_merge_request_email.text.erb +++ b/app/views/notify/request_review_merge_request_email.text.erb @@ -1 +1,2 @@ <%= sanitize_name(@updated_by.name) %> requested a new review on <%= merge_request_reference_link(@merge_request) %>. +<%= render_if_exists 'notify/diff_summary' -%> diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml index d798eab7635..b0d9f142d88 100644 --- a/app/views/profiles/_name.html.haml +++ b/app/views/profiles/_name.html.haml @@ -4,6 +4,6 @@ %small.form-text.text-gl-muted = s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) } - else - = form.text_field :name, class: 'gl-form-input form-control', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead") + = form.text_field :name, class: 'gl-form-input form-control', required: true, title: s_("Profiles|Using emoji in names seems fun, but please try to set a status message instead") %small.form-text.text-gl-muted = s_("Profiles|Enter your name, so people you know can recognize you.") diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 743c26260e4..6dcd661ecdb 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,66 +1,84 @@ - page_title _('Emails') +- profile_message = _('Used for avatar detection. You can change it in your %{openingTag}profile settings%{closingTag}.') % { openingTag: "<a href='#{profile_path}' class='gl-text-blue-500!'>".html_safe, closingTag: '</a>'.html_safe} +- notification_message = _('Used for account notifications if a %{openingTag}group-specific email address%{closingTag} is not set.') % { openingTag: "<a href='#{profile_notifications_path}' class='gl-text-blue-500!'>".html_safe, closingTag: '</a>'.html_safe} +- public_email_message = _('Your public email will be displayed on your public profile.') +- commit_email_message = _('Used for web based operations, such as edits and merges.') - @force_desktop_expanded_sidebar = true + .settings-section.js-search-settings-section .settings-sticky-header .settings-sticky-header-inner %h4.gl-my-0 - = _('Add email address') + = s_('Profiles|Email addresses') %p.gl-text-secondary - = _('Control emails linked to your account') - %div - = gitlab_ui_form_for 'email', url: profile_emails_path do |f| - .form-group - = f.label :email, _('Email'), class: 'label-bold' - = f.text_field :email, class: 'form-control gl-form-input', data: { qa_selector: 'email_address_field' } - .gl-mt-3 - = f.submit _('Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true + = s_('Profiles|Control emails linked to your account') -.settings-section.js-search-settings-section - .settings-sticky-header - .settings-sticky-header-inner - %h4.gl-my-0 - = _('Linked emails (%{email_count})') % { email_count: @emails.load.size } - .account-well.gl-mb-3 - %ul - %li - - profile_message = _('Your primary email is used for avatar detection. You can change it in your %{openingTag}profile settings%{closingTag}.') % { openingTag: "<a href='#{profile_path}'>".html_safe, closingTag: '</a>'.html_safe} - = profile_message.html_safe - %li - = _('Your commit email is used for web based operations, such as edits and merges.') - %li - - notification_message = _('Your default notification email is used for account notifications if a %{openingTag}group-specific email address%{closingTag} is not set.') % { openingTag: "<a href='#{profile_notifications_path}'>".html_safe, closingTag: '</a>'.html_safe} - = notification_message.html_safe - %li - = _('Your public email will be displayed on your public profile.') - %li - = _('All email addresses will be used to identify your commits.') - %ul.content-list - %li - = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? } - %ul - %li= s_('Profiles|Primary email') - - if @primary_email == current_user.commit_email_or_default - %li= s_('Profiles|Commit email') - - if @primary_email == current_user.public_email - %li= s_('Profiles|Public email') - - if @primary_email == current_user.notification_email_or_default - %li= s_('Profiles|Default notification email') - - @emails.reject(&:user_primary_email?).each do |email| - %li{ data: { qa_selector: 'email_row_content' } } - .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-gap-3 - %div - = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } + .settings-section.js-search-settings-section + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = s_('Profiles|Linked emails') + .gl-new-card-count + = sprite_icon('mail', css_class: 'gl-mr-2') + = @emails.load.size + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'toggle_email_address_field' } }) do + = s_('Profiles|Add new email') + - c.with_body do + .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content + %h4.gl-mt-0 + = s_('Profiles|Add new email') + = gitlab_ui_form_for 'email', url: profile_emails_path do |f| + .form-group + = f.label :email, s_('Profiles|Email address'), class: 'label-bold' + = f.text_field :email, class: 'form-control gl-form-input gl-form-input-xl', data: { qa_selector: 'email_address_field' } + .gl-mt-3 + = f.submit s_('Profiles|Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') + - if @emails.any? + %ul.content-list + %li{ class: 'gl-px-5!' } + = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? } %ul - - if email.email == current_user.commit_email_or_default - %li= s_('Profiles|Commit email') - - if email.email == current_user.public_email - %li= s_('Profiles|Public email') - - if email.email == current_user.notification_email_or_default - %li= s_('Profiles|Notification email') - .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3 - - unless email.confirmed? - - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" - = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small + %li.gl-mt-2 + = s_('Profiles|Primary email') + .gl-text-secondary.gl-font-sm= profile_message.html_safe + - if @primary_email == current_user.commit_email_or_default + %li.gl-mt-2 + = s_('Profiles|Commit email') + .gl-text-secondary.gl-font-sm= commit_email_message + - if @primary_email == current_user.public_email + %li.gl-mt-2 + = s_('Profiles|Public email') + .gl-text-secondary.gl-font-sm= public_email_message + - if @primary_email == current_user.notification_email_or_default + %li.gl-mt-2 + = s_('Profiles|Default notification email') + .gl-text-secondary.gl-font-sm= notification_message.html_safe + - @emails.reject(&:user_primary_email?).each do |email| + %li{ class: 'gl-px-5!', data: { qa_selector: 'email_row_content' } } + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-gap-3 + %div + = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } + %ul + - if email.email == current_user.commit_email_or_default + %li.gl-mt-2 + = s_('Profiles|Commit email') + .gl-text-secondary.gl-font-sm= commit_email_message + - if email.email == current_user.public_email + %li.gl-mt-2 + = s_('Profiles|Public email') + .gl-text-secondary.gl-font-sm= public_email_message + - if email.email == current_user.notification_email_or_default + %li.gl-mt-2 + = s_('Profiles|Default notification email') + .gl-text-secondary.gl-font-sm= notification_message.html_safe + .gl-display-flex.gl-sm-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3 + - unless email.confirmed? + - confirm_title = "#{email.confirmation_sent_at ? s_('Profiles|Resend confirmation email') : s_('Profiles|Send confirmation email')}" + = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small - = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, variant: :danger, size: :small, icon: 'remove', 'aria-label': _('Remove') + = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), confirm_btn_variant: 'danger', qa_selector: 'delete_email_link'}, method: :delete, size: :small, icon: 'remove', 'aria-label': _('Remove') diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index ffd8bc3de27..2bc977feb24 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -8,3 +8,5 @@ .gl-mt-3 = f.submit s_('Profiles|Add key'), pajamas_button: true + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index d8b8dda29dc..f8520cb430d 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -1,24 +1,29 @@ -%li.key-list-item - .float-left.gl-mr-3 - = sprite_icon('key', css_class: "settings-list-icon d-none d-sm-block gl-mt-4") - .key-list-item-info +%tr.key-list-item + %td{ data: { label: s_('Profiles|Key') } } + %div{ class: 'gl-display-flex! gl-pl-0!' } + = sprite_icon('key', css_class: "settings-list-icon d-none d-sm-inline gl-mr-2") + .gl-display-flex.gl-flex-direction-column.gl-text-truncate + %p.gl-text-truncate.gl-m-0 + %code= key.fingerprint + - if key.subkeys.present? + .subkeys.gl-mt-3{ class: 'gl-text-left!' } + %span.gl-font-sm + = _('Subkeys:') + %ul.subkeys-list + - key.subkeys.each do |subkey| + %li + %p.gl-text-truncate.gl-m-0 + %code= subkey.fingerprint + + %td{ data: { label: _('Status') } } - key.emails_with_verified_status.map do |email, verified| - = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified } + %div{ class: 'gl-text-left!' } + = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified } + + %td{ data: { label: _('Created') } } + = html_escape(s_('Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) } - %span.text-truncate - %code= key.fingerprint - - if key.subkeys.present? - .subkeys - %span.bold - = _('Subkeys') - = ':' - %ul.subkeys-list - - key.subkeys.each do |subkey| - %li - %code= subkey.fingerprint - .float-right - %span.key-created-at - = html_escape(s_('Profiles|Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) } - = link_button_to nil, profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: 'gl-ml-3', variant: :danger, icon: 'remove', 'aria-label': _('Remove') - = link_button_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: 'gl-ml-3', variant: :danger, 'aria-label': _('Revoke') do + %td{ class: 'gl-py-3!', data: { label: _('Actions') } } + = link_button_to nil, profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.'), confirm_btn_variant: 'danger' }, method: :delete, class: 'has-tooltip', icon: 'remove', category: :secondary, 'title': _('Remove'), 'aria-label': _('Remove') + = link_button_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.'), confirm_btn_variant: 'danger' }, method: :put, class: 'gl-ml-3', category: :secondary, variant: :danger, 'aria-label': _('Revoke') do = _('Revoke') diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml index ebbd1c8f672..0a50ce55b50 100644 --- a/app/views/profiles/gpg_keys/_key_table.html.haml +++ b/app/views/profiles/gpg_keys/_key_table.html.haml @@ -1,10 +1,19 @@ - is_admin = local_assigns.fetch(:admin, false) +- hide_class = local_assigns.fetch(:hide_class, false) - if @gpg_keys.any? - %ul.content-list - = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin } + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } } + %thead.d-none.d-md-table-header-group + %tr + %th= s_('Profiles|Key') + %th= _('Status') + %th= _('Created') + %th= _('Actions') + = render partial: 'profiles/gpg_keys/key', collection: @gpg_keys, locals: { is_admin: is_admin } + - else - %p.settings-message.text-center + %p.gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content{ class: hide_class } - if is_admin = _('There are no GPG keys associated with this account.') - else diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 2dfd6c7860f..2714193d1d1 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,6 +1,8 @@ - page_title _('GPG Keys') - add_page_specific_style 'page_bundles/profile' - @force_desktop_expanded_sidebar = true +- add_form_class = 'gl-display-none' if !form_errors(@gpg_key) +- hide_class = 'gl-display-none' if form_errors(@gpg_key) .settings-section.js-search-settings-section .settings-sticky-header @@ -10,17 +12,24 @@ %p.gl-text-secondary = _('GPG keys allow you to verify signed commits.') - %h5.gl-font-lg.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') } - = _('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' + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Your GPG keys') + .gl-new-card-count + = sprite_icon('key', css_class: 'gl-mr-2') + = @gpg_keys.count + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content #{hide_class}" }) do + = _('Add new key') + - c.with_body do + .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class } + %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') } + = _('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' -.settings-section.js-search-settings-section - .settings-sticky-header - .settings-sticky-header-inner - %h4.gl-my-0 - = _('Your GPG keys (%{count})') % { count: @gpg_keys.count } - .gl-mb-3 - = render 'key_table' + = render 'key_table', hide_class: hide_class diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 5c4ea7b2ecb..b1df63a72ab 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -25,12 +25,13 @@ %p.form-text.text-muted= ssh_key_expires_field_description .js-add-ssh-key-validation-warning.hide - .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } + .bs-callout.bs-callout-warning.gl-mt-0{ role: 'alert', aria_live: 'assertive' } %strong= _('Oops, are you sure?') %p= s_("Profiles|Publicly visible private SSH keys can compromise your system.") - = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-add-ssh-key-validation-confirm-submit' }) do = _("Yes, add it") - .gl-mt-3 - = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { qa_selector: 'add_key_button' } + + = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { qa_selector: 'add_key_button' } + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-add-ssh-key-validation-cancel gl-ml-2 js-toggle-button' }) do + = _('Cancel') diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 288007ec806..7ba42274f88 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -1,43 +1,49 @@ - icon_classes = 'settings-list-icon gl-display-none gl-sm-display-block' -%li.key-list-item - .gl-display-flex.gl-align-items-flex-start - .key-list-item-info.gl-w-full.float-none - = link_to path_to_key(key, is_admin), class: "title text-3" do - = key.title +%tr.key-list-item + %td{ data: { label: _('Title'), testid: 'title' } } + = link_to path_to_key(key, is_admin) do + = key.title - .gl-display-flex.gl-align-items-center.gl-mt-2 - - if key.valid? && !key.expired? - = sprite_icon('key', css_class: icon_classes) - - else - %span.gl-display-inline-block.has-tooltip{ title: ssh_key_expiration_tooltip(key) } - = sprite_icon('warning-solid', css_class: icon_classes) + %td{ data: { label: s_('Profiles|Key'), testid: 'key' } } + .gl-align-items-center{ class: 'gl-display-flex! gl-pl-0!' } + - if key.valid? && !key.expired? + = sprite_icon('key', css_class: icon_classes) + - else + %span.gl-display-inline-block.has-tooltip{ title: ssh_key_expiration_tooltip(key) } + = sprite_icon('warning-solid', css_class: icon_classes) + %span.gl-text-truncate.gl-sm-ml-3 + = key.fingerprint - %span.gl-text-truncate.gl-sm-ml-3 - = key.fingerprint + %td{ data: { label: s_('Profiles|Usage type'), testid: 'usage-type' } } + = ssh_key_usage_types.invert[key.usage_type] - .gl-mt-3= html_escape(s_('Profiles|Created%{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2').html_safe} + %td{ data: { label: s_('Profiles|Created'), testid: 'created' } } + = html_escape(s_('%{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at).html_safe} - .key-list-item-dates - %span.last-used-at.gl-mr-3 - = s_('Profiles|Last used:') - -# TODO: Remove this conditional when https://gitlab.com/gitlab-org/gitlab/-/issues/324764 is resolved. - - if Feature.enabled?(:disable_ssh_key_used_tracking) - = _('Unavailable') - = link_to sprite_icon('question-o'), help_page_path('user/ssh.md', anchor: 'view-your-accounts-ssh-keys') - - else - = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never') - %span.expires.gl-mr-3 - = key.expired? ? s_('Profiles|Expired:') : s_('Profiles|Expires:') - = key.expires_at ? key.expires_at.to_date : _('Never') - %span.last-used-at.gl-mr-3 - = s_('Profiles|Usage type:') - = ssh_key_usage_types.invert[key.usage_type] - .gl-display-flex.gl-float-right - - if key.can_delete? - - if key.signing? && !is_admin - = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_revoke_modal_data(key, revoke_profile_key_path(key)) }) do - = _('Revoke') - .gl-pl-3 - = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) }) do - = _('Remove') + %td{ data: { label: s_('Profiles|Last used'), testid: 'last-used' } } + -# TODO: Remove this conditional when https://gitlab.com/gitlab-org/gitlab/-/issues/324764 is resolved. + - if Feature.enabled?(:disable_ssh_key_used_tracking) + = _('Unavailable') + = link_to sprite_icon('question-o'), help_page_path('user/ssh.md', anchor: 'view-your-accounts-ssh-keys') + - else + = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never') + + %td{ data: { label: s_('Profiles|Expires'), testid: 'expires' } } + - if key.expired? + %span.gl-text-red-500 + = s_('Profiles|Expired') + = key.expires_at.to_date + - elsif key.expires_at + = key.expires_at.to_date + - else + = _('Never') + + %td{ data: { label: _('Actions'), testid: 'actions' } } + %div{ class: 'gl-display-flex! gl-pl-0!' } + - if key.can_delete? + - if key.signing? && !is_admin + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', 'aria-label' => _('Revoke'), data: ssh_key_revoke_modal_data(key, revoke_profile_key_path(key)) }) do + = _('Revoke') + .gl-pl-3 + = render Pajamas::ButtonComponent.new(size: :small, icon: 'remove', button_options: { title: _('Remove'), 'aria-label' => _('Remove'), class: 'js-confirm-modal-button', data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) }) diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index f1d5a127728..d5193a424ef 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -1,45 +1,70 @@ - is_admin = defined?(admin) ? true : false -.row.gl-mt-3 - .col-md-4 - = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c| - - c.with_header do - = _('SSH Key') - - c.with_body do - %ul.content-list - %li - %span.light= _('Title:') - %strong= @key.title - %li - %span.light= s_('Profiles|Usage type:') - %strong= ssh_key_usage_types.invert[@key.usage_type] - %li - %span.light= _('Created on:') - %strong= @key.created_at.to_fs(:medium) - %li - %span.light= _('Expires:') - %strong= @key.expires_at&.to_fs(:medium) || _('Never') - %li - %span.light= _('Last used on:') - %strong= @key.last_used_at&.to_fs(:medium) || _('Never') - .col-md-8 - = form_errors(@key, type: 'key') unless @key.valid? - %pre.well-pre - = @key.key - = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c| - - c.with_header do - = _('Fingerprints') - - c.with_body do - %ul.content-list - %li - %span.light= 'MD5:' - %code.key-fingerprint= @key.fingerprint - - if @key.fingerprint_sha256.present? - %li - %span.light= 'SHA256:' - %code.key-fingerprint= @key.fingerprint_sha256 +%h1.gl-font-size-h-display + = s_('Profiles|SSH Key: %{title}').html_safe % { title: @key.title } - .col-md-12 - .float-right - - if @key.can_delete? - = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin)) += 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 + .gl-new-card-title-wrapper + .gl-new-card-title + = _('Key details') + - c.with_body do + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md.ssh-keys-list + %thead + %th= s_('Profiles|Usage type') + %th= s_('Profiles|Created') + %th= s_('Profiles|Last used') + %th= s_('Profiles|Expires') + %tbody + %tr + %td{ data: { label: s_('Profiles|Usage type'), testid: 'usage' } } + = ssh_key_usage_types.invert[@key.usage_type] + %td{ data: { label: s_('Profiles|Created'), testid: 'created' } } + = @key.created_at.to_fs(:medium) + %td{ data: { label: s_('Profiles|Last used'), testid: 'last-used' } } + = @key.last_used_at&.to_fs(:medium) || _('Never') + %td{ data: { label: s_('Profiles|Expires'), testid: 'expires' } } + - if @key.expired? + %span.gl-text-red-500 + = s_('Profiles|Expired') + = @key.expires_at&.to_fs(:medium) + - elsif @key.expires_at + = @key.expires_at&.to_fs(:medium) + - else + = _('Never') + +.gl-mt-5 + = form_errors(@key, type: 'key') unless @key.valid? + + = 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 gl-overflow-hidden'}) do |c| + - c.with_header do + .gl-new-card-title-wrapper + .gl-new-card-title + = _('SSH Key') + - c.with_body do + .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!') + + = 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 + .gl-new-card-title-wrapper + .gl-new-card-title + = _('Fingerprints') + - c.with_body do + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md + %tbody + %tr + %th= _('MD5') + %td.gl-font-monospace.key-fingerprint= @key.fingerprint + - if @key.fingerprint_sha256.present? + %tr + %th= _('SHA256') + %td.gl-font-monospace.key-fingerprint= @key.fingerprint_sha256 + +.gl-mt-5.gl-float-right + - if @key.can_delete? + = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin)) diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml index 176d7a42002..cfe507ad65d 100644 --- a/app/views/profiles/keys/_key_table.html.haml +++ b/app/views/profiles/keys/_key_table.html.haml @@ -1,10 +1,21 @@ - is_admin = local_assigns.fetch(:admin, false) +- hide_class = local_assigns.fetch(:hide_class, false) - if @keys.any? - %ul.content-list.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } } - = render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin } + .table-holder + %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } } + %thead.d-none.d-md-table-header-group + %tr + %th= _('Title') + %th= s_('Profiles|Key') + %th= s_('Profiles|Usage type') + %th= s_('Profiles|Created') + %th= s_('Profiles|Last used') + %th= s_('Profiles|Expires') + %th= _('Actions') + = render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin } - else - %p.settings-message.text-center + %p.gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content{ class: hide_class } - if is_admin = _('There are no SSH keys associated with this account.') - else diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index c2e65dcc8ef..0cd41788a53 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,6 +1,8 @@ - page_title _('SSH Keys') - add_page_specific_style 'page_bundles/profile' - @force_desktop_expanded_sidebar = true +- add_form_class = 'gl-display-none' if !form_errors(@key) +- hide_class = 'gl-display-none' if form_errors(@key) .settings-section.js-search-settings-section .settings-sticky-header @@ -12,17 +14,24 @@ - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url } = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe } - %h5.gl-font-lg.gl-mt-0 - = _('Add an SSH key') - %p - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') } - = _('Add an SSH 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' + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Your SSH keys') + .gl-new-card-count + = sprite_icon('key', css_class: 'gl-mr-2') + = @keys.count + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content #{hide_class}" }) do + = _('Add new key') + - c.with_body do + .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class } + %h4.gl-mt-0 + = _('Add an SSH key') + %p + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') } + = _('Add an SSH 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' -.settings-section.js-search-settings-section - .settings-sticky-header - .settings-sticky-header-inner - %h4.gl-my-0 - = _('Your SSH keys (%{count})') % { count: @keys.count } - .gl-mb-3 - = render 'key_table' + = render 'key_table', hide_class: hide_class diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 5020f6cbb22..c12f6907afb 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -16,13 +16,26 @@ #js-new-access-token-app{ data: { access_token_type: type } } - = render 'shared/access_tokens/form', - ajax: true, - type: type, - path: profile_personal_access_tokens_path, - token: @personal_access_token, - scopes: @scopes, - help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Active personal access tokens') + .gl-new-card-count + = sprite_icon('token', css_class: 'gl-mr-2') + %span.js-token-count= @active_access_tokens.size + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do + = _('Add new token') + - c.with_body do + .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form + = render 'shared/access_tokens/form', + ajax: true, + type: type, + path: profile_personal_access_tokens_path, + token: @personal_access_token, + scopes: @scopes, + help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } } diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index e5e7c1dc3f4..681d4e087f3 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -3,6 +3,8 @@ - user_theme_id = Gitlab::Themes.for_user(@user).id - user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id - user_fields = { theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json +- fixed_help_text = s_('Preferences|Content will be a maximum of 1280 pixels wide.') +- fluid_help_text = s_('Preferences|Content will span %{percentage} of the page width.').html_safe % { percentage: '100%' } - @themes = Gitlab::Themes::available_themes.to_json - data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path } - @force_desktop_expanded_sidebar = true @@ -11,6 +13,7 @@ = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename = gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f| + = render_if_exists 'profiles/preferences/code_suggestions_settings_self_assignment' .settings-section.js-preferences-form.js-search-settings-section.application-theme#navigation-theme .settings-sticky-header .settings-sticky-header-inner @@ -18,9 +21,6 @@ = s_('Preferences|Color theme') %p.gl-text-secondary = s_('Preferences|Customize the color of GitLab.') - - if show_super_sidebar? - %p - = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.') .application-theme.row - Gitlab::Themes.each do |theme| %label.col-6.col-sm-4.col-md-3.col-xl-2.gl-mb-5 @@ -37,7 +37,7 @@ %p.gl-text-secondary = s_('Preferences|Customize the appearance of the syntax.') = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'change-the-syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer' .syntax-theme.row - Gitlab::ColorSchemes.each do |scheme| %label.col-6.col-sm-4.col-md-3.col-lg-auto.gl-mb-5 @@ -68,17 +68,17 @@ .form-group = f.label :layout, class: 'label-bold' do = s_('Preferences|Layout width') - = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select' - .form-text.text-muted - = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } - .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, fluid_width: true.to_s } } + = 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 + + .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, toggle_class: 'gl-form-input-xl' } } = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific .form-group = f.label :project_view, class: 'label-bold' do = s_('Preferences|Project overview content') - = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select' + = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select gl-form-input-xl gl-display-block' .form-text.text-muted = s_('Preferences|Choose what content you want to see on a project’s overview page.') .form-group @@ -104,7 +104,7 @@ .form-group = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' = f.number_field :tab_width, - class: 'form-control gl-form-input', + class: 'form-control gl-form-input gl-max-w-15', min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX, required: true @@ -120,7 +120,8 @@ = _('Customize language and region related settings.') = succeed '.' do = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer' - .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language, block: true.to_s, fluid_width: true.to_s } } + + .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language, block: true.to_s, toggle_class: 'gl-form-input-xl' } } %p.gl-mt-n5 = link_to help_page_url('development/i18n/translation'), class: 'text-nowrap', target: '_blank', rel: 'noopener noreferrer' do = _("Help translate GitLab into your language") @@ -129,7 +130,7 @@ .form-group = f.label :first_day_of_week, class: 'label-bold' do = _('First day of the week') - = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select' + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select gl-display-block gl-form-input-xl' .settings-section.js-preferences-form.js-search-settings-section#time-preferences .settings-sticky-header @@ -144,19 +145,18 @@ = f.gitlab_ui_checkbox_component :time_display_relative, s_('Preferences|Use relative times'), help_text: s_('Preferences|For example: 30 minutes ago.') - - if Feature.enabled?(:disable_follow_users, @user) - .settings-section.js-preferences-form.js-search-settings-section#enabled_following - .settings-sticky-header - .settings-sticky-header-inner - %h4.gl-my-0 - = s_('Preferences|Enable follow users feature') - %p.gl-text-secondary - = s_('Preferences|Turns on or off the ability to follow or be followed by other users.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer' - .form-group - = f.gitlab_ui_checkbox_component :enabled_following, - s_('Preferences|Enable follow users') + .settings-section.js-preferences-form.js-search-settings-section#enabled_following + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Enable follow users feature') + %p.gl-text-secondary + = s_('Preferences|Turns on or off the ability to follow or be followed by other users.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer' + .form-group + = f.gitlab_ui_checkbox_component :enabled_following, + s_('Preferences|Enable follow users') = render_if_exists 'profiles/preferences/code_suggestions_settings', form: f = render_if_exists 'profiles/preferences/zoekt_settings', form: f diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index ebdea5786f5..4da48771ba3 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -5,7 +5,7 @@ - @force_desktop_expanded_sidebar = true - if Feature.enabled?(:edit_user_profile_vue, current_user) - .js-user-profile + .js-user-profile{ data: user_profile_data(@user) } - else = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| .settings-section.js-search-settings-section diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml index 29551505a7e..7ddb80c90f9 100644 --- a/app/views/projects/_deletion_failed.html.haml +++ b/app/views/projects/_deletion_failed.html.haml @@ -5,5 +5,7 @@ dismissible: false, alert_options: { class: 'project-deletion-failed-message' }) do |c| - c.with_body do - This project was scheduled for deletion, but failed with the following message: + = _('This project was scheduled for deletion, but failed with the following message:') = project.delete_error + %br + = _('The project visibility may have been made more restrictive if the parent group\'s visibility changed while the deletion was scheduled.') diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 3ef2c722e98..20fb2b43c63 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -2,30 +2,35 @@ - project = local_assigns.fetch(:project) -.sub-section{ data: { qa_selector: 'export_project_content' } } - %h4= _('Export project') - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/import_export') } - %p= _('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 } - %p.gl-mb-0 - %p= _('The following items will be exported:') - %ul - - project_export_descriptions.each do |desc| - %li= desc - %p= _('The following items will NOT be exported:') - %ul - %li= _('Job logs and artifacts') - %li= _('Container registry images') - %li= _('CI variables') - %li= _('Pipeline triggers') - %li= _('Webhooks') - %li= _('Any encrypted tokens') - - if project.export_status == :finished - = render Pajamas::ButtonComponent.new(href: download_export_project_path(project), - method: :get, - button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do - = _('Download export') - = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do - = _('Generate new export') - - else - = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do - = _('Export project') += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'export_project_content' } }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title= _('Export project') + + - 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 } + .gl-mb-0 + %p.gl-font-weight-bold= _('The following items will be exported:') + %ul + - project_export_descriptions.each do |desc| + %li= desc + %p.gl-font-weight-bold= _('The following items will NOT be exported:') + %ul + %li= _('Job logs and artifacts') + %li= _('Container registry images') + %li= _('CI variables') + %li= _('Pipeline triggers') + %li= _('Webhooks') + %li= _('Any encrypted tokens') + - if project.export_status == :finished + = render Pajamas::ButtonComponent.new(href: download_export_project_path(project), + method: :get, + button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do + = _('Download export') + = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do + = _('Generate new export') + - else + = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do + = _('Export project') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index b5bbb57d58f..cb341ede9de 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -3,19 +3,19 @@ - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } - has_project_shortcut_buttons = !current_user || current_user.project_shortcut_buttons -- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0) +- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0, ref_type: @ref_type) - if readme_path = @project.repository.readme_path - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") #tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } } .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-5 - #js-last-commit.gl-m-auto + #js-last-commit.gl-m-auto{ data: {ref_type: @ref_type.to_s} } = gl_loading_icon(size: 'md') - if project.licensed_feature_available?(:code_owners) #js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } } .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch - = render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview + = render 'projects/tree/tree_header', tree: @tree - if project.forked? #js-fork-info{ data: vue_fork_divergence_data(project, ref) } diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 59147138834..4ac30547ce3 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -8,10 +8,9 @@ %div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' } = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image') %div - %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } + %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } = @project.name - %span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@project) } - = visibility_level_icon(@project.visibility_level, options: { class: 'icon' }) + = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon') = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2' - if @project.group = render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project' diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml index dc9dc92675d..123520acad8 100644 --- a/app/views/projects/_merge_request_settings_description_text.html.haml +++ b/app/views/projects/_merge_request_settings_description_text.html.haml @@ -1 +1 @@ -%p= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.') +%p.gl-text-secondary= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.') diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 983b8056358..ca1fef6eb32 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -9,7 +9,7 @@ .form-group.gl-form-group.project-name.col-sm-12 = f.label :name, class: 'label-bold' do %span= _("Project name") - = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } + = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { testid: 'project-name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } %small#js-project-name-description.form-text.text-gl-muted = s_("ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.") #js-project-name-error.gl-field-error.gl-mt-2.gl-display-none @@ -35,7 +35,7 @@ .form-group.project-path.col-sm-6 = f.label :path, class: 'label-bold' do %span= _("Project slug") - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_path', username: current_user.username } + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { testid: 'project-path', username: current_user.username } .js-group-namespace-error.form-text.gl-text-red-500.gl-display-none = s_('ProjectsNew|Pick a group or namespace where you want to create this project.') - if current_user.can_create_group? @@ -59,7 +59,7 @@ class: "form-control gl-form-input", rows: 3, maxlength: 250, - data: { qa_selector: 'project_description', + data: { testid: 'project-description', track_label: track_label, track_action: "activate_form_input", track_property: "project_description" } @@ -71,7 +71,7 @@ = f.label :visibility_level, class: 'label-bold' do = s_('ProjectsNew|Visibility Level') = link_to sprite_icon('question-o'), help_page_path('user/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer' - = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'} + = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { testid: 'visibility-radios'} - if !hide_init_with_readme = f.label :project_configuration, class: 'label-bold' do @@ -80,7 +80,7 @@ .form-group = render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_readme]', checked: true, - checkbox_options: { data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c| + checkbox_options: { data: { testid: 'initialize-with-readme-checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c| - c.with_label do = s_('ProjectsNew|Initialize repository with a README') - c.with_help_text do @@ -88,7 +88,7 @@ .form-group = render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_sast]', - checkbox_options: { data: { qa_selector: 'initialize_with_sast_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c| + checkbox_options: { data: { testid: 'initialize-with-sast-checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c| - c.with_label do = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') - c.with_help_text do @@ -97,5 +97,5 @@ -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675 = render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label -= f.submit _('Create project'), class: "js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true += f.submit _('Create project'), class: "js-create-project-button", data: { testid: 'project-create-button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true = link_button_to _('Cancel'), @parent_group || dashboard_groups_path, data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index dec3199ffe1..12b310f8ba0 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -3,10 +3,14 @@ - issues_count = Projects::AllIssuesCountService.new(project).count - forks_count = Projects::ForksCountService.new(project).count -.sub-section - %h4.danger-title= _('Delete project') - %p - %strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.') - %p - %strong= _('Deleted projects cannot be restored!') - #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(forks_count), stars_count: number_with_delimiter(project.star_count) } } += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-bg-red-50 gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.danger-title= _('Delete project') + + - c.with_body do + %p + %strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.') + %p + %strong= _('Deleted projects cannot be restored!') + #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(forks_count), stars_count: number_with_delimiter(project.star_count) } } diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml index 260c2b2272e..2db78d0f62a 100644 --- a/app/views/projects/_remove_fork.html.haml +++ b/app/views/projects/_remove_fork.html.haml @@ -1,11 +1,15 @@ - return unless @project.forked? && can?(current_user, :remove_fork_project, @project) - remove_form_id = "js-remove-project-fork-form" -.sub-section - %h4.danger-title= _('Remove fork relationship') - %p= remove_fork_project_description_message(@project) += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.danger-title= _('Remove fork relationship') + %p.gl-new-card-description + = remove_fork_project_description_message(@project) - = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f| - %p - %strong= _('After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks.') - .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) } + - c.with_body do + = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f| + %p + %strong= _('After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks.') + .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 0a83efdb3b8..c2382a66132 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -5,7 +5,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe - %p= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + %p.gl-text-secondary= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .settings-content - if ::Gitlab::ServiceDesk.supported? .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project), @@ -19,6 +19,7 @@ outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", templates: available_service_desk_templates_for(@project), - public_project: "#{@project.public?}" } } + public_project: "#{@project.public?}", + custom_email_endpoint: project_service_desk_custom_email_path(@project) } } - elsif show_callout?('promote_service_desk_dismissed') = render 'shared/promotions/promote_servicedesk' diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index 93fc8d12960..fe84a83c43c 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -3,21 +3,29 @@ - hidden_input_id = "new_namespace_id" - initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id, project_id: @project.id } -.sub-section{ data: { qa_selector: 'transfer_project_content' } } - %h4.danger-title= _('Transfer project') - = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f| - .form-group += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'transfer_project_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .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') } - %p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - %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?") - = link_to _('Create one'), new_group_path, target: '_blank' - = _('Things to be aware of before transferring:') - %ul - %li= _("Be careful. Changing the project's namespace can have unintended side effects.") - %li= _('You can only transfer the project to namespaces you manage.') - %li= _('You will need to update your local repositories to point to the new location.') - %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') - = hidden_field_tag(hidden_input_id) - .js-transfer-project-form{ data: initial_data } + = _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + + - 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 } + %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?") + = link_to _('Create one'), new_group_path, target: '_blank' + %p.gl-font-weight-bold= _('Things to be aware of before transferring:') + %ul + %li= _("Be careful. Changing the project's namespace can have unintended side effects.") + %li= _('You can only transfer the project to namespaces you manage.') + %li= _('You will need to update your local repositories to point to the new location.') + %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') + = hidden_field_tag(hidden_input_id) + .js-transfer-project-form{ data: initial_data } diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index e82e0972d82..82cfb0435c7 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -7,7 +7,7 @@ .landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] } .col-12.col-md-3.p-0 .svg-content - = image_tag 'illustrations/wiki_login_empty.svg' + = image_tag 'illustrations/empty-state/empty-wiki-md.svg' .col-12.col-md-9.text-center.text-md-left.pl-md-0.pl-sm-3.mb-4 %h4 = _("This project does not have a wiki homepage yet") diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index e5566882371..543bdaf46df 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -24,7 +24,7 @@ - if !expanded -# Data info will be removed once we migrate this to use GraphQL -# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406 - #js-view-blob-app{ data: vue_blob_app_data(project, blob, ref) } + #js-view-blob-app{ data: vue_blob_app_data(project, blob, ref).merge(ref_type: @ref_type.to_s) } = gl_loading_icon(size: 'md') - else %article.file-holder diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index 417c11ba37a..539453bf6af 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -1,21 +1,21 @@ - blame = local_assigns.fetch(:blame, false) .nav-block .tree-ref-container - .tree-ref-holder + .tree-ref-holder.gl-max-w-26 #js-tree-ref-switcher{ data: { project_id: @project.id, project_root_path: project_path(@project), ref: current_ref, ref_type: @ref_type.to_s } } %ul.breadcrumb.repo-breadcrumb %li.breadcrumb-item - = link_to project_tree_path(@project, @ref) do + = link_to project_tree_path(@project, @ref, ref_type: @ref_type) do = @project.path - path_breadcrumbs do |title, path| - title = truncate(title, length: 40) %li.breadcrumb-item - if path == @path - = link_to project_blob_path(@project, tree_join(@ref, path)) do + = link_to project_blob_path(@project, tree_join(@ref, path), ref_type: @ref_type) do %strong= title - else - = link_to title, project_tree_path(@project, tree_join(@ref, path)) + = link_to title, project_tree_path(@project, tree_join(@ref, path), ref_type: @ref_type) .tree-controls.gl-children-ml-sm-3< = render 'projects/find_file_link' diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 68520d36858..49a29e1dcb7 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -8,23 +8,17 @@ = sprite_icon('branch', size: 12) = ref - if current_action?(:edit) || current_action?(:update) - %span.float-left.gl-mr-3 - = text_field_tag 'file_path', (params[:file_path] || @path), - class: 'form-control gl-form-input new-file-path js-file-path-name-input' - = render 'template_selectors' + - input_options = { id: 'file_path', name: 'file_path', value: (params[:file_path] || @path), class: 'new-file-path js-file-path-name-input' } + = render 'filepath_form', input_options: input_options - if current_action?(:new) || current_action?(:create) - %span.float-left.gl-mr-3 - \/ - = text_field_tag 'file_name', params[:file_name], placeholder: "Filename", data: { qa_selector: 'file_name_field' }, - required: true, class: 'form-control gl-form-input new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '') - = render 'template_selectors' + - input_options = { id: 'file_name', name: 'file_name', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : ''), required: true, placeholder: "Filename", testid: 'file_name_field', class: 'new-file-name js-file-path-name-input' } + = render 'filepath_form', input_options: input_options - if should_suggest_gitlab_ci_yml? - .js-suggest-gitlab-ci-yml{ data: { target: '#gitlab-ci-yml-selector', - track_label: 'suggest_gitlab_ci_yml', - merge_request_path: params[:mr_path], - dismiss_key: @project.id, - human_access: human_access } } + .js-suggest-gitlab-ci-yml{ data: { track_label: 'suggest_gitlab_ci_yml', + merge_request_path: params[:mr_path], + dismiss_key: @project.id, + human_access: human_access } } - if Feature.enabled?(:source_editor_toolbar, current_user) #editor-toolbar diff --git a/app/views/projects/blob/_filepath_form.html.haml b/app/views/projects/blob/_filepath_form.html.haml new file mode 100644 index 00000000000..53c681fd264 --- /dev/null +++ b/app/views/projects/blob/_filepath_form.html.haml @@ -0,0 +1 @@ += dropdown_data_attr(options: { data: { templates: { licenses: licenses_for_select(@project), gitignore_names: gitignore_names(@project), gitlab_ci_ymls: gitlab_ci_ymls(@project), dockerfile_names: dockerfile_names(@project) }, selected: params[:template], input_options: input_options }}) diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml index 0fa4a90e28b..f645d23aa1c 100644 --- a/app/views/projects/blob/_pipeline_tour_success.html.haml +++ b/app/views/projects/blob/_pipeline_tour_success.html.haml @@ -1,6 +1,6 @@ .js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name, 'go-to-pipelines-path': project_pipelines_path(@project), 'project-merge-requests-path': project_merge_requests_path(@project), - 'example-link': help_page_path('ci/examples/index.md', anchor: 'gitlab-cicd-examples'), + 'example-link': help_page_path('ci/examples/index.md'), 'code-quality-link': help_page_path('ci/testing/code_quality'), 'human-access': @project.team.human_max_access(current_user&.id) } } diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml deleted file mode 100644 index 0bd29ceb563..00000000000 --- a/app/views/projects/blob/_template_selectors.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -.template-selectors-menu.gl-pl-3 - .template-selector-dropdowns-wrap - .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } }) - .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } }) - #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } }) - .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } }) diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index c8cf12c36f9..9ec824f64d4 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -4,13 +4,13 @@ - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco', prefetch: true) -- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer }) +- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, refType: @ref_type.to_s, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer }) .js-signature-container{ data: { 'signatures-path': signatures_path } } = render 'projects/last_push' -#tree-holder.tree-holder +#tree-holder.tree-holder.gl-pt-4 = render 'blob', blob: @blob = render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration? diff --git a/app/views/projects/branch_defaults/_show.html.haml b/app/views/projects/branch_defaults/_show.html.haml index 4ecbc3b7fc8..5906cd34c17 100644 --- a/app/views/projects/branch_defaults/_show.html.haml +++ b/app/views/projects/branch_defaults/_show.html.haml @@ -5,7 +5,7 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch defaults') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('ProjectSettings|Select the default branch for this project, and configure the template for branch names.') .settings-content diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml index 605715e2899..c16c03953c6 100644 --- a/app/views/projects/branch_rules/_show.html.haml +++ b/app/views/projects/branch_rules/_show.html.haml @@ -8,9 +8,9 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch rules') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Define rules for who can push, merge, and the required approvals for each branch.') = link_to(_('Leave feedback.'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/388149', target: '_blank', rel: 'noopener noreferrer') - .settings-content.gl-pr-0 + .settings-content #js-branch-rules{ data: { project_path: @project.full_path, branch_rules_path: project_settings_repository_branch_rules_path(@project), show_code_owners: show_code_owners.to_s, show_status_checks: show_status_checks.to_s, show_approvers: show_approvers.to_s } } diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index ae8d230f356..7c52350f101 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -4,10 +4,10 @@ - mr_status = merge_request_status(related_merge_request) - is_default_branch = branch.name == @repository.root_ref -%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-3!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } } +%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-5! gl-pr-2!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } } .branch-info .gl-display-flex.gl-align-items-center - = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do + = link_to project_tree_path(@project, branch.name, ref_type: 'heads'), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do = branch.name = clipboard_button(text: branch.name, title: _("Copy branch name")) - if is_default_branch diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml index c01e3677c19..8ef7d435420 100644 --- a/app/views/projects/branches/_panel.html.haml +++ b/app/views/projects/branches/_panel.html.haml @@ -7,7 +7,7 @@ - return unless branches.any? -= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }, footer_options: { class: 'gl-new-card-footer' }) do |c| += 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' }, footer_options: { class: 'gl-new-card-footer' }) do |c| - c.with_header do %h3.gl-new-card-title.h5 = panel_title diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index c9dcfaff8c6..963c416ed42 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -1,16 +1,3 @@ - unless @project.empty_repo? - if current_user - .count-badge.btn-group - - if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2 - = link_button_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'has-tooltip fork-btn', icon: 'fork' do - = s_('ProjectOverview|Fork') - - else - - disabled_tooltip = fork_button_disabled_tooltip(@project) - - count_class = 'disabled' unless can?(current_user, :read_code, @project) - - button_class = 'disabled' if disabled_tooltip - - %span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip } - = link_button_to new_project_fork_path(@project), class: "fork-btn #{button_class}", data: { qa_selector: 'fork_button' }, icon: 'fork' do - = s_('ProjectOverview|Fork') - = link_button_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "count has-tooltip fork-count #{count_class}" do - = @project.forks_count + #js-forks-button{ data: fork_button_data_attributes(@project) } diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index d00d9f62999..fffa1ff36b9 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -5,7 +5,7 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Repository cleanup') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary - link_url = 'https://github.com/newren/git-filter-repo' - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url } - link_end = '</a>'.html_safe @@ -21,7 +21,7 @@ .gl-mb-3 %h5.gl-mt-0 = _("Upload object map") - %button.gl-button.btn.btn-default.js-choose-file{ type: "button" } + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-file' }) do = _("Choose a file") %span.gl-ml-3.js-filename = _("No file selected") diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index a0f47f375f7..010f15ec6f2 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -7,7 +7,7 @@ - context_commits = @context_commits&.map { |commit| commit.present(current_user: current_user) } - hidden = @hidden_commit_count -- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits| +- commits.chunk { |commit| local_committed_date(commit, current_user) }.each do |day, daily_commits| %li.js-commit-header.gl-py-2.gl-border-b{ data: { day: day } } %span.day.font-weight-bold= l(day, format: '%b %d, %Y') @@ -44,7 +44,8 @@ - if commits.size == 0 && context_commits.nil? .commits-empty.gl-mt-6 - = custom_icon('illustration_no_commits') + .svg-content.svg-150 + = image_tag('illustrations/empty-state/empty-search-md.svg') %h4 = _('Your search didn\'t match any commits.') %p diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 8afc9ade3e1..1034f06f722 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Commits") - add_page_specific_style 'page_bundles/tree' +- add_page_specific_style 'page_bundles/merge_request' - page_title _("Commits"), @ref = content_for :meta_tags do diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 5b6f7c392dd..69c7a497c7d 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -1,13 +1,14 @@ - add_to_breadcrumbs s_("CompareRevisions|Compare revisions"), project_compare_index_path(@project) - page_title "#{params[:from]} to #{params[:to]}" +- has_diff = @commits.present? || @diffs.present? && @diffs.diff_files.present? +-# Only show commit list in the first page +- hide_commit_list = params[:page].present? && params[:page] != '1' .sub-header-block.gl-border-b-0.gl-mb-0.gl-pt-4 .js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } } #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) } -- if @commits.present? || @diffs.present? - -# Only show commit list in the first page - - hide_commit_list = params[:page].present? && params[:page] != '1' +- if has_diff = render "projects/commits/commit_list" unless hide_commit_list = render "projects/diffs/diffs", diffs: @diffs, diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml index 283408ffa63..cdf3562a8ed 100644 --- a/app/views/projects/confluences/show.html.haml +++ b/app/views/projects/confluences/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title _('Confluence') - page_title _('Confluence') - add_page_specific_style 'page_bundles/wiki' -= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do += render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do %h4 = s_('WikiEmpty|Confluence is enabled') %p diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml index 997443d5fa9..0044ff4dc24 100644 --- a/app/views/projects/deploy_keys/edit.html.haml +++ b/app/views/projects/deploy_keys/edit.html.haml @@ -1,10 +1,8 @@ - page_title _('Edit Deploy Key') %h1.page-title.gl-font-size-h-display= _('Edit Deploy Key') -%hr -%div - = gitlab_ui_form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f| - = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } - .form-actions - = f.submit _('Save changes'), pajamas_button: true - = link_button_to _('Cancel'), project_settings_repository_path(@project) += gitlab_ui_form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f| + = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } + .gl-display-flex.gl-mt-6.gl-gap-3 + = f.submit _('Save changes'), pajamas_button: true + = link_button_to _('Cancel'), project_settings_repository_path(@project, anchor: 'js-deploy-keys-settings') diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 98e8c2dd61b..662f1bb158d 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -17,7 +17,7 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = _('Collapse') - %p= _('Update your project name, topics, description, and avatar.') + %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.') .settings-content= render 'projects/settings/general' %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { qa_selector: 'visibility_features_permissions_content' } } @@ -25,7 +25,7 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.') + %p.gl-text-secondary= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default emoji reactions.') .settings-content = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f| @@ -40,15 +40,13 @@ - c.with_body do = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } -= render_if_exists 'projects/settings/analytics', expanded: expanded - %section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('ProjectSettings|Badges') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_('ProjectSettings|Customize this project\'s badges.') = link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges') .settings-content @@ -60,52 +58,59 @@ = render 'projects/service_desk_settings' -= render_if_exists 'product_analytics/project_settings', expanded: expanded - %section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') + %p.gl-text-secondary= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') .settings-content = render_if_exists 'projects/settings/restore', project: @project - .sub-section - %h4= _('Housekeeping') - %p - = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') - = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' - = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do - = _('Run housekeeping') + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title= _('Housekeeping') + %p.gl-new-card-description + = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') + = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' - .gl-display-inline-flex - #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } + - c.with_body do + .gl-display-flex.gl-flex-wrap.gl-gap-3 + = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do + = _('Run housekeeping') + #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } = render 'export', project: @project = render_if_exists 'projects/settings/archive' - .sub-section.rename-repository - %h4.warning-title= _('Change path') - = render 'projects/errors' - = gitlab_ui_form_for @project do |f| - .form-group + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card rename-repository' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .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') } - %p= _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - %ul - %li= _("Be careful. Renaming a project's repository can have unintended side effects.") - %li= _('You will need to update your local repositories to point to the new location.') - - if @project.deployment_platform.present? - %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') - = f.label :path, _('Path'), class: 'label-bold' + = _("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 } + + - c.with_body do + = render 'projects/errors' + = gitlab_ui_form_for @project do |f| .form-group - .input-group - .input-group-prepend - .input-group-text - #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ - = f.text_field :path, class: 'form-control', data: { qa_selector: 'project_path_field' } - = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true + %p + %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.") + = _('You will need to update your local repositories to point to the new location.') + - if @project.deployment_platform.present? + %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') + = f.label :path, _('Path'), class: 'label-bold' + .form-group + .input-group + .input-group-prepend + .input-group-text + #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ + = f.text_field :path, class: 'form-control gl-form-input-xl', data: { qa_selector: 'project_path_field' } + = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true = render 'transfer', project: @project diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml index e473a6f3cfd..ec0830ad153 100644 --- a/app/views/projects/feature_flags/index.html.haml +++ b/app/views/projects/feature_flags/index.html.haml @@ -3,7 +3,7 @@ #feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json), "project-id" => @project.id, "project-name" => @project.name, - "error-state-svg-path" => image_path('illustrations/feature_flag.svg'), + "error-state-svg-path" => image_path('illustrations/empty-state/empty-feature-flag-md.svg'), "feature-flags-help-page-path" => help_page_path("operations/feature_flags"), "feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"), "feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "go-application-example"), diff --git a/app/views/projects/feature_flags_user_lists/index.html.haml b/app/views/projects/feature_flags_user_lists/index.html.haml index c0e98b27d29..2e279d71758 100644 --- a/app/views/projects/feature_flags_user_lists/index.html.haml +++ b/app/views/projects/feature_flags_user_lists/index.html.haml @@ -5,4 +5,4 @@ #js-user-lists{ data: { project_id: @project.id, feature_flags_help_page_path: help_page_path("operations/feature_flags"), new_user_list_path: can?(current_user, :create_feature_flag, @project) ? new_project_feature_flags_user_list_path(@project): nil, - error_state_svg_path: image_path('illustrations/feature_flag.svg') } } + error_state_svg_path: image_path('illustrations/empty-state/empty-feature-flag-md.svg') } } diff --git a/app/views/projects/feature_flags_user_lists/show.html.haml b/app/views/projects/feature_flags_user_lists/show.html.haml index 5c4e93e7707..58276eedc09 100644 --- a/app/views/projects/feature_flags_user_lists/show.html.haml +++ b/app/views/projects/feature_flags_user_lists/show.html.haml @@ -5,4 +5,4 @@ #js-edit-user-list{ data: { project_id: @project.id, user_list_iid: @user_list.iid, - empty_state_path: image_path('illustrations/feature_flag.svg') } } + empty_state_path: image_path('illustrations/empty-state/empty-feature-flag-md.svg') } } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 7e93e44c463..541b8c1147d 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,9 +1,9 @@ - page_title _("Find File"), @ref - add_page_specific_style 'page_bundles/tree' -.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } +.file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } .nav-block.gl-xs-mr-0 - .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full + .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full.gl-max-w-26 #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } } %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap %li.breadcrumb-item.gl-white-space-nowrap diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index 30084e3310b..2eaf89be4ef 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -7,7 +7,8 @@ %hr - if @hook_log.oversize? - = button_tag _("Resend Request"), class: "btn gl-button btn-default float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large") + = render Pajamas::ButtonComponent.new(button_options: { class: "float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large") }) do + = _("Resend Request") - else = link_button_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: 'float-right gl-ml-3' diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml index e6cd8c15809..165e414f75b 100644 --- a/app/views/projects/integrations/shimos/show.html.haml +++ b/app/views/projects/integrations/shimos/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title s_('Shimo|Shimo Workspace') - page_title s_('Shimo|Shimo Workspace') - add_page_specific_style 'page_bundles/wiki' -= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do += render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do %h4 = s_('Shimo|Shimo Workspace integration is enabled') %p diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml index ec233bc9aff..e502457808d 100644 --- a/app/views/projects/issuable/_show.html.haml +++ b/app/views/projects/issuable/_show.html.haml @@ -7,5 +7,4 @@ = render "projects/issues/service_desk/alert_moved_from_service_desk", issue: issuable -= render 'shared/issue_type/details_header', issuable: issuable = render 'shared/issue_type/details_content', issuable: issuable, api_awards_path: api_awards_path diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index f8f57934303..8bfad64c369 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,6 +1,7 @@ - page_title _('Issues') - add_page_specific_style 'page_bundles/issuable_list' - add_page_specific_style 'page_bundles/issues_list' +- add_page_specific_style 'page_bundles/work_items' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues") diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index d344ae6a4e6..64143502b77 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/merge_request' - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title _("New") - page_title _("New Issue") diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml index 9793f21e4a9..2d17719a8c2 100644 --- a/app/views/projects/issues/service_desk.html.haml +++ b/app/views/projects/issues/service_desk.html.haml @@ -15,7 +15,7 @@ can_edit_project_settings: can?(current_user, :admin_project, @project).to_s, service_desk_callout_svg_path: image_path('service_desk_callout.svg'), service_desk_settings_path: edit_project_path(@project, anchor: 'js-service-desk'), - service_desk_help_path: help_page_path('user/project/service_desk'), + service_desk_help_path: help_page_path('user/project/service_desk/index'), is_service_desk_supported: Gitlab::ServiceDesk.supported?.to_s, is_service_desk_enabled: @project.service_desk_enabled?.to_s } } - else diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index d39d292fb53..0073c6b89cd 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -1,5 +1,6 @@ - page_title _("Jobs") - add_page_specific_style 'page_bundles/ci_status' +- add_page_specific_style 'page_bundles/merge_request' - admin = local_assigns.fetch(:admin, false) #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index b151c355b3e..d81855b12ed 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -4,6 +4,7 @@ - add_page_specific_style 'page_bundles/build' - add_page_specific_style 'page_bundles/xterm' - add_page_specific_style 'page_bundles/ci_status' +- add_page_specific_style 'page_bundles/merge_request' = render_if_exists "shared/shared_runners_minutes_limit_flash_message" diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index e1c904d000f..8855e8024b3 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -23,7 +23,7 @@ = _('Prioritized labels') .gl-new-card-description = _('Drag to reorder prioritized labels and change their relative priority.') - .js-prioritized-labels.gl-px-3.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } + .js-prioritized-labels.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } = render 'shared/empty_states/priority_labels' - if @prioritized_labels.any? @@ -37,8 +37,8 @@ .gl-new-card-header .gl-new-card-title-wrapper %h3.gl-new-card-title{ class: ('hide' if hide) }= _('Other labels') - .gl-new-card-body - .js-other-labels.manage-labels-list.gl-new-card-content + .gl-new-card-body.gl-px-0 + .js-other-labels.manage-labels-list = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project } = paginate @labels, theme: 'gitlab' diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 5ea67376a86..69e2487152e 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -106,7 +106,7 @@ - if @merge_request.can_be_cherry_picked? = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit -#js-review-bar{ data: { new_comment_template_path: profile_comment_templates_path } } +#js-review-bar{ data: review_bar_data(@merge_request, current_user) } - if current_user && Feature.enabled?(:mr_experience_survey, current_user) #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } } diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 606d4e06d33..9ec4363fa9a 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -13,7 +13,7 @@ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}'; window.gl.mrWidgetData.code_coverage_check_help_page_path = '#{help_page_path('ci/testing/code_coverage.md', anchor: 'coverage-check-approval-rule')}'; window.gl.mrWidgetData.security_configuration_path = '#{project_security_configuration_path(@project)}'; - window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md')}'; + window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_scanning_of_cyclonedx_files')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}'; window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/approvals/index.md")}'; window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/empty-state/empty-pipeline-md.svg')}'; diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index 3facca4d4f7..f8d0e2d2a15 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -3,6 +3,7 @@ - breadcrumb_title _("Merge conflicts") - page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests") - add_page_specific_style 'page_bundles/merge_conflicts' +- add_page_specific_style 'page_bundles/merge_request' = render "projects/merge_requests/mr_title", hide_gutter_toggle: true diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml index 6a8894384df..f2c2700b012 100644 --- a/app/views/projects/merge_requests/creations/new.html.haml +++ b/app/views/projects/merge_requests/creations/new.html.haml @@ -3,8 +3,15 @@ - page_title _("New merge request") - add_page_specific_style 'page_bundles/pipelines' - add_page_specific_style 'page_bundles/ci_status' +- add_page_specific_style 'page_bundles/merge_request' -- if @merge_request.can_be_created && !params[:change_branches] +- conflicting_mr = @merge_request.existing_mrs_targeting_same_branch.first + +- if @merge_request.can_be_created && !params[:change_branches] && !conflicting_mr = render 'new_submit' - else + - if conflicting_mr + - link_to_mr = link_to(conflicting_mr.to_reference, project_merge_request_path(@project, conflicting_mr)) + - flash.now[:alert] = safe_format(s_("These branches already have an open merge request: %{link_to_mr}. Select a different source or target branch."), link_to_mr: link_to_mr) + = render 'new_compare' diff --git a/app/views/projects/merge_requests/diffs.html.haml b/app/views/projects/merge_requests/diffs.html.haml index 1ef212ee5ce..03306e98407 100644 --- a/app/views/projects/merge_requests/diffs.html.haml +++ b/app/views/projects/merge_requests/diffs.html.haml @@ -1 +1,3 @@ +- add_page_specific_style 'page_bundles/merge_request' + = render 'page' diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml index 77cc69f32ab..3aca4783241 100644 --- a/app/views/projects/merge_requests/edit.html.haml +++ b/app/views/projects/merge_requests/edit.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/merge_request' - add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project) - breadcrumb_title @merge_request.to_reference - page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests") diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 79da09c5205..e2d3e082289 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -6,6 +6,7 @@ - page_title _("Merge requests") - new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request') - add_page_specific_style 'page_bundles/issuable_list' +- add_page_specific_style 'page_bundles/merge_request' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} merge requests") diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 1ef212ee5ce..03306e98407 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1 +1,3 @@ +- add_page_specific_style 'page_bundles/merge_request' + = render 'page' diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 110bc8d82f8..a1c89a9dd30 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -8,32 +8,49 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Mirroring repositories') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.') = link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' - .settings-content - - if mirror_settings_enabled - = gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| - .panel.panel-default - .panel-body - %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' } - - = render 'projects/mirrors/instructions' - - = render 'projects/mirrors/mirror_repos_form', f: f - = render 'projects/mirrors/branch_filter' - - .panel-footer - = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' } - - else - = render Pajamas::AlertComponent.new(dismissible: false) do |c| - - c.with_body do - = _('Mirror settings are only available to GitLab administrators.') - - = render 'projects/mirrors/mirror_repos_list' + .settings-content + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h5.gl-new-card-title + = _('Mirrored repositories') + .gl-new-card-count + = sprite_icon('earth', css_class: 'gl-mr-2') + %span.js-mirrored-repo-count + = mirrored_repositories_count + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'add-new-mirror' } }) do + = _('Add new') + - c.with_body do + - if mirror_settings_enabled + .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content + %h4.gl-mt-0 + = s_('Profiles|Add new mirror repository') + = gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| + %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' } + + = render 'projects/mirrors/instructions' + + = render 'projects/mirrors/mirror_repos_form', f: f + + = render 'projects/mirrors/branch_filter' + + = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' } + + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') + + - else + = render Pajamas::AlertComponent.new(dismissible: false) do |c| + - c.with_body do + = _('Mirror settings are only available to GitLab administrators.') + + = render 'projects/mirrors/mirror_repos_list' diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml index 185d86245c5..0debd13709d 100644 --- a/app/views/projects/mirrors/_mirror_repos_list.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -1,49 +1,41 @@ - mirror_settings_enabled = can?(current_user, :admin_remote_mirror, @project) -.panel.panel-default - .table-responsive - - if !@project.mirror? && @project.remote_mirrors.count == 0 - = render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-5' }) do |c| - - c.with_header do - %strong - = _('Mirrored repositories') + ' (0)' - - c.with_body do - = _('There are currently no mirrored repositories.') - - else - %table.table.gl-table.gl-mt-5 - %thead - %tr - %th - = _('Mirrored repositories') - = render_if_exists 'projects/mirrors/mirrored_repositories_count' - %th= _('Direction') - %th= _('Last update attempt') - %th= _('Last successful update') - %th - %th - %tbody.js-mirrors-table-body - = render_if_exists 'projects/mirrors/table_pull_row' - - @project.remote_mirrors.each_with_index do |mirror, index| - - next if mirror.new_record? - %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row_container' } } - %td{ data: { qa_selector: 'mirror_repository_url_content' } } - = mirror.safe_url || _('Invalid URL') - = render_if_exists 'projects/mirrors/mirror_branches_setting_badge', record: mirror - %td= _('Push') - %td - = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') - %td{ data: { qa_selector: 'mirror_last_update_at_content' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') - %td - - if mirror.disabled? - = render 'projects/mirrors/disabled_mirror_badge' - - if mirror.last_error.present? - = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge_content' }, title: html_escape(mirror.last_error.try(:strip)) } - %td.gl-display-flex - - 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') - = render 'shared/remote_mirror_update_button', remote_mirror: mirror - = render Pajamas::ButtonComponent.new(variant: :danger, - icon: 'remove', - button_options: { class: 'js-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } }) +.table-responsive.gl-mb-0 + - if !@project.mirror? && @project.remote_mirrors.count == 0 + .gl-new-card-empty.gl-px-5.gl-py-4= _('There are currently no mirrored repositories.') + - else + %table.table.b-table.gl-table.b-table-stacked-md + %thead.d-none.d-md-table-header-group + %tr + %th= _('Repository') + %th= _('Direction') + %th= _('Last update attempt') + %th= _('Last successful update') + %th + %th + %tbody.js-mirrors-table-body + = render_if_exists 'projects/mirrors/table_pull_row' + - @project.remote_mirrors.each_with_index do |mirror, index| + - next if mirror.new_record? + %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row_container' } } + %td{ data: { qa_selector: 'mirror_repository_url_content' } } + = mirror.safe_url || _('Invalid URL') + = render_if_exists 'projects/mirrors/mirror_branches_setting_badge', record: mirror + %td= _('Push') + %td + = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') + %td{ data: { qa_selector: 'mirror_last_update_at_content' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td + - if mirror.disabled? + = render 'projects/mirrors/disabled_mirror_badge' + - if mirror.last_error.present? + = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge_content' }, title: html_escape(mirror.last_error.try(:strip)) } + %td + - 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') + = render 'shared/remote_mirror_update_button', remote_mirror: mirror + = render Pajamas::ButtonComponent.new(variant: :danger, + icon: 'remove', + button_options: { class: 'js-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } }) diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml index 2caba2ae9be..a1c6376e9b4 100644 --- a/app/views/projects/ml/models/index.html.haml +++ b/app/views/projects/ml/models/index.html.haml @@ -1,5 +1,4 @@ - breadcrumb_title s_('ModelRegistry|Model registry') - page_title s_('ModelRegistry|Model registry') -- presenter = ::Ml::ModelsIndexPresenter.new(@models) -#js-index-ml-models{ data: { view_model: presenter.present } } += render(Projects::Ml::ModelsIndexComponent.new(models: @models)) diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 8c94a18e1b0..e3cc9199352 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -2,8 +2,7 @@ - if note_editable || !is_current_user %div{ class: "dropdown more-actions note-actions-item gl-ml-0!" } - = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-icon', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do - = sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon') + = 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) diff --git a/app/views/projects/packages/infrastructure_registry/show.html.haml b/app/views/projects/packages/infrastructure_registry/show.html.haml index 8624fdacda7..8410ac0091d 100644 --- a/app/views/projects/packages/infrastructure_registry/show.html.haml +++ b/app/views/projects/packages/infrastructure_registry/show.html.haml @@ -7,7 +7,7 @@ .col-12 #js-vue-packages-detail{ data: { package: package_from_presenter(@package), can_delete: can?(current_user, :destroy_package, @project).to_s, - svg_path: image_path('illustrations/no-packages.svg'), + svg_path: image_path('illustrations/empty-state/empty-package-md.svg'), project_name: @project.name, project_path: @project.root_ancestor.full_path, gitlab_host: Gitlab.config.gitlab.host, diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml index 5397828d48e..9fb265b08c2 100644 --- a/app/views/projects/packages/packages/index.html.haml +++ b/app/views/projects/packages/packages/index.html.haml @@ -6,7 +6,7 @@ full_path: @project.full_path, endpoint: project_packages_path(@project), page_type: 'projects', - empty_list_illustration: image_path('illustrations/no-packages.svg'), + empty_list_illustration: image_path('illustrations/empty-state/empty-package-md.svg'), npm_instance_url: package_registry_instance_url(:npm), project_list_url: project_packages_path(@project), settings_path: show_package_registry_settings(@project) ? project_settings_packages_and_registries_path(@project) : '', diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml index 4c8ec21db39..b1ec7a362b7 100644 --- a/app/views/projects/pages/_pages_settings.html.haml +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -1,7 +1,6 @@ -- 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 +- 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 -- return unless can_edit_max_page_size || can_enforce_https_only = 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 @@ -17,14 +16,13 @@ %p.gl-pl-6 = s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end } - - if Feature.enabled?(:pages_unique_domain, @project) - .form-group - = f.fields_for :project_setting do |settings| - = settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled, - s_('GitLabPages|Use unique domain'), - label_options: { class: 'label-bold' } - %p.gl-pl-6 - = s_("GitLabPages|When enabled, a unique domain is generated to access pages.").html_safe + .form-group + = f.fields_for :project_setting do |settings| + = settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled, + s_('GitLabPages|Use unique domain'), + label_options: { class: 'label-bold' } + %p.gl-pl-6 + = s_("GitLabPages|When enabled, a unique domain is generated to access pages.").html_safe .gl-mt-3 = f.submit s_('GitLabPages|Save changes'), pajamas_button: true diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 88a60b1fb06..5051fc6a5f5 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -2,6 +2,7 @@ - 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') } } diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index e16a2235e53..c3d6d0c5971 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,6 +1,7 @@ - page_title _('CI/CD Analytics') #js-project-pipelines-charts-app{ data: { project_path: @project.full_path, + project_id: @project.id, should_render_dora_charts: should_render_dora_charts.to_s, should_render_quality_summary: should_render_quality_summary.to_s, failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'), diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index bdf09e5356f..435edde319b 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -6,6 +6,7 @@ - add_page_specific_style 'page_bundles/pipeline' - add_page_specific_style 'page_bundles/reports' - add_page_specific_style 'page_bundles/ci_status' +- add_page_specific_style 'page_bundles/merge_request' - add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid }) .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml index 93c53fc99fc..a0677ee2385 100644 --- a/app/views/projects/project_templates/_template.html.haml +++ b/app/views/projects/project_templates/_template.html.haml @@ -1,4 +1,4 @@ -.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_container' } } +.template-option.d-flex.align-items-center{ data: { testid: 'template-option-container' } } .logo.gl-mr-3.px-1 = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}" .description @@ -13,5 +13,5 @@ %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name, 'data-testid': "use_template_#{template.name}" } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_action: "click_button", track_value: "" } } - %span{ data: { qa_selector: 'use_template_button' } } + %span{ data: { testid: 'use-template-button' } } = _("Use template") diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index d67c3dc19d7..8dcc59a09d0 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -1,23 +1,20 @@ = gitlab_ui_form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' } - = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c| - - c.with_header do - = _('Protect a tag') - - c.with_body do - = form_errors(@protected_tag) - .form-group.row - = f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right' - .col-md-10.protected-tags-dropdown - = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f } - .form-text.text-muted - - wildcards_url = help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags') - - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } - = html_escape(_("%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}v*%{code_tag_end} or %{code_tag_start}*-release%{code_tag_end} are supported.")) % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>'.html_safe, code_tag_start: '<code>'.html_safe, code_tag_end: '</code>'.html_safe } - .form-group.row - = f.label :create_access_levels_attributes, _('Allowed to create:'), class: 'col-md-2 text-left text-md-right' - .col-md-10 - .create_access_levels-container - = yield :create_access_levels + = form_errors(@protected_tag) + .form-group + = f.label :name, _('Tag') + .protected-tags-dropdown + = render partial: "projects/protected_tags/shared/dropdown", locals: { f: f } + .form-text.text-muted + - wildcards_url = help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags') + - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } + = html_escape(_("%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}v*%{code_tag_end} or %{code_tag_start}*-release%{code_tag_end} are supported.")) % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>'.html_safe, code_tag_start: '<code>'.html_safe, code_tag_end: '</code>'.html_safe } + .form-group + = f.label :create_access_levels_attributes, _('Allowed to create') + .create_access_levels-container + = yield :create_access_levels + + = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' } + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') - - c.with_footer do - = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' } diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml index 9d5d649bc40..758df7b3c1e 100644 --- a/app/views/projects/protected_tags/shared/_dropdown.html.haml +++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml @@ -1,7 +1,7 @@ = f.hidden_field(:name) = dropdown_tag(s_('ProtectedBranch|Select tag or create wildcard'), - options: { toggle_class: 'js-protected-tag-select js-filter-submit wide monospace', + options: { toggle_class: 'js-protected-tag-select js-filter-submit wide monospace gl-w-auto!', filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: s_("ProtectedBranch|Search protected tags"), footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index a016ccf8656..f71ecc3a7c5 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -6,15 +6,30 @@ = s_("ProtectedTag|Protected tags") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_("ProtectedTag|Limit access to creating and updating tags.") = link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags") .settings-content - %p + %p.gl-text-secondary = s_("ProtectedTag|By default, protected tags restrict who can modify the tag.") = link_to s_("ProtectedTag|Learn more."), help_page_path("user/project/protected_tags", anchor: "who-can-modify-a-protected-tag") - - if can? current_user, :admin_project, @project - = yield :create_protected_tag - - = yield :tag_list + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Protected tags') + .gl-new-card-count + = sprite_icon('tag', css_class: 'gl-mr-2') + = @protected_tags_count + - if can? current_user, :admin_project, @project + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content" }) do + = _('Add tag') + - c.with_body do + - if can? current_user, :admin_project, @project + .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content + %h4.gl-mt-0 + = _('Protect a tag') + = yield :create_protected_tag + = yield :tag_list diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml index 4fe1c8bd3cb..11e8d3a81c2 100644 --- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml @@ -1,10 +1,10 @@ %tr.js-protected-tag-edit-form{ data: { url: project_protected_tag_path(@project, protected_tag) } } - %td + %td{ data: { label: s_('ProtectedBranch|Tag') }, class: 'gl-vertical-align-middle!' } %span.ref-name= protected_tag.name - if @project.root_ref?(protected_tag.name) = gl_badge_tag s_('ProtectedTags|default'), variant: :info, class: 'gl-ml-2' - %td + %td{ data: { label: s_('ProtectedBranch|Last commit') }, class: 'gl-vertical-align-middle!' } - if protected_tag.wildcard? - matching_tags = protected_tag.matching(repository.tag_names) = link_to pluralize(matching_tags.count, "matching tag"), project_protected_tag_path(@project, protected_tag) @@ -18,5 +18,5 @@ = yield - if can? current_user, :admin_project, @project - %td - = link_button_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary + %td{ data: { label: _('Actions') }, class: 'gl-vertical-align-middle!' } + = link_button_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary, size: :small diff --git a/app/views/projects/protected_tags/shared/_tags_list.html.haml b/app/views/projects/protected_tags/shared/_tags_list.html.haml index 0a85a353e27..66b030a194b 100644 --- a/app/views/projects/protected_tags/shared/_tags_list.html.haml +++ b/app/views/projects/protected_tags/shared/_tags_list.html.haml @@ -1,31 +1,27 @@ .protected-tags-list.js-protected-tags-list - if @protected_tags.empty? - .card-header - = s_('ProtectedBranch|Protected tags (%{tags_count})') % { tags_count: 0 } - %p.settings-message.text-center + .gl-new-card-empty.gl-px-5.gl-py-4 = s_('ProtectedBranch|No tags are protected.') - else - can_admin_project = can?(current_user, :admin_project, @project) - %table.table.table-bordered + %table.table.b-table.gl-table.b-table-stacked-md %colgroup %col{ width: "25%" } %col{ width: "25%" } %col{ width: "50%" } - if can_admin_project %col - %thead + %thead.d-none.d-md-table-header-group %tr %th - = s_('ProtectedBranch|Protected tags (%{tags_count})') % { tags_count: @protected_tags_count } + = s_('ProtectedBranch|Tag') %th = s_('ProtectedBranch|Last commit') %th = s_('ProtectedBranch|Allowed to create') - if can_admin_project %th - %tbody - %tr - = yield + %tbody= yield = paginate @protected_tags, theme: 'gitlab' diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index 32a2e36c779..2d435a7ce9d 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -27,7 +27,7 @@ - elsif @group_runners.empty? = _('This group does not have any group runners yet.') - - if can?(current_user, :admin_group_runners, @project.group) + - if can?(current_user, :register_group_runners, @project.group) || can?(current_user, :create_runner, @project.group) - group_link_start = "<a href='#{group_runners_path(@project.group)}'>".html_safe - group_link_end = '</a>'.html_safe = s_("Runners|To register them, go to the %{link_start}group's Runners page%{link_end}.").html_safe % { link_start: group_link_start, link_end: group_link_end } diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index 05685c26ac5..e7da3177cde 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -1,18 +1,22 @@ - return unless can?(current_user, :archive_project, @project) -.sub-section - %h4.warning-title += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'export_project_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.warning-title + - if @project.archived? + = _('Unarchive project') + - else + = _('Archive project') + + - c.with_body do - if @project.archived? - = _('Unarchive project') + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') } + %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } + = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do + = _('Unarchive project') - else - = _('Archive project') - - if @project.archived? - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') } - %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } - = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do - = _('Unarchive project') - - else - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') } - %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } - = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do - = _('Archive project') + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') } + %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } + = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do + = _('Archive project') diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index e4af6d59cad..b81c3bc9704 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -4,9 +4,11 @@ - type_plural = _('project access tokens') - @force_desktop_expanded_sidebar = true -.gl-mt-5.js-search-settings-section - %h4.gl-my-0 - = page_title +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title %p.gl-text-secondary - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') } - if current_user.can?(:create_resource_access_tokens, @project) @@ -23,9 +25,21 @@ #js-new-access-token-app{ data: { access_token_type: type } } - - if current_user.can?(:create_resource_access_tokens, @project) - = render_if_exists 'projects/settings/access_tokens/form', - type: type + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Active project access tokens') + .gl-new-card-count + = sprite_icon('token', css_class: 'gl-mr-2') + %span.js-token-count= @active_access_tokens.size + - if current_user.can?(:create_resource_access_tokens, @project) + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-new-token-button' } }) do + = _('Add new token') + - c.with_body do + - if current_user.can?(:create_resource_access_tokens, @project) + .gl-new-card-add-form.gl-mt-3.gl-display-none.js-toggle-content.js-add-new-token-form + = render_if_exists 'projects/settings/access_tokens/form', type: type - #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true - } } + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } } diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 6eccbd245af..d51acc5e708 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -1,6 +1,7 @@ - help_link_public_pipelines = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'change-which-users-can-view-your-pipelines'), target: '_blank', rel: 'noopener noreferrer' - help_link_auto_canceling = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank', rel: 'noopener noreferrer' -- help_link_skip_outdated = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'prevent-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer' +- help_link_prevent_outdated = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'prevent-outdated-deployment-jobs'), target: '_blank', rel: 'noopener noreferrer' +- help_link_prevent_outdated_allow_rollback = link_to sprite_icon('question-o'), help_page_path('ci/environments/deployment_safety', anchor: 'job-retries-for-rollback-deployments'), target: '_blank', rel: 'noopener noreferrer' - help_link_separated_caches = link_to sprite_icon('question-o'), help_page_path('ci/caching/index', anchor: 'cache-key-names'), target: '_blank', rel: 'noopener noreferrer' .row.gl-mt-3 @@ -23,7 +24,12 @@ .form-group = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form| = form.gitlab_ui_checkbox_component :forward_deployment_enabled, _("Prevent outdated deployment jobs"), - help_text: (_('When a deployment job is successful, prevent older deployment jobs that are still pending.') + ' ' + help_link_skip_outdated).html_safe + help_text: (_('When a deployment job is successful, prevent older deployment jobs that are still pending.') + ' ' + help_link_prevent_outdated).html_safe + .gl-pl-6 + = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form| + = form.gitlab_ui_checkbox_component :forward_deployment_rollback_allowed, _("Allow job retries for rollback deployments"), + help_text: (_('Allow job retries even if the deployment job is outdated.') + ' ' + help_link_prevent_outdated_allow_rollback).html_safe, + checkbox_options: { class: 'gl-pl-6' } .form-group = f.gitlab_ui_checkbox_component :ci_separated_caches, diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 007169809c9..6de39058455 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -11,7 +11,7 @@ = _("General pipelines") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("Customize your pipeline configuration.") .settings-content = render 'form' @@ -22,7 +22,7 @@ = s_('CICD|Auto DevOps') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %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 } @@ -40,7 +40,7 @@ = _("Runners") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expand_runners ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("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 @@ -56,7 +56,7 @@ = _("Artifacts") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("A job artifact is an archive of files and directories saved by a job when it finishes.") .settings-content #js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/jobs/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } } @@ -70,10 +70,10 @@ %section.settings.no-animate#js-pipeline-triggers{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _("Pipeline triggers") + = _("Pipeline trigger tokens") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("Trigger a pipeline for a branch or tag by generating a trigger token and using it with an API call. The token impersonates a user's project access and permissions.") = link_to _('Learn more.'), help_page_path('ci/triggers/index'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -88,7 +88,7 @@ = _("Deploy freezes") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary - freeze_period_docs = help_page_path('user/project/releases/index', anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze') - freeze_period_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: freeze_period_docs } = html_escape(s_('DeployFreeze|Add a freeze period to prevent unintended releases during a period of time for a given environment. You must update the deployment jobs in %{filename} according to the deploy freezes added here. %{freeze_period_link_start}Learn more.%{freeze_period_link_end}')) % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe, filename: tag.code('.gitlab-ci.yml') } @@ -106,7 +106,7 @@ = _("Token Access") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("Control how the CI_JOB_TOKEN CI/CD variable is used for API access between projects.") .settings-content = render 'ci/token_access/index' @@ -118,7 +118,7 @@ = _("Secure Files") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = _("Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.") = link_to _('Learn more'), help_page_path('ci/secure_files/index'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/projects/settings/integrations/_form.html.haml b/app/views/projects/settings/integrations/_form.html.haml index 39dfd410727..97c7729de44 100644 --- a/app/views/projects/settings/integrations/_form.html.haml +++ b/app/views/projects/settings/integrations/_form.html.haml @@ -14,10 +14,10 @@ - if integration.to_param === 'slack' = render 'shared/integrations/slack_notifications_deprecation_alert' -%h2.gl-mb-4 +%h2.gl-mb-0.gl-display-flex.gl-align-items-center.gl-gap-3 = integration.title - if integration.operating? - = sprite_icon('check', css_class: 'gl-text-green-500') + = render Pajamas::BadgeComponent.new(s_('FeatureFlags|Active'), variant: 'success') = 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 59cdda5bb92..6c0c99543cc 100644 --- a/app/views/projects/settings/integrations/index.html.haml +++ b/app/views/projects/settings/integrations/index.html.haml @@ -8,5 +8,5 @@ %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= _("%{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 } + %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 } = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index 7433e81c11c..398c7758d66 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -9,7 +9,7 @@ = _('Alerts') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = _('Expand') - %p + %p.gl-text-secondary = _('Display alerts from all configured monitoring tools.') = link_to _('Learn more.'), help_page_path('operations/incident_management/integrations.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 5d89790ef9f..1cfdd7086d9 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -8,7 +8,7 @@ = _('Error tracking') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = _('Expand') - %p + %p.gl-text-secondary = _('Link Sentry to GitLab to discover and view the errors your application generates.') = link_to _('Learn more.'), help_page_path('operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/projects/tracing/show.html.haml b/app/views/projects/tracing/show.html.haml new file mode 100644 index 00000000000..4ba316a0b5c --- /dev/null +++ b/app/views/projects/tracing/show.html.haml @@ -0,0 +1,5 @@ +- 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/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index c834a0bc818..a4ed19c2fc9 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,5 +1,3 @@ -- is_project_overview = local_assigns.fetch(:is_project_overview, false) - .tree-ref-container.gl-display-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0 .tree-ref-holder.gl-max-w-26{ data: { qa_selector: 'ref_dropdown_container' } } #js-tree-ref-switcher{ data: { project_id: @project.id, ref_type: @ref_type.to_s, project_root_path: project_path(@project) } } @@ -10,7 +8,7 @@ .tree-controls .d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3.gl-first-child-ml-sm-0< = render_if_exists 'projects/tree/lock_link' - #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref), is_project_overview: is_project_overview.to_s } } + #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref) } } = render 'projects/find_file_link' = render 'shared/web_ide_button', blob: nil diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index fbbf1c04613..3c3f9eb7390 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -1,8 +1,9 @@ +- ref_type_enum_value = @ref_type&.upcase - add_page_specific_style 'page_bundles/tree' - current_route_path = request.fullpath.match(%r{-/tree/[^/]+/(.+$)}).to_a[1] -- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) +- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "", refType: ref_type_enum_value}) - add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path }) -- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"}) +- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/", refType: ref_type_enum_value}) - breadcrumb_title _("Repository") - page_title @path.presence || _("Files"), @ref diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index b621f1ab3ed..b7e226b009c 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -1,3 +1,4 @@ +- show_cancel_button = local_assigns.fetch(:show_cancel_button, false) = gitlab_ui_form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f| = form_errors(@trigger) @@ -9,3 +10,6 @@ = f.label :key, s_("Trigger|Description"), class: "label-bold" = f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: s_("Trigger|Trigger description") = f.submit btn_text, pajamas_button: true + - if show_cancel_button + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index b68aad24b50..7b6915b7b85 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,17 +1,38 @@ +- add_form_class = 'gl-display-none' if !form_errors(@trigger) +- hide_class = 'gl-display-none' if form_errors(@trigger) + .row.gl-mt-3.gl-mb-3 .col-lg-12 - = render Pajamas::CardComponent.new do |c| + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header gl-flex-wrap gl-sm-flex-nowrap' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| - c.with_header do - = _("Manage your project's triggers") + .gl-new-card-title-wrapper + %h5.gl-new-card-title + = _("Active pipeline trigger tokens") + .gl-new-card-count + = sprite_icon('token', css_class: 'gl-mr-2') + = @triggers.size + .gl-new-card-actions.gl-display-flex.gl-justify-content-end.gl-w-full.gl-sm-w-auto.gl-mt-3.gl-sm-mt-0 + = render Pajamas::ButtonComponent.new(size: :small, category: :tertiary, button_options: { data: { testid: 'reveal-hide-values-button' } }) do + = _('Reveal values') + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "gl-ml-2 js-toggle-button js-toggle-content #{hide_class}" }) do + = _('Add new token') + - c.with_body do - = render 'projects/triggers/form', btn_text: _('Add trigger') - .gl-mb-5 + .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class } + %h4.gl-mt-0 + = _('Add new pipeline trigger token') + = render 'projects/triggers/form', btn_text: _('Create pipeline trigger token'), show_cancel_button: true + #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } } - - c.with_footer do - %p - = _("These examples show how to trigger this project's pipeline for a branch or tag.") - %p.light + %details.gl-mt-5.gl-border.gl-rounded-base + %summary.gl-py-3.gl-px-5.gl-font-weight-semibold + = _("View trigger token usage examples") + .gl-p-5 + %p.gl-text-secondary + = _("These examples show common methods of triggering a pipeline with a pipeline trigger token. The URL and ID for this project is prefilled.") + + %p.gl-text-secondary = _('In each example, replace %{code_start}TOKEN%{code_end} with the trigger token you generated and replace %{code_start}REF_NAME%{code_end} with the branch or tag name.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } %h5.gl-mt-3 @@ -40,10 +61,10 @@ %h5.gl-mt-3 = _('Pass job variables') - %p.light + %p.gl-text-secondary = _('To pass variables to the triggered pipeline, add %{code_start}variables[VARIABLE]=VALUE%{code_end} to the API request.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } - %p.light + %p.gl-text-secondary = _('cURL:') %pre @@ -54,7 +75,7 @@ -F "ref=REF_NAME" \ -F "variables[RUN_NIGHTLY_BUILD]=true" \ #{builds_trigger_url(@project.id)} - %p.light + %p.gl-text-secondary = _('Webhook:') %pre.gl-mb-0 diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index aad96151678..616af3f3338 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -15,9 +15,10 @@ .row .col-sm-12 - = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.' - %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' } - = s_('UsageQuota|Learn more about usage quotas') + '.' + %p.gl-text-secondary + = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.' + %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' } + = s_('UsageQuota|Learn more about usage quotas') + '.' = gl_tabs_nav({ id: 'js-project-usage-quotas-tabs' }) do = gl_tab_link_to '#storage-quota-tab', item_active: true do diff --git a/app/views/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml index ed2d420ffcd..1edec308283 100644 --- a/app/views/protected_branches/shared/_branches_list.html.haml +++ b/app/views/protected_branches/shared/_branches_list.html.haml @@ -1,12 +1,10 @@ .protected-branches-list.js-protected-branches-list{ data: { testid: 'protected-branches-list' } } - if @protected_branches.empty? - .card-header.bg-white - = s_("ProtectedBranch|Protected branch (%{protected_branches_count})") % { protected_branches_count: 0 } - %p.settings-message.text-center - = s_("ProtectedBranch|There are currently no protected branches, protect a branch with the form above.") + %p.gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content + = s_("ProtectedBranch|There are currently no protected branches, to protect a branch start by creating a new one above.") - else .flash-container - %table.table.table-bordered + %table.table.b-table.gl-table.b-table-stacked-md %colgroup %col{ width: "30%" } %col{ width: "20%" } @@ -34,5 +32,3 @@ %th %tbody = yield - - = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml index d97347b89de..96e6990b080 100644 --- a/app/views/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -1,44 +1,43 @@ = gitlab_ui_form_for [protected_branch_entity, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } - = render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c| - - c.with_header do - = s_("ProtectedBranch|Protect a branch") - - c.with_body do - = form_errors(@protected_branch) - .form-group.row - = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' - .col-sm-12 - - if protected_branch_entity.is_a?(Group) - = f.text_field :name, placeholder: 'prod*', class: 'form-control gl-w-full! gl-form-input-lg' - - else - = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } - .form-text.text-muted - - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules') - - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } - - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' } - - if protected_branch_entity.is_a?(Group) - = (s_("ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe - - else - = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe - .form-group.row - = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12' - .col-sm-12 - = yield :merge_access_levels - .form-group.row - = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push and merge:"), class: 'col-sm-12' - .col-sm-12 - = yield :push_access_levels - .form-group.row - = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-sm-12' - .col-sm-12 - = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle', - label: s_("ProtectedBranch|Allowed to force push"), - label_position: :hidden) do - - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push') - - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } - = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe - = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity - - c.with_footer do - = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true - .js-alert-protected-branch-created-container.gl-mb-5 + = form_errors(@protected_branch) + + %h4.gl-mt-0= s_("ProtectedBranch|Protect a branch") + + .form-group.row + = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' + .col-sm-12 + - if protected_branch_entity.is_a?(Group) + = f.text_field :name, placeholder: 'prod*', class: 'form-control gl-w-full! gl-form-input-lg' + - else + = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } + .form-text.text-muted + - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules') + - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } + - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' } + - if protected_branch_entity.is_a?(Group) + = (s_("ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe + - else + = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe + .form-group.row + = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12' + .col-sm-12 + = yield :merge_access_levels + .form-group.row + = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push and merge:"), class: 'col-sm-12' + .col-sm-12 + = yield :push_access_levels + .form-group.row + = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-sm-12' + .col-sm-12 + = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle', + label: s_("ProtectedBranch|Allowed to force push"), + label_position: :hidden) do + - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push') + - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } + = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe + = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity + = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') diff --git a/app/views/protected_branches/shared/_index.html.haml b/app/views/protected_branches/shared/_index.html.haml index d0e21e38429..dccfefc1cb8 100644 --- a/app/views/protected_branches/shared/_index.html.haml +++ b/app/views/protected_branches/shared/_index.html.haml @@ -7,15 +7,31 @@ = s_("ProtectedBranch|Protected branches") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = s_("ProtectedBranch|Keep stable branches secure and force developers to use merge requests.") = link_to s_("ProtectedBranch|What are protected branches?"), help_page_path("user/project/protected_branches") .settings-content - %p - = s_("ProtectedBranch|By default, protected branches restrict who can modify the branch.") - = link_to s_("ProtectedBranch|Learn more."), help_page_path("user/project/protected_branches", anchor: "who-can-modify-a-protected-branch") + .js-alert-protected-branch-created-container.gl-mt-5 - - if can_admin_entity - = content_for :create_protected_branch + = 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 + %h3.gl-new-card-title + = s_("ProtectedBranch|Protected branches") + .gl-new-card-count + = sprite_icon('branch', css_class: 'gl-mr-2') + %span= @protected_branches.size + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'add-protected-branch-button' } }) do + = _('Add protected branch') + .gl-new-card-description.gl-mt-2.gl-sm-mt-0 + = s_("ProtectedBranch|By default, protected branches restrict who can modify the branch.") + = link_to s_("ProtectedBranch|Learn more."), help_page_path("user/project/protected_branches", anchor: "who-can-modify-a-protected-branch") + - c.with_body do + - if can_admin_entity + .gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content + = content_for :create_protected_branch - = content_for :branches_list + = content_for :branches_list + + = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml index 69969b7f848..93c84e67d81 100644 --- a/app/views/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_protected_branch.html.haml @@ -3,14 +3,14 @@ - protected_branch_test_type = protected_branch.project_level? ? 'project-level' : 'group-level' %tr.js-protected-branch-edit-form{ data: { url: url, testid: 'protected-branch', test_type: protected_branch_test_type } } - %td - %span.ref-name= protected_branch.name + %td{ class: 'gl-vertical-align-middle!', data: { label: s_("ProtectedBranch|Branch") } } + %div + %span.ref-name= protected_branch.name - - if protected_branch.project_level? - - if protected_branch_entity.root_ref?(protected_branch.name) - = gl_badge_tag s_('ProtectedBranch|default'), variant: :info + - if protected_branch.project_level? + - if protected_branch_entity.root_ref?(protected_branch.name) + = gl_badge_tag s_('ProtectedBranch|default'), variant: :info - %div - if protected_branch.wildcard? - matching_branches = protected_branch.matching(repository.branch_names) = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) @@ -22,9 +22,9 @@ = render_if_exists 'protected_branches/ee/code_owner_approval_table', can_update: local_assigns[:can_update], protected_branch: protected_branch, protected_branch_entity: protected_branch_entity - if can_admin_entity - %td.text-right{ data: { testid: 'protected-branch-action' } } + %td.text-right{ data: { label: _('Actions'), testid: 'protected-branch-action' } } - if local_assigns[:is_inherited] %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Inherited - This setting can be changed at the group level'), 'aria-hidden': 'true' } = sprite_icon 'lock' - else - = link_button_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, size: :small + = link_button_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary, size: :small diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 65501b27451..e780b13de6e 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -1,3 +1,4 @@ +<%- cache current_appearance do %> { "name": "<%= appearance_pwa_name %>", "short_name": "<%= appearance_pwa_short_name %>", @@ -33,3 +34,4 @@ } <% end -%>] } +<% end %> diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 934f59ea586..16ca829a6d4 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -8,7 +8,7 @@ = hidden_field_tag :project_id, params[:project_id] - group_attributes = @group&.attributes&.slice('id', 'name')&.merge(full_name: @group&.full_name) - project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) -- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' +- search_bar_classes = !show_super_sidebar? ? 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' : '' - if @search_results && !(@search_results.respond_to?(:failed?) && @search_results.failed?) - if @search_service_presenter.without_count? diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml index 5013d8e439a..14201c0d23a 100644 --- a/app/views/shared/_email_with_badge.html.haml +++ b/app/views/shared/_email_with_badge.html.haml @@ -1,6 +1,7 @@ - variant = verified ? :success : :danger - text = verified ? _('Verified') : _('Unverified') -%span.gl-mr-3 - = email -= gl_badge_tag text, { variant: variant } +- if email + %span.gl-mr-2 + = email += gl_badge_tag text, { variant: variant, size: :sm } diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 1aac7af443f..bb21c4a28fd 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -6,8 +6,8 @@ - toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user - tooltip_title = label_status_tooltip(label, status) if status -%li.label-list-item.gl-list-style-none.gl-py-3{ id: label_css_id, data: { id: label.id } } - .label-content.gl-px-3.gl-py-2.gl-rounded-base{ class: "#{ 'gl-py-3' if force_priority }" } +%li.label-list-item.gl-list-style-none{ id: label_css_id, data: { id: label.id } } + .label-content.gl-pl-5.gl-pr-3.gl-py-4.gl-rounded-base{ class: "#{ 'gl-py-3' if force_priority }" } = render "shared/label_row", label: label, force_priority: force_priority %ul.label-actions-list - if can?(current_user, :admin_label, @project) diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 19489981d94..5058455dcd7 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -4,7 +4,7 @@ - show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests) .label-name.gl-flex-shrink-0.gl-mr-5 - = render_label(label, tooltip: false) + = render_label(label, link: '#', tooltip: true, tooltip_shows_title: true) - if show_labels_full_path?(@project, @group) .gl-mt-2 = render 'shared/label_full_path', label: label diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index e0d385024cd..1f6f41187fc 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -5,5 +5,5 @@ - c.with_body do = no_password_message - c.with_actions do - = link_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action btn btn-confirm btn-md gl-button' - = link_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button' + = link_button_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action', variant: :confirm + = link_button_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action' diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml index e0313710736..cfc0afb4646 100644 --- a/app/views/shared/_service_ping_consent.html.haml +++ b/app/views/shared/_service_ping_consent.html.haml @@ -1,7 +1,7 @@ - if session[:ask_for_usage_stats_consent] = render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c| - c.with_body do - - docs_link = link_to '', help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link' + - docs_link = link_to '', help_page_path('administration/settings/usage_statistics.md'), class: 'gl-link' - settings_link = link_to '', metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link' = safe_format s_('ServicePing|To help improve GitLab, we would like to periodically %{link_start}collect usage information%{link_end}.'), tag_pair(docs_link, :link_start, :link_end) = safe_format s_('ServicePing|This can be changed at any time in %{link_start}your settings%{link_end}.'), tag_pair(settings_link, :link_start, :link_end) diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index 54af364aca3..e46da882e83 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -7,7 +7,7 @@ - access_levels = local_assigns.fetch(:access_levels, false) - default_access_level = local_assigns.fetch(:default_access_level, false) -%h5.gl-font-lg.gl-mt-0 +%h4.gl-mt-0 = title = gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f| @@ -15,9 +15,13 @@ .form-group = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold' - - resource_type = resource.is_a?(Group) ? "group" : "project" - = f.text_field :name, class: 'form-control gl-form-input gl-max-w-80', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text' - %span.form-text.text-muted#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type } + = f.text_field :name, class: 'form-control gl-form-input gl-form-input-xl', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text' + %span.form-text.text-muted#access_token_help_text + - if resource + - resource_type = resource.is_a?(Group) ? "group" : "project" + = s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type } + - else + = s_("AccessTokens|For example, the application using the token or the purpose of the token.") .js-access-tokens-expires-at{ data: expires_at_field_data } = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } @@ -39,3 +43,5 @@ .gl-mt-3 = f.submit s_('AccessTokens|Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do + = _('Cancel') diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 584d0758c76..bbaf5bf9627 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -5,37 +5,34 @@ = form_errors(deploy_key) .form-group - = form.label :title, class: 'col-form-label col-sm-2' - .col-sm-10= form.text_field :title, class: 'form-control gl-form-input', data: { qa_selector: 'deploy_key_title_field' }, readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) + = form.label :title + = form.text_field :title, class: 'form-control gl-form-input', data: { testid: 'deploy-key-title-field' }, readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) -.form-group - - if deploy_key.new_record? - = form.label :key, class: 'col-form-label col-sm-2' - .col-sm-10 - %p.light - - link_start = "<a href='#{help_page_path('user/ssh')}' target='_blank' rel='noreferrer noopener'>".html_safe - - link_end = '</a>' - = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } - = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { qa_selector: 'deploy_key_field' } - - else - - if deploy_key.fingerprint_sha256.present? - = form.label :fingerprint, _('Fingerprint (SHA256)'), class: 'col-form-label col-sm-2' - .col-sm-10 - = form.text_field :fingerprint_sha256, class: 'form-control gl-form-input', readonly: 'readonly' - - if deploy_key.fingerprint.present? - = form.label :fingerprint, _('Fingerprint (MD5)'), class: 'col-form-label col-sm-2' - .col-sm-10 - = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' +- if deploy_key.new_record? + .form-group + = form.label :key + + %p.gl-text-secondary + - link_start = "<a href='#{help_page_path('user/ssh')}' target='_blank' rel='noreferrer noopener'>".html_safe + - link_end = '</a>' + = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } + = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { testid: 'deploy-key-field' } +- else + - if deploy_key.fingerprint_sha256.present? + .form-group + = form.label :fingerprint, _('Fingerprint (SHA256)') + = form.text_field :fingerprint_sha256, class: 'form-control gl-form-input', readonly: 'readonly' + - if deploy_key.fingerprint.present? + .form-group + = form.label :fingerprint, _('Fingerprint (MD5)') + = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' .form-group - .col-sm-10 - = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' - = form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly' + = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' + = form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly' - if deploy_keys_project.present? = form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form| .form-group - .col-form-label.col-sm-2 - .col-sm-10 - = 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') + = 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') diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index 1cd2a590653..650e50e0312 100644 --- a/app/views/shared/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -1,14 +1,16 @@ - expanded = expanded_by_default? -%section.rspec-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings_content' } } +%section.rspec-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { testid: 'deploy-keys-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Deploy keys') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %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 } .settings-content - %h5.gl-mt-0 - = render @deploy_keys.form_partial_path - %hr - #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } } + = 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 + .gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content + = render @deploy_keys.form_partial_path + + #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } } 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 c9e17b18264..c633088b26a 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -1,11 +1,15 @@ = 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| = form_errors(@deploy_keys.new_key) + + .form-group.row + %h4.gl-my-0= s_('DeployKeys|Add new deploy key') + .form-group.row = f.label :title, class: "label-bold" - = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'deploy_key_title_field' } + = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { testid: 'deploy-key-title-field' } .form-group.row = f.label :key, class: "label-bold" - = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true, data: { qa_selector: 'deploy_key_field' } + = 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 = _('Paste a public key here.') @@ -17,8 +21,10 @@ help_text: _('Allow this key to push to this repository') .form-group.row = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' - = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_key_expires_at_field' }, value: f.object.expires_at + = 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 - .form-group.row - = f.submit _("Add key"), data: { qa_selector: "add_deploy_key_button"}, pajamas_button: true + .form-group.row.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/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index e5f1fd99125..ccffc3ec923 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -5,16 +5,8 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= s_('DeployTokens|Deploy tokens') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - %p + %p.gl-text-secondary = description .settings-content - #js-new-deploy-token{ data: { - container_registry_enabled: container_registry_enabled?(group_or_project), - packages_registry_enabled: packages_registry_enabled?(group_or_project), - create_new_token_path: create_deploy_token_path(group_or_project), - token_type: group_or_project.is_a?(Group) ? 'group' : 'project', - deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md') - } - } - %hr + #new-deploy-token-alert = render 'shared/deploy_tokens/table', group_or_project: group_or_project, active_tokens: @deploy_tokens diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index 3827ecf73a4..3b351387d41 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -1,32 +1,54 @@ -%h5= s_("DeployTokens|Active Deploy Tokens (%{active_tokens})") % { active_tokens: active_tokens.length } - -- if active_tokens.present? - .table-responsive.deploy-tokens - %table.table - %thead - %tr - %th= s_('DeployTokens|Name') - %th= s_('DeployTokens|Username') - %th= s_('DeployTokens|Created') - %th= s_('DeployTokens|Expires') - %th= s_('DeployTokens|Scopes') - %th - %tbody - - active_tokens.each do |token| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h5.gl-new-card-title + = s_("DeployTokens|Active deploy tokens") + .gl-new-card-count + = sprite_icon('token', css_class: 'gl-mr-2') + = active_tokens.length + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content" }) do + = _('Add token') + - c.with_body do + .gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content + #js-new-deploy-token{ data: { + container_registry_enabled: container_registry_enabled?(group_or_project), + packages_registry_enabled: packages_registry_enabled?(group_or_project), + create_new_token_path: create_deploy_token_path(group_or_project), + token_type: group_or_project.is_a?(Group) ? 'group' : 'project', + deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md') + } + } + - if active_tokens.present? + %table.table.b-table.gl-table.b-table-stacked-md + %thead %tr - %td= token.name - %td= token.username - %td= token.created_at.to_date.to_fs(:medium) - %td - - if token.expires? - %span{ class: ('text-warning' if token.expires_soon?) } - = time_ago_with_tooltip(token.expires_at) - - else - %span.token-never-expires-label= _('Never') - %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') - %td - .js-deploy-token-revoke-button{ data: deploy_token_revoke_button_data(token: token, group_or_project: group_or_project) } + %th= s_('DeployTokens|Name') + %th= s_('DeployTokens|Username') + %th= s_('DeployTokens|Created') + %th= s_('DeployTokens|Expires') + %th= s_('DeployTokens|Scopes') + %th + %tbody + - active_tokens.each do |token| + %tr + %td{ data: { label: _('Name') }, class: 'gl-vertical-align-middle!' } + = token.name + %td{ data: { label: _('Username') }, class: 'gl-vertical-align-middle!' } + = token.username + %td{ data: { label: _('Created') }, class: 'gl-vertical-align-middle!' } + = token.created_at.to_date.to_fs(:medium) + %td{ data: { label: _('Expires') }, class: 'gl-vertical-align-middle!' } + - if token.expires? + %span{ class: ('text-warning' if token.expires_soon?) } + = time_ago_with_tooltip(token.expires_at) + - else + %span.token-never-expires-label= _('Never') + %td{ data: { label: _('Scopes') }, class: 'gl-vertical-align-middle!' } + = token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') + %td{ data: { label: _('Actions') }, class: 'gl-vertical-align-middle!' } + .js-deploy-token-revoke-button{ data: deploy_token_revoke_button_data(token: token, group_or_project: group_or_project) } -- else - .settings-message.text-center - = s_('DeployTokens|This %{entity_type} has no active Deploy Tokens.') % { entity_type: group_or_project.class.name.downcase } + - else + .gl-new-card-empty.gl-px-5.gl-py-4 + = s_('DeployTokens|This %{entity_type} has no active deploy tokens.') % { entity_type: group_or_project.class.name.downcase } diff --git a/app/views/shared/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml index 512daf7b96b..6b80b42f918 100644 --- a/app/views/shared/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml @@ -1,10 +1,7 @@ -- submit_btn_css ||= 'btn btn-danger btn-md gl-button btn-danger-secondary' = form_tag path do %input{ :name => "_method", :type => "hidden", :value => "delete" } - if defined? small - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'remove', button_options: { class: submit_btn_css, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do - %span.sr-only - = _('Destroy') + = render Pajamas::ButtonComponent.new(type: :submit, category: :tertiary, icon: 'remove', button_options: { 'class': 'has-tooltip', 'title': _('Destroy'), aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) - else - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: submit_btn_css, aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do + = render Pajamas::ButtonComponent.new(type: :submit, category: :primary, variant: :danger, button_options: { aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do = _('Destroy') diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml index ae539c46cf1..f24606317ff 100644 --- a/app/views/shared/doorkeeper/applications/_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_form.html.haml @@ -1,3 +1,5 @@ +- show_cancel_button = local_assigns.fetch(:cancel, false) + = gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form gl-max-w-80' } do |f| = form_errors(@application) @@ -22,3 +24,6 @@ .gl-mt-3 = f.submit _('Save application'), pajamas_button: true + - if show_cancel_button + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do + = _('Cancel') diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index bf78f275d65..f28cc64b969 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -1,4 +1,6 @@ - @force_desktop_expanded_sidebar = true +- add_form_class = 'gl-display-none' if !form_errors(@application) +- hide_class = 'gl-display-none' if form_errors(@application) .settings-section.js-search-settings-section .settings-sticky-header @@ -14,73 +16,92 @@ - else = _("Manage applications that you've authorized to use your account.") - if oauth_applications_enabled - %h5.gl-mt-0 - = _('Add new application') - .gl-border-b.gl-pb-6 - = render 'shared/doorkeeper/applications/form', url: form_url + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card oauth-applications js-toggle-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Your applications') + .gl-new-card-count + = sprite_icon('applications', css_class: 'gl-mr-2') + = @applications.size + .gl-new-card-actions + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content #{hide_class}" }) do + = _('Add new application') + - c.with_body do + .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class } + %h4.gl-mt-0 + = _('Add new application') + = render 'shared/doorkeeper/applications/form', url: form_url, cancel: true + + - if @applications.any? + .table-holder + %table.table.b-table.gl-table.b-table-stacked-sm.gl-mt-n1.gl-mb-n2 + %thead.d-none.d-md-table-header-group + %tr + %th= _('Name') + %th= _('Callback URL') + %th= _('Clients') + %th.last-heading + %tbody + - @applications.each do |application| + %tr{ id: "application_#{application.id}" } + %td{ data: { label: _('Name') } } + = link_to application.name, application_url.call(application) + %td{ data: { label: _('Callback URL') } } + - application.redirect_uri.split.each do |uri| + = uri + %td{ data: { label: _('Clients') } } + = application.access_tokens.count + %td{ class: 'gl-py-3!', data: { label: _('Actions') } } + %div{ class: 'gl-display-flex! gl-pl-0!' } + = render Pajamas::ButtonComponent.new(category: :tertiary, href: edit_application_url.call(application), icon: 'pencil', button_options: { class: 'has-tooltip gl-mr-3', 'title': _('Edit'), 'aria-label': _('Edit') }) + = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true + - else + .gl-new-card-empty.gl-px-5.gl-py-4.js-toggle-content + = _("You don't have any applications.") - else .bs-callout.bs-callout-disabled - = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission') - - if oauth_applications_enabled - .oauth-applications.gl-pt-6 - %h5.gl-mt-0 - = _("Your applications (%{size})") % { size: @applications.size } - - if @applications.any? - .table-responsive - %table.table - %thead - %tr - %th= _('Name') - %th= _('Callback URL') - %th= _('Clients') - %th.last-heading - %tbody - - @applications.each do |application| - %tr{ id: "application_#{application.id}" } - %td= link_to application.name, application_url.call(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td.gl-display-flex - = link_button_to nil, edit_application_url.call(application), class: 'gl-mr-3', icon: 'pencil', 'aria-label': _('Edit') - = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true - - else - .settings-message - = _("You don't have any applications") - - if oauth_authorized_applications_enabled - .oauth-authorized-applications.gl-mt-4 - - if oauth_applications_enabled - %h5.gl-mt-0 - = _("Authorized applications (%{size})") % { size: @authorized_tokens.size } + = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission.') - - if @authorized_tokens.any? - .table-responsive - %table.table.table-striped - %thead - %tr - %th= _('Name') - %th= _('Authorized At') - %th= _('Scope') - %th - %tbody - - @authorized_tokens.each do |token| - %tr{ id: ("application_#{token.application.id}" if token.application) } - %td - - if token.application - = token.application.name - - else - = _('Anonymous') - .form-text.text-muted - %em= _("Authorization was granted by entering your username and password in the application.") - %td= token.created_at - %td= token.scopes - %td - - if token.application - = render 'doorkeeper/authorized_applications/delete_form', application: token.application - - else - = render 'doorkeeper/authorized_applications/delete_form', token: token - - else - .settings-message - = _("You don't have any authorized applications") + - if oauth_authorized_applications_enabled + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card oauth-authorized-applications' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('Authorized applications') + .gl-new-card-count + = sprite_icon('applications', css_class: 'gl-mr-2') + = @authorized_tokens.size + - c.with_body do + - if @authorized_tokens.any? + .table-holder + %table.table.b-table.gl-table.b-table-stacked-sm.gl-mt-n1.gl-mb-n2 + %thead.d-none.d-md-table-header-group + %tr + %th= _('Name') + %th= _('Authorized At') + %th= _('Scope') + %th + %tbody + - @authorized_tokens.each do |token| + %tr{ id: ("application_#{token.application.id}" if token.application) } + %td{ data: { label: _('Name') } } + - if token.application + = token.application.name + - else + = _('Anonymous') + .form-text.text-muted + %em= _("Authorization was granted by entering your username and password in the application.") + %td{ data: { label: _('Authorized At') } } + = token.created_at + %td{ data: { label: _('Scope') } } + = token.scopes + %td{ class: 'gl-py-3!', data: { label: _('Actions') } } + - if token.application + = render 'doorkeeper/authorized_applications/delete_form', application: token.application + - else + = render 'doorkeeper/authorized_applications/delete_form', token: token + - else + .gl-new-card-empty.gl-px-5.gl-py-4{ class: hide_class } + = _("You don't have any authorized applications.") diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index b075cece877..4101a456f32 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -40,7 +40,7 @@ = render "shared/tokens/scopes_list", token: @application -.form-actions.gl-display-flex.gl-justify-content-space-between +.gl-display-flex.gl-justify-content-space-between %div - if @created = link_button_to _('Continue'), index_path, class: 'gl-mr-3', variant: :confirm diff --git a/app/views/shared/empty_states/_milestones.html.haml b/app/views/shared/empty_states/_milestones.html.haml index 0d7dbd1415b..d88baab3011 100644 --- a/app/views/shared/empty_states/_milestones.html.haml +++ b/app/views/shared/empty_states/_milestones.html.haml @@ -3,8 +3,8 @@ .row.empty-state .col-12 - .svg-content - = image_tag 'illustrations/milestone_burndown_chart.svg' + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-milestone-md.svg' .col-12 .text-content.text-center %h4= s_('Milestones|Use milestones to track issues and merge requests over a fixed period of time') diff --git a/app/views/shared/empty_states/_milestones_tab.html.haml b/app/views/shared/empty_states/_milestones_tab.html.haml index 52df30434b4..081a260971c 100644 --- a/app/views/shared/empty_states/_milestones_tab.html.haml +++ b/app/views/shared/empty_states/_milestones_tab.html.haml @@ -4,8 +4,8 @@ .row.empty-state .col-12 - .svg-content - = image_tag 'illustrations/milestone_burndown_chart.svg' + .svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-milestone-md.svg' .col-12 .text-content - if closed_tab_selected diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml index ba5fbd90528..d71ed963fd1 100644 --- a/app/views/shared/empty_states/_profile_tabs.html.haml +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -12,10 +12,10 @@ - if current_user_empty_message_description.present? %p= current_user_empty_message_description - - if secondary_button_link.present? - = link_button_to secondary_button_label, secondary_button_link, variant: :confirm, category: :secondary - - if primary_button_link.present? = link_button_to primary_button_label, primary_button_link, variant: :confirm + + - if secondary_button_link.present? + = link_button_to secondary_button_label, secondary_button_link, variant: :confirm, category: :secondary - else %h5= visitor_empty_message diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index 9e628a1f409..567c4a2d444 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -6,21 +6,21 @@ - create_path = wiki_page_path(@wiki, params[:id], view: 'create') - create_link = link_button_to s_('WikiEmpty|Create your first page'), create_path, title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }, variant: :confirm - = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do + = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do %h4.text-left = messages.dig(:writable, :title) %p.text-left = messages.dig(:writable, :body) = create_link - if show_enable_confluence_integration?(@wiki.container) - = link_to s_('WikiEmpty|Enable the Confluence Wiki integration'), + = link_button_to s_('WikiEmpty|Enable the Confluence Wiki integration'), edit_project_settings_integration_path(@project, :confluence), - class: 'btn gl-button', title: s_('WikiEmpty|Enable the Confluence Wiki integration') + title: s_('WikiEmpty|Enable the Confluence Wiki integration') - elsif @project && can?(current_user, :read_issue, @project) - issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project) - = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do + = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do %h4 = messages.dig(:issuable, :title) %p.text-left @@ -29,7 +29,7 @@ = link_button_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), title: s_('WikiEmptyIssueMessage|Suggest wiki improvement'), variant: :confirm - else - = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do + = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do %h4 = messages.dig(:readonly, :title) %p diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index 0b7034838ed..03054c959fd 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,6 +1,6 @@ .row.empty-state.empty-state-wiki .col-12 - .svg-content{ data: { qa_selector: 'svg_content' } } + .svg-content.svg-150{ data: { qa_selector: 'svg_content' } } = image_tag image_path .col-12 .text-content.text-center diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index b125fe34464..8df13ec83fe 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -44,6 +44,10 @@ .js-subscriptions-dropdown - if is_issue .block + .title + = _('Confidentiality') + .js-confidentiality-dropdown + .block .js-move-issues{ data: move_data } = hidden_field_tag "update[issuable_ids]", [] diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fadaeafeaf6..46710081307 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -42,7 +42,7 @@ .js-sidebar-labels-widget-root{ data: sidebar_labels_data(issuable_sidebar, @project) } - if issuable_sidebar[:supports_milestone] - .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } } + .block.milestone{ data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } } .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 diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 01f1dbdb3cf..34815026ff2 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -11,9 +11,11 @@ - vis1020 = _('This merge request is from an internal project to a public project.') - i18n = { '010' => vis010, '020' => vis020, '1020' => vis1020 } -- source_level = @merge_request.source_project.visibility_level -- source_visibility = @merge_request.source_project.visibility -- target_level = @merge_request.target_project.visibility_level +- source_project = @merge_request.source_project +- target_project = @merge_request.target_project +- source_level = source_project.visibility_level +- source_visibility = source_project.visibility +- target_level = target_project.visibility_level - visibilityMismatchString = i18n["#{source_level}#{target_level}"] @@ -39,10 +41,18 @@ = dropdown_filter(_("Search branches")) = dropdown_content = dropdown_loading - - if source_level < target_level = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| - c.with_body do = visibilityMismatchString %br = _('Review the target project before submitting to avoid exposing %{source} changes.') % { source: source_visibility } +- elsif target_level > Gitlab::VisibilityLevel::PRIVATE + - source_access_level = source_project.project_feature.repository_access_level + - target_access_level = target_project.project_feature.repository_access_level + - if source_access_level < target_access_level + = render Pajamas::AlertComponent.new(title: _('Should these changes be private?'), variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| + - c.with_body do + - warning_message = html_escape(_("Project %{code_open}%{source_project}%{code_close} has more restricted access settings than %{code_open}%{target_project}%{code_close}. To avoid exposing private changes, make sure you're submitting changes to the correct project.")) + = warning_message % {code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, source_project: source_project.name_with_namespace, target_project: target_project.name_with_namespace} + diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index bd9afc3ce69..d25ef3f4e83 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -8,4 +8,4 @@ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } = dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name)) - = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-pl-4 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' } + = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-md-pl-3 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' } diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index 40a02fddbf3..249e296b41a 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -4,6 +4,7 @@ .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'), diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index 4997d429587..558287480e1 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -16,6 +16,6 @@ #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-float-right gl-display-block gl-sm-display-none! gutter-toggle issuable-gutter-toggle js-sidebar-toggle' }) + = 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/nav/_your_work_scope_header.html.haml b/app/views/shared/nav/_your_work_scope_header.html.haml index 86172fb14ed..cdd0be3c682 100644 --- a/app/views/shared/nav/_your_work_scope_header.html.haml +++ b/app/views/shared/nav/_your_work_scope_header.html.haml @@ -1,5 +1,5 @@ %li.context-header - = link_to root_url, title: _('Your work'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do + = link_to root_path, title: _('Your work'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do %span.avatar-container.icon-avatar.rect-avatar.s32 = sprite_icon('work', size: 18) %span.sidebar-context-title diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 3e880a36e29..bbcd072c762 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,4 +1,5 @@ - noteable_name = @note.noteable.human_class_name .js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } } - %input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'js-comment-button js-comment-submit-button', value: _('Comment'), data: { qa_selector: 'comment_button' }}) do + = _('Comment') diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 23ce38d50e0..ab8d4bba8ac 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -16,15 +16,15 @@ = sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom') %span.uploading-error-message -# Populated by app/assets/javascripts/dropzone_input.js - %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link + = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'retry-uploading-link' }) do %span.gl-button-text = _("Try again") = _("or") - %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline + = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'attach-new-file markdown-selector' }) do %span.gl-button-text = _("attach a new file") = _(".") - %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide + = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'button-cancel-uploading-files hide' }) do %span.gl-button-text = _("Cancel") diff --git a/app/views/shared/packages/_no_packages.html.haml b/app/views/shared/packages/_no_packages.html.haml index ae5c2cfd378..7cc8110fb6b 100644 --- a/app/views/shared/packages/_no_packages.html.haml +++ b/app/views/shared/packages/_no_packages.html.haml @@ -1,4 +1,5 @@ -.svg-content= image_tag 'illustrations/no-packages.svg' +.svg-content.svg-150 + = image_tag 'illustrations/empty-state/empty-package-md.svg' .text-content %h4.text-center= _('There are no packages yet') %p diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index e09736cad6c..95188cefdd1 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -35,8 +35,7 @@ %span.project-name< = project.name - %span.gl-mr-3.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) } - = visibility_level_icon(project.visibility_level) + = visibility_level_content(project, css_class: 'gl-mr-3') - if explore_projects_tab? && project_license_name(project) %span.gl-display-inline-flex.gl-align-items-center.gl-mr-3 diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml index 80cd23989a0..c969520d7e9 100644 --- a/app/views/shared/ssh_keys/_key_delete.html.haml +++ b/app/views/shared/ssh_keys/_key_delete.html.haml @@ -1,5 +1,4 @@ - category = local_assigns[:category] || :primary -.gl-p-2 - = render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do - = _('Delete') += render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do + = _('Delete') diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index a0e55cd5723..f040ea8e542 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -3,7 +3,7 @@ .js-vue-webhook-form{ data: webhook_form_data(hook) } .form-group = form.label :token, s_('Webhooks|Secret token'), class: 'label-bold' - = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-48' + = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-form-input-xl' %p.form-text.text-muted - code_start = '<code>'.html_safe - code_end = '</code>'.html_safe diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 50ce6552616..9b84222e920 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -2,8 +2,8 @@ - sslBadgeText = _('SSL Verification:') + ' ' + sslStatus %li.label-list-item - .gl-display-flex.lgl-align-items-center.row.gl-mx-n1 - .col-md-8.col-lg-7.gl-px-3 + .gl-display-flex.lgl-align-items-center.row.gl-mx-0 + .col-md-8.col-lg-7.gl-px-5 .light-header.gl-mb-2 = hook.url - if hook.rate_limited? @@ -19,7 +19,7 @@ = gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm) = gl_badge_tag(sslBadgeText, size: :sm) - .col-md-4.col-lg-5.gl-mt-2.gl-px-3.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline + .col-md-4.col-lg-5.gl-mt-2.gl-px-5.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline = render 'shared/web_hooks/test_button', hook: hook, size: 'small' = render Pajamas::ButtonComponent.new(href: edit_hook_path(hook), size: :small) do = _('Edit') diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 0ea6a0307ba..ccd86937e4f 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -1,4 +1,4 @@ -= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index', class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body'}) do |c| += render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index', class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c| - c.with_header do .gl-new-card-title-wrapper %h3.gl-new-card-title @@ -9,16 +9,15 @@ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content' }) do = _('Add new webhook') - c.with_body do - .gl-new-card-content - = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-mb-3 gl-display-none js-toggle-content' } do |f| - = render partial: partial, locals: { form: f, hook: @hook } - = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" } - = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do - = _('Cancel') - - if hooks.any? - %ul.content-list{ class: 'gl-my-n3!' } - - hooks.each do |hook| - = render 'shared/web_hooks/hook', hook: hook - - else - %p.gl-new-card-empty.gl-text-center - = _('No webhooks enabled. Select trigger events above.') + = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-m-3 gl-display-none js-toggle-content' } do |f| + = render partial: partial, locals: { form: f, hook: @hook } + = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" } + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do + = _('Cancel') + - if hooks.any? + %ul.content-list + - hooks.each do |hook| + = render 'shared/web_hooks/hook', hook: hook + - else + %p.gl-new-card-empty.gl-text-center + = _('No webhooks enabled. Select trigger events above.') diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 28699ca27f3..be1f43f44de 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -12,6 +12,8 @@ .nav-controls.pb-md-3.pb-lg-0 = render 'shared/wikis/main_links' + - if Feature.enabled?(:print_wiki, current_user) + #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } } - if @page.historical? = render Pajamas::AlertComponent.new(variant: :warning, diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 380d6aacb84..e2ddbb90213 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -26,7 +26,7 @@ = s_("UserProfile|Edit profile") = render 'users/view_gpg_keys' = render 'users/view_user_in_admin_area' - .js-user-profile-actions{ data: { user_id: @user.id } } + .js-user-profile-actions{ data: user_profile_actions_data(@user) } - else = render layout: 'users/cover_controls' do - if @user == current_user diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 6f6fd9ddb65..1664add1ac9 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -219,6 +219,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:batched_git_ref_updates_cleanup_scheduler + :worker_name: BatchedGitRefUpdates::CleanupSchedulerWorker + :feature_category: :gitaly + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:bulk_imports_stuck_import :worker_name: BulkImports::StuckImportWorker :feature_category: :importers @@ -552,6 +561,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:members_expiring + :worker_name: Members::ExpiringWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: cronjob:metrics_global_metrics_update :worker_name: Metrics::GlobalMetricsUpdateWorker :feature_category: :metrics @@ -660,6 +678,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:pause_control_resume + :worker_name: PauseControl::ResumeWorker + :feature_category: :global_search + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:personal_access_tokens_expired_notification :worker_name: PersonalAccessTokens::ExpiredNotificationWorker :feature_category: :system_access @@ -795,6 +822,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:service_desk_custom_email_verification_cleanup + :worker_name: ServiceDesk::CustomEmailVerificationCleanupWorker + :feature_category: :service_desk + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:ssh_keys_expired_notification :worker_name: SshKeys::ExpiredNotificationWorker :feature_category: :compliance_management @@ -2298,6 +2334,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: batched_git_ref_updates_project_cleanup + :worker_name: BatchedGitRefUpdates::ProjectCleanupWorker + :feature_category: :gitaly + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: bitbucket_server_import_advance_stage :worker_name: Gitlab::BitbucketServerImport::AdvanceStageWorker :feature_category: :importers @@ -2438,7 +2483,7 @@ :feature_category: :importers :has_external_dependencies: true :urgency: :low - :resource_boundary: :unknown + :resource_boundary: :memory :weight: 1 :idempotent: false :tags: [] @@ -2523,6 +2568,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: click_house_events_sync + :worker_name: ClickHouse::EventsSyncWorker + :feature_category: :value_stream_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: counters_cleanup_refresh :worker_name: Counters::CleanupRefreshWorker :feature_category: :not_owned @@ -2685,6 +2739,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: environments_stop_job_success + :worker_name: Environments::StopJobSuccessWorker + :feature_category: :continuous_delivery + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: error_tracking_issue_link :worker_name: ErrorTrackingIssueLinkWorker :feature_category: :error_tracking @@ -2955,6 +3018,15 @@ :weight: 2 :idempotent: :tags: [] +- :name: members_expiring_email_notification + :worker_name: Members::ExpiringEmailNotificationWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge :worker_name: MergeWorker :feature_category: :source_code_management diff --git a/app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb b/app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb new file mode 100644 index 00000000000..9c50e319be0 --- /dev/null +++ b/app/workers/batched_git_ref_updates/cleanup_scheduler_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module BatchedGitRefUpdates + class CleanupSchedulerWorker + include ApplicationWorker + # Ignore RuboCop as the context is added in the service + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + idempotent! + data_consistency :sticky + + feature_category :gitaly + + def perform + stats = CleanupSchedulerService.new.execute + + log_extra_metadata_on_done(:stats, stats) + end + end +end diff --git a/app/workers/batched_git_ref_updates/project_cleanup_worker.rb b/app/workers/batched_git_ref_updates/project_cleanup_worker.rb new file mode 100644 index 00000000000..b2b1df29430 --- /dev/null +++ b/app/workers/batched_git_ref_updates/project_cleanup_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module BatchedGitRefUpdates + class ProjectCleanupWorker + include ApplicationWorker + + idempotent! + data_consistency :delayed + + feature_category :gitaly + + def perform(project_id) + stats = ProjectCleanupService.new(project_id).execute + + log_extra_metadata_on_done(:stats, stats) + end + end +end diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index 247105d2a1a..f5baa220715 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# Deprecated and will be removed in 17.0. +# Use `Environments::StopJobSuccessWorker` instead. class BuildSuccessWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb index 378eff99b52..634d7ed3c87 100644 --- a/app/workers/bulk_imports/pipeline_batch_worker.rb +++ b/app/workers/bulk_imports/pipeline_batch_worker.rb @@ -9,6 +9,7 @@ module BulkImports feature_category :importers sidekiq_options retry: false, dead: false worker_has_external_dependencies! + worker_resource_boundary :memory def perform(batch_id) @batch = ::BulkImports::BatchTracker.find(batch_id) diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index e0db18cb987..098e167ac29 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -42,7 +42,6 @@ module BulkImports def run return skip_tracker if entity.failed? - raise(Pipeline::ExpiredError, 'Pipeline timeout') if job_timeout? raise(Pipeline::FailedError, "Export from source instance failed: #{export_status.error}") if export_failed? raise(Pipeline::ExpiredError, 'Empty export status on source instance') if empty_export_timeout? @@ -181,12 +180,6 @@ module BulkImports "gitlab:bulk_imports:pipeline_worker:#{pipeline_tracker.id}" end - def job_timeout? - return false unless file_extraction_pipeline? - - time_since_tracker_created > Pipeline::NDJSON_EXPORT_TIMEOUT - end - def enqueue_batches 1.upto(export_status.batches_count) do |batch_number| batch = pipeline_tracker.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 52a4f075cf0..067dbb7492f 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -32,7 +32,7 @@ module Ci end def create_deployment(build) - ::Deployments::CreateForBuildService.new.execute(build) + ::Deployments::CreateForJobService.new.execute(build) end end end diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb index 2a1f492cacb..2bebfdf9114 100644 --- a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb +++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb @@ -3,14 +3,16 @@ module Ci class PipelineSuccessUnlockArtifactsWorker include ApplicationWorker + include PipelineBackgroundQueue data_consistency :always sidekiq_options retry: 3 - include PipelineBackgroundQueue idempotent! + defer_on_database_health_signal :gitlab_ci, [:ci_job_artifacts] + def perform(pipeline_id) ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| # TODO: Move this check inside the Ci::UnlockArtifactsService diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb new file mode 100644 index 00000000000..054e7763297 --- /dev/null +++ b/app/workers/click_house/events_sync_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ClickHouse + class EventsSyncWorker + include ApplicationWorker + include Gitlab::ExclusiveLeaseHelpers + + idempotent! + data_consistency :delayed + worker_has_external_dependencies! # the worker interacts with a ClickHouse database + feature_category :value_stream_management + + # the job is scheduled every 3 minutes and we will allow maximum 2.5 minutes runtime + MAX_TTL = 2.5.minutes.to_i + + def perform + unless enabled? + log_extra_metadata_on_done(:result, { status: :disabled }) + + return + end + + metadata = { status: :processed } + + # Prevent parallel jobs + begin + in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do + true + end + + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + # Skip retrying, just let the next worker to start after a few minutes + metadata = { status: :skipped } + end + + log_extra_metadata_on_done(:result, metadata) + end + + private + + def enabled? + ClickHouse::Client.configuration.databases[:main].present? && Feature.enabled?(:event_sync_worker_for_click_house) + end + end +end diff --git a/app/workers/clusters/agents/notify_git_push_worker.rb b/app/workers/clusters/agents/notify_git_push_worker.rb index d2994bb9144..db1de0b3518 100644 --- a/app/workers/clusters/agents/notify_git_push_worker.rb +++ b/app/workers/clusters/agents/notify_git_push_worker.rb @@ -14,7 +14,6 @@ module Clusters def perform(project_id) return unless project = ::Project.find_by_id(project_id) - return unless Feature.enabled?(:notify_kas_on_git_push, project) Gitlab::Kas::Client.new.send_git_push_event(project: project) end diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index 772388ffc9e..b40914770b5 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -5,10 +5,15 @@ module Gitlab # Module that provides methods shared by the various workers used for # importing GitHub projects. module ReschedulingMethods + extend ActiveSupport::Concern include JobDelayCalculator ENQUEUED_JOB_COUNT = 'github-importer/enqueued_job_count/%{project}/%{collection}' + included do + loggable_arguments 2 + end + # project_id - The ID of the GitLab project to import the note into. # hash - A Hash containing the details of the GitHub object to import. # notify_key - The Redis key to notify upon completion, if any. diff --git a/app/workers/concerns/packages/error_handling.rb b/app/workers/concerns/packages/error_handling.rb new file mode 100644 index 00000000000..26948d39912 --- /dev/null +++ b/app/workers/concerns/packages/error_handling.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Packages + module ErrorHandling + extend ActiveSupport::Concern + + DEFAULT_STATUS_MESSAGE = 'Unexpected error' + + CONTROLLED_ERRORS = [ + ArgumentError, + ActiveRecord::RecordInvalid, + ::Packages::Helm::ExtractFileMetadataService::ExtractionError, + ::Packages::Nuget::ExtractMetadataFileService::ExtractionError, + ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError, + ::Packages::Nuget::UpdatePackageFromMetadataService::ZipError, + ::Packages::Rubygems::ProcessGemService::ExtractionError, + ::Packages::Rubygems::ProcessGemService::InvalidMetadataError + ].freeze + + def process_package_file_error(package_file:, exception:, extra_log_payload: {}) + log_payload = { + project_id: package_file.project_id, + package_file_id: package_file.id + }.merge(extra_log_payload) + Gitlab::ErrorTracking.log_exception(exception, **log_payload) + + package_file.package.update_columns( + status: :error, + status_message: truncated_status_message(exception) + ) + end + + private + + def controlled_error?(exception) + CONTROLLED_ERRORS.include?(exception.class) + end + + def truncated_status_message(exception) + status_message = exception.message if controlled_error?(exception) + + # Do not save the exception message in case it contains confidential data + status_message ||= "#{DEFAULT_STATUS_MESSAGE}: #{exception.class}" + + status_message.truncate(::Packages::Package::STATUS_MESSAGE_MAX_LENGTH) + end + end +end diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index c260e06607c..02eda924b71 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -151,6 +151,10 @@ module WorkerAttributes set_class_attribute(:weight, value) end + def pause_control(value) + ::Gitlab::SidekiqMiddleware::PauseControl::WorkersMap.set_strategy_for(strategy: value, worker: self) + end + def get_weight get_class_attribute(:weight) || NAMESPACE_WEIGHTS[queue_namespace] || @@ -193,10 +197,10 @@ module WorkerAttributes !!get_class_attribute(:big_payload) end - def defer_on_database_health_signal(gitlab_schema, delay_by = DEFAULT_DEFER_DELAY, tables = []) + def defer_on_database_health_signal(gitlab_schema, tables = [], delay_by = DEFAULT_DEFER_DELAY) set_class_attribute( :database_health_check_attrs, - { gitlab_schema: gitlab_schema, delay_by: delay_by, tables: tables } + { gitlab_schema: gitlab_schema, tables: tables, delay_by: delay_by } ) end diff --git a/app/workers/environments/stop_job_success_worker.rb b/app/workers/environments/stop_job_success_worker.rb new file mode 100644 index 00000000000..cc7d83512f3 --- /dev/null +++ b/app/workers/environments/stop_job_success_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Environments + class StopJobSuccessWorker + include ApplicationWorker + + data_consistency :delayed + idempotent! + 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? + end + end + + private + + def stop_environment(build) + build.persisted_environment.fire_state_event(:stop_complete) + end + end +end diff --git a/app/workers/integrations/group_mention_worker.rb b/app/workers/integrations/group_mention_worker.rb index 6cde1657ccd..cbf70dc5c6a 100644 --- a/app/workers/integrations/group_mention_worker.rb +++ b/app/workers/integrations/group_mention_worker.rb @@ -22,19 +22,19 @@ module Integrations mentionable = case mentionable_type when 'Issue' - Issue.find(mentionable_id) + Issue.find_by_id(mentionable_id) when 'MergeRequest' - MergeRequest.find(mentionable_id) + MergeRequest.find_by_id(mentionable_id) + else + Sidekiq.logger.error( + message: 'Integrations::GroupMentionWorker: mentionable not supported', + mentionable_type: mentionable_type, + mentionable_id: mentionable_id + ) + nil end - if mentionable.nil? - Sidekiq.logger.error( - message: 'Integrations::GroupMentionWorker: mentionable not supported', - mentionable_type: mentionable_type, - mentionable_id: mentionable_id - ) - return - end + return if mentionable.nil? Integrations::GroupMentionService.new(mentionable, hook_data: hook_data, is_confidential: is_confidential).execute end diff --git a/app/workers/members/expiring_email_notification_worker.rb b/app/workers/members/expiring_email_notification_worker.rb new file mode 100644 index 00000000000..1d0a6eb254a --- /dev/null +++ b/app/workers/members/expiring_email_notification_worker.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Members + class ExpiringEmailNotificationWorker # rubocop:disable Scalability/CronWorkerContext + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + feature_category :system_access + urgency :low + idempotent! + + def perform(member_id) + notification_service = NotificationService.new + member = ::Member.find_by_id(member_id) + + return unless member + return unless Feature.enabled?(:member_expiring_email_notification, member.source.root_ancestor) + return if member.expiry_notified_at.present? + + with_context(user: member.user) do + notification_service.member_about_to_expire(member) + Gitlab::AppLogger.info(message: "Notifying user about expiring membership", member_id: member.id) + + member.update(expiry_notified_at: Time.current) + end + end + end +end diff --git a/app/workers/members/expiring_worker.rb b/app/workers/members/expiring_worker.rb new file mode 100644 index 00000000000..0d631af3a7c --- /dev/null +++ b/app/workers/members/expiring_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Members + class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :system_access + urgency :low + + BATCH_LIMIT = 500 + + def perform + return unless Feature.enabled?(:member_expiring_email_notification) + + limit_date = Member::DAYS_TO_EXPIRE.days.from_now.to_date + + expiring_members = Member.active.where(users: { user_type: :human }).expiring_and_not_notified(limit_date) # rubocop: disable CodeReuse/ActiveRecord + + expiring_members.each_batch(of: BATCH_LIMIT) do |members| + members.pluck_primary_key.each do |member_id| + Members::ExpiringEmailNotificationWorker.perform_async(member_id) + end + end + end + end +end diff --git a/app/workers/merge_requests/mergeability_check_batch_worker.rb b/app/workers/merge_requests/mergeability_check_batch_worker.rb index f48e9c234ab..e95c3952c8c 100644 --- a/app/workers/merge_requests/mergeability_check_batch_worker.rb +++ b/app/workers/merge_requests/mergeability_check_batch_worker.rb @@ -40,8 +40,7 @@ module MergeRequests private def merge_status_recheck_not_allowed?(merge_request, user) - ::Feature.enabled?(:restrict_merge_status_recheck, merge_request.project) && - !Ability.allowed?(user, :update_merge_request, merge_request.project) + !Ability.allowed?(user, :update_merge_request, merge_request.project) end end end diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb index 0e21e98d182..843560d4334 100644 --- a/app/workers/packages/debian/process_package_file_worker.rb +++ b/app/workers/packages/debian/process_package_file_worker.rb @@ -5,6 +5,7 @@ module Packages class ProcessPackageFileWorker include ApplicationWorker include Gitlab::Utils::StrongMemoize + include ::Packages::ErrorHandling data_consistency :always @@ -24,11 +25,16 @@ module Packages return unless package_file.debian_file_metadatum&.unknown? ::Packages::Debian::ProcessPackageFileService.new(package_file, distribution_name, component_name).execute - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, - distribution_name: @distribution_name, component_name: @component_name) + rescue StandardError => exception package_file.update_column(:status, :error) - package_file.package.update_column(:status, :error) + process_package_file_error( + package_file: package_file, + exception: exception, + extra_log_payload: { + distribution_name: @distribution_name, + component_name: @component_name + } + ) end private diff --git a/app/workers/packages/helm/extraction_worker.rb b/app/workers/packages/helm/extraction_worker.rb index 0ba2d149f77..ca043c5c8c7 100644 --- a/app/workers/packages/helm/extraction_worker.rb +++ b/app/workers/packages/helm/extraction_worker.rb @@ -4,6 +4,7 @@ module Packages module Helm class ExtractionWorker include ApplicationWorker + include ::Packages::ErrorHandling data_consistency :always @@ -19,10 +20,11 @@ module Packages return unless package_file && !package_file.package.default? ::Packages::Helm::ProcessFileService.new(channel, package_file).execute - - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) - package_file.package.update_column(:status, :error) + rescue StandardError => exception + process_package_file_error( + package_file: package_file, + exception: exception + ) end end end diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb index b8e00621aa1..55aca0beb03 100644 --- a/app/workers/packages/nuget/extraction_worker.rb +++ b/app/workers/packages/nuget/extraction_worker.rb @@ -4,6 +4,7 @@ module Packages module Nuget class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include ::Packages::ErrorHandling data_consistency :always @@ -18,10 +19,11 @@ module Packages return unless package_file ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute - - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) - package_file.package.update_column(:status, :error) + rescue StandardError => exception + process_package_file_error( + package_file: package_file, + exception: exception + ) end end end diff --git a/app/workers/packages/rubygems/extraction_worker.rb b/app/workers/packages/rubygems/extraction_worker.rb index dbaf9bc35a9..7076fdb3b90 100644 --- a/app/workers/packages/rubygems/extraction_worker.rb +++ b/app/workers/packages/rubygems/extraction_worker.rb @@ -4,6 +4,7 @@ module Packages module Rubygems class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include ::Packages::ErrorHandling data_consistency :always @@ -19,10 +20,11 @@ module Packages return unless package_file ::Packages::Rubygems::ProcessGemService.new(package_file).execute - - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) - package_file.package.update_column(:status, :error) + rescue StandardError => exception + process_package_file_error( + package_file: package_file, + exception: exception + ) end end end diff --git a/app/workers/pause_control/resume_worker.rb b/app/workers/pause_control/resume_worker.rb new file mode 100644 index 00000000000..98725c0b6f2 --- /dev/null +++ b/app/workers/pause_control/resume_worker.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module PauseControl + class ResumeWorker + include ApplicationWorker + # There is no onward scheduling and this cron handles work from across the + # application, so there's no useful context to add. + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + RESCHEDULE_DELAY = 1.second + + feature_category :global_search + data_consistency :sticky + idempotent! + urgency :low + + def perform + reschedule_job = false + + pause_strategies_workers.each do |strategy, workers| + strategy_klass = Gitlab::SidekiqMiddleware::PauseControl.for(strategy) + + next if strategy_klass.should_pause? + + workers.each do |worker| + next unless jobs_in_the_queue?(worker) + + queue_size = resume_processing!(worker) + reschedule_job = true if queue_size.to_i > 0 + end + end + + self.class.perform_in(RESCHEDULE_DELAY) if reschedule_job + end + + private + + def jobs_in_the_queue?(worker) + Gitlab::SidekiqMiddleware::PauseControl::PauseControlService.has_jobs_in_waiting_queue?(worker.to_s) + end + + def resume_processing!(worker) + Gitlab::SidekiqMiddleware::PauseControl::PauseControlService.resume_processing!(worker.to_s) + end + + def pause_strategies_workers + Gitlab::SidekiqMiddleware::PauseControl::WorkersMap.workers || [] + end + end +end diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 708dd3433cb..cc72704d8c9 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -19,6 +19,7 @@ class ProcessCommitWorker weight 3 idempotent! loggable_arguments 2, 3 + deduplicate :until_executed, feature_flag: :deduplicate_process_commit_worker # project_id - The ID of the project this commit belongs to. # user_id - The ID of the user that pushed the commit. diff --git a/app/workers/service_desk/custom_email_verification_cleanup_worker.rb b/app/workers/service_desk/custom_email_verification_cleanup_worker.rb new file mode 100644 index 00000000000..6434b9b09bb --- /dev/null +++ b/app/workers/service_desk/custom_email_verification_cleanup_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ServiceDesk + # Marks custom email verifications as failed when + # verification has started and timeframe to ingest + # the verification email has closed. + # + # This ensures we can finish the verification process and send verification result emails + # even when we did not receive any verification email. + class CustomEmailVerificationCleanupWorker + include ApplicationWorker + include CronjobQueue + + idempotent! + + data_consistency :sticky + feature_category :service_desk + + def perform + # Limit ensures we have 50ms per verification before another job gets scheduled. + collection = CustomEmailVerification.started.overdue.limit(2_400) + + collection.find_each do |verification| + with_context(project: verification.project) do + CustomEmailVerifications::UpdateService.new( + project: verification.project, + current_user: nil, + params: { + mail: nil + } + ).execute + end + end + end + end +end diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb index d024109e754..87566bff467 100644 --- a/app/workers/users/deactivate_dormant_users_worker.rb +++ b/app/workers/users/deactivate_dormant_users_worker.rb @@ -15,16 +15,21 @@ module Users return unless ::Gitlab::CurrentSettings.current_application_settings.deactivate_dormant_users - deactivate_users(User.dormant) - deactivate_users(User.with_no_activity) + admin_bot = User.admin_bot + return unless admin_bot + + deactivate_users(User.dormant, admin_bot) + deactivate_users(User.with_no_activity, admin_bot) end private - def deactivate_users(scope) + def deactivate_users(scope, admin_bot) with_context(caller_id: self.class.name.to_s) do scope.each_batch do |batch| - batch.each(&:deactivate) + batch.each do |user| + Users::DeactivateService.new(admin_bot).execute(user) + end end end end diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index 043a16e3527..cea0816f5a6 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -24,7 +24,10 @@ class WebHookWorker # present in the request header so the hook can pass this same header value in its request. Gitlab::WebHooks::RecursionDetection.set_request_uuid(params[:recursion_detection_request_uuid]) - WebHookService.new(hook, data, hook_name, jid).execute + WebHookService.new(hook, data, hook_name, jid).execute.tap do |response| + log_extra_metadata_on_done(:response_status, response.status) + log_extra_metadata_on_done(:http_status, response[:http_status]) + end end end # rubocop:enable Scalability/IdempotentWorker |