diff options
Diffstat (limited to 'app/assets')
1069 files changed, 18198 insertions, 9386 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; +} |