diff options
Diffstat (limited to 'app/assets')
756 files changed, 13162 insertions, 6551 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico Binary files differdeleted file mode 100644 index 48b1095370d..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico Binary files differdeleted file mode 100644 index 623c728faf6..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_created.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico Binary files differdeleted file mode 100644 index 3073fe5a761..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico Binary files differdeleted file mode 100644 index 6c713d7b675..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico Binary files differdeleted file mode 100644 index dbf855fdafd..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico Binary files differdeleted file mode 100644 index ccd00606aeb..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico Binary files differdeleted file mode 100644 index 6cdf3ae2e36..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico Binary files differdeleted file mode 100644 index 968e7c4c2d4..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_running.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico b/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico Binary files differdeleted file mode 100644 index 5444b8e41dc..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico Binary files differdeleted file mode 100644 index 7e3be35cc3a..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico Binary files differdeleted file mode 100644 index a1fb6e91d65..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_success.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico Binary files differdeleted file mode 100644 index 5d931619fb2..00000000000 --- a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico +++ /dev/null diff --git a/app/assets/images/mr_favicons/favicon_status_merged.png b/app/assets/images/mr_favicons/favicon_status_merged.png Binary files differnew file mode 100644 index 00000000000..0acb2e463a9 --- /dev/null +++ b/app/assets/images/mr_favicons/favicon_status_merged.png diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js new file mode 100644 index 00000000000..02a4a3c2f15 --- /dev/null +++ b/app/assets/javascripts/access_level/constants.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; + +// Matches `lib/gitlab/access.rb` +export const ACCESS_LEVEL_NO_ACCESS_INTEGER = 0; +export const ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER = 5; +export const ACCESS_LEVEL_GUEST_INTEGER = 10; +export const ACCESS_LEVEL_REPORTER_INTEGER = 20; +export const ACCESS_LEVEL_DEVELOPER_INTEGER = 30; +export const ACCESS_LEVEL_MAINTAINER_INTEGER = 40; +export const ACCESS_LEVEL_OWNER_INTEGER = 50; + +export const ACCESS_LEVEL_LABELS = { + [ACCESS_LEVEL_NO_ACCESS_INTEGER]: __('No access'), + [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: __('Minimal Access'), + [ACCESS_LEVEL_GUEST_INTEGER]: __('Guest'), + [ACCESS_LEVEL_REPORTER_INTEGER]: __('Reporter'), + [ACCESS_LEVEL_DEVELOPER_INTEGER]: __('Developer'), + [ACCESS_LEVEL_MAINTAINER_INTEGER]: __('Maintainer'), + [ACCESS_LEVEL_OWNER_INTEGER]: __('Owner'), +}; 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 c66b595ffdc..a5f8f369604 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,10 +1,15 @@ <script> -import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import { createAlert } from '~/alert'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { + OPERATORS_IS, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import eventHub from '../event_hub'; import { findCommitIndex, @@ -12,6 +17,7 @@ import { removeIfReadyToBeRemoved, removeIfPresent, } from '../utils'; +import Token from './token.vue'; export default { components: { @@ -19,9 +25,9 @@ export default { GlTabs, GlTab, ReviewTabContainer, - GlSearchBoxByType, GlSprintf, GlBadge, + GlFilteredSearch, }, props: { contextCommitsPath: { @@ -41,6 +47,49 @@ export default { required: true, }, }, + data() { + return { + availableTokens: [ + { + icon: 'pencil', + title: __('Author'), + type: TOKEN_TYPE_AUTHOR, + operators: OPERATORS_IS, + token: UserToken, + defaultAuthors: [], + unique: true, + fetchAuthors: this.fetchAuthors, + initialAuthors: [], + }, + { + formattedKey: __('Committed-before'), + key: 'committed-before', + type: 'committed-before-date', + param: '', + symbol: '', + icon: 'clock', + tag: 'committed_before', + title: __('Committed-before'), + operators: OPERATORS_IS, + token: Token, + unique: true, + }, + { + formattedKey: __('Committed-after'), + key: 'committed-after', + type: 'committed-after-date', + param: '', + symbol: '', + icon: 'clock', + tag: 'committed_after', + title: __('Committed-after'), + operators: OPERATORS_IS, + token: Token, + unique: true, + }, + ], + }; + }, computed: { ...mapState([ 'tabIndex', @@ -98,8 +147,6 @@ export default { }, beforeDestroy() { eventHub.$off('openModal', this.openModal); - clearTimeout(this.timeout); - this.timeout = null; }, methods: { ...mapActions([ @@ -114,10 +161,8 @@ export default { 'setSearchText', 'setToRemoveCommits', 'resetModalState', + 'fetchAuthors', ]), - focusSearch() { - this.$refs.searchInput.focusInput(); - }, openModal() { this.searchCommits(); this.fetchContextCommits(); @@ -125,7 +170,6 @@ export default { }, handleTabChange(tabIndex) { if (tabIndex === 0) { - this.focusSearch(); if (this.shouldPurge) { this.setSelectedCommits( [...this.commits, ...this.selectedCommits].filter((commit) => commit.isSelected), @@ -133,17 +177,36 @@ export default { } } }, - handleSearchCommits(value) { - // We only call the service, if we have 3 characters or we don't have any characters - if (value.length >= 3) { - clearTimeout(this.timeout); - this.timeout = setTimeout(() => { - this.searchCommits(value); - }, 500); - } else if (value.length === 0) { - this.searchCommits(); + blurSearchInput() { + const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector( + '.gl-filtered-search-token-segment-input', + ); + if (searchInputEl) { + searchInputEl.blur(); } - this.setSearchText(value); + }, + handleSearchCommits(value = []) { + const searchValues = value.reduce((acc, searchFilter) => { + const isEqualSearch = searchFilter?.value?.operator === '='; + + if (!isEqualSearch && typeof searchFilter === 'object') return acc; + + if (typeof searchFilter === 'string' && searchFilter.length >= 3) { + acc.searchText = searchFilter; + } else if (searchFilter?.type === 'author' && searchFilter?.value?.data?.length >= 3) { + acc.author = searchFilter?.value?.data; + } else if (searchFilter?.type === 'committed-before-date') { + acc.committed_before = searchFilter?.value?.data; + } else if (searchFilter?.type === 'committed-after-date') { + acc.committed_after = searchFilter?.value?.data; + } + + return acc; + }, {}); + + this.searchCommits(searchValues); + this.blurSearchInput(); + this.setSearchText(searchValues.searchText); }, handleCommitRowSelect(event) { const index = event[0]; @@ -208,11 +271,12 @@ export default { }, handleModalClose() { this.resetModalState(); - clearTimeout(this.timeout); }, handleModalHide() { this.resetModalState(); - clearTimeout(this.timeout); + }, + shouldShowInputDateFormat(value) { + return ['Committed-before', 'Committed-after'].indexOf(value) !== -1; }, }, }; @@ -223,13 +287,14 @@ export default { ref="modal" cancel-variant="light" size="md" + no-focus-on-show + modal-class="add-review-item-modal" body-class="add-review-item pt-0" :scrollable="true" :ok-title="__('Save changes')" modal-id="add-review-item" :title="__('Add or remove previously merged commits')" :ok-disabled="disableSaveButton" - @shown="focusSearch" @ok="handleCreateContextCommits" @cancel="handleModalClose" @close="handleModalClose" @@ -245,11 +310,24 @@ export default { </gl-sprintf> </template> <div class="gl-mt-3"> - <gl-search-box-by-type - ref="searchInput" - :placeholder="__(`Search by commit title or SHA`)" - @input="handleSearchCommits" - /> + <gl-filtered-search + ref="filteredSearchInput" + class="flex-grow-1" + :placeholder="__(`Search or filter commits`)" + :available-tokens="availableTokens" + @clear="handleSearchCommits" + @submit="handleSearchCommits" + > + <template #title="{ value }"> + <div> + {{ value }} + <span v-if="shouldShowInputDateFormat(value)" class="title-hint-text"> + <{{ __('yyyy-mm-dd') }}> + </span> + </div> + </template> + </gl-filtered-search> + <review-tab-container :is-loading="isLoadingCommits" :loading-error="commitsLoadingError" diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue new file mode 100644 index 00000000000..c403adbbf60 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue @@ -0,0 +1,28 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; + +export default { + components: { + GlFilteredSearchToken, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + val: '', + }; + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" /> +</template> diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js index de9c7488ace..f085b0d0e5e 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/actions.js +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -1,8 +1,11 @@ import _ from 'lodash'; +import * as Sentry from '@sentry/browser'; import Api from '~/api'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants'; import * as types from './mutation_types'; export const setBaseConfig = ({ commit }, options) => { @@ -11,14 +14,14 @@ export const setBaseConfig = ({ commit }, options) => { export const setTabIndex = ({ commit }, tabIndex) => commit(types.SET_TABINDEX, tabIndex); -export const searchCommits = ({ dispatch, commit, state }, searchText) => { +export const searchCommits = ({ dispatch, commit, state }, search = {}) => { commit(types.FETCH_COMMITS); let params = {}; - if (searchText) { + if (search) { params = { params: { - search: searchText, + ...search, per_page: 40, }, }; @@ -37,7 +40,7 @@ export const searchCommits = ({ dispatch, commit, state }, searchText) => { } return c; }); - if (!searchText) { + if (!search) { dispatch('setCommits', { commits: [...commits, ...state.contextCommits] }); } else { dispatch('setCommits', { commits }); @@ -131,6 +134,23 @@ export const setSelectedCommits = ({ commit }, selected) => { commit(types.SET_SELECTED_COMMITS, selectedCommits); }; +export const fetchAuthors = ({ dispatch, state }, author = null) => { + const { projectId } = state; + return axios + .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), { + params: { + project_id: projectId, + states: ACTIVE_AND_BLOCKED_USER_STATES, + search: author, + }, + }) + .then(({ data }) => data) + .catch((error) => { + Sentry.captureException(error); + dispatch('receiveAuthorsError'); + }); +}; + export const setSearchText = ({ commit }, searchText) => commit(types.SET_SEARCH_TEXT, searchText); export const setToRemoveCommits = ({ commit }, data) => commit(types.SET_TO_REMOVE_COMMITS, data); 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 0bf3441379b..560834a26ae 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/index.js +++ b/app/assets/javascripts/add_context_commits_modal/store/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters'; import * as actions from './actions'; import mutations from './mutations'; import state from './state'; @@ -12,4 +13,5 @@ export default () => state: state(), actions, mutations, + modules: { filters }, }); diff --git a/app/assets/javascripts/add_context_commits_modal/store/state.js b/app/assets/javascripts/add_context_commits_modal/store/state.js index 37239adccbb..fed3148bc9e 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/state.js +++ b/app/assets/javascripts/add_context_commits_modal/store/state.js @@ -1,4 +1,5 @@ export default () => ({ + projectId: '', contextCommitsPath: '', tabIndex: 0, isLoadingCommits: false, diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue new file mode 100644 index 00000000000..f2271f8af24 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue @@ -0,0 +1,148 @@ +<script> +import { GlButton, GlDropdown, GlModal } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { ACTIONS_I18N } from '../constants'; + +const modalActionButtonAttributes = { + block: { + text: __('OK'), + attributes: { + variant: 'confirm', + }, + }, + removeUserAndReport: { + text: __('OK'), + attributes: { + variant: 'danger', + }, + }, + secondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, +}; + +export default { + name: 'AbuseReportActions', + components: { + GlButton, + GlDropdown, + GlModal, + }, + modalId: 'abuse-report-row-action-confirm-modal', + modalActionButtonAttributes, + i18n: ACTIONS_I18N, + props: { + report: { + type: Object, + required: true, + }, + }, + data() { + return { + userBlocked: this.report.userBlocked, + confirmModalShown: false, + actionToConfirm: 'block', + }; + }, + computed: { + blockUserButtonText() { + const { alreadyBlocked, blockUser } = this.$options.i18n; + + return this.userBlocked ? alreadyBlocked : blockUser; + }, + removeUserAndReportConfirmText() { + return sprintf(this.$options.i18n.removeUserAndReportConfirm, { + user: this.report.reportedUser.name, + }); + }, + modalData() { + return { + block: { + action: this.blockUser, + confirmText: this.$options.i18n.blockUserConfirm, + }, + removeUserAndReport: { + action: this.removeUserAndReport, + confirmText: this.removeUserAndReportConfirmText, + }, + }; + }, + }, + methods: { + showConfirmModal(action) { + this.confirmModalShown = true; + this.actionToConfirm = action; + }, + blockUser() { + axios + .put(this.report.blockUserPath) + .then(this.handleBlockUserResponse) + .catch(this.handleError); + }, + removeUserAndReport() { + axios + .delete(this.report.removeUserAndReportPath) + .then(this.handleRemoveReportResponse) + .catch(this.handleError); + }, + removeReport() { + axios + .delete(this.report.removeReportPath) + .then(this.handleRemoveReportResponse) + .catch(this.handleError); + }, + handleRemoveReportResponse() { + window.location.reload(); + }, + handleBlockUserResponse({ data }) { + const message = data?.error || data?.notice; + const alertOptions = data?.notice ? { variant: VARIANT_SUCCESS } : {}; + + if (message) { + createAlert({ message, ...alertOptions }); + } + + if (!data?.error) { + this.userBlocked = true; + } + }, + handleError(error) { + createAlert({ + message: __('Something went wrong. Please try again.'), + captureError: true, + error, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown text="Actions" text-sr-only icon="ellipsis_v" category="tertiary" no-caret right> + <div class="gl-px-2"> + <gl-button block variant="danger" @click="showConfirmModal('removeUserAndReport')"> + {{ $options.i18n.removeUserAndReport }} + </gl-button> + <gl-button block :disabled="userBlocked" @click="showConfirmModal('block')"> + {{ blockUserButtonText }} + </gl-button> + <gl-button block @click="removeReport"> + {{ $options.i18n.removeReport }} + </gl-button> + </div> + <gl-modal + v-model="confirmModalShown" + :modal-id="$options.modalId" + :title="modalData[actionToConfirm].confirmText" + size="sm" + :action-primary="$options.modalActionButtonAttributes[actionToConfirm]" + :action-secondary="$options.modalActionButtonAttributes.secondary" + @primary="modalData[actionToConfirm].action" + /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue new file mode 100644 index 00000000000..f49411604f1 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue @@ -0,0 +1,66 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlCollapse } from '@gitlab/ui'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + components: { + GlButton, + GlCollapse, + }, + directives: { SafeHtml }, + props: { + report: { + type: Object, + required: true, + }, + }, + data() { + return { + isVisible: false, + collapseId: uniqueId('abuse-report-detail-'), + }; + }, + computed: { + toggleText() { + return this.isVisible ? __('Hide details') : __('Show details'); + }, + reportedUserCreatedAt() { + const { reportedUser } = this.report; + return sprintf(__('User joined %{timeAgo}'), { + timeAgo: getTimeago().format(reportedUser.createdAt), + }); + }, + }, + methods: { + toggleCollapse() { + this.isVisible = !this.isVisible; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <gl-collapse :id="collapseId" v-model="isVisible"> + <dl class="gl-mb-2"> + <dd>{{ reportedUserCreatedAt }}</dd> + + <dt>{{ __('Message') }}</dt> + <dd v-safe-html="report.message"></dd> + </dl> + </gl-collapse> + <div> + <gl-button + :aria-expanded="`${isVisible}`" + :aria-controls="collapseId" + size="small" + variant="link" + @click="toggleCollapse" + >{{ toggleText }} + </gl-button> + </div> + </div> +</template> 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 a4211002f71..a9fe59a7b85 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue @@ -1,11 +1,20 @@ <script> +import { GlSprintf, GlLink } from '@gitlab/ui'; import { getTimeago } from '~/lib/utils/datetime_utility'; +import { queryToObject } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { SORT_UPDATED_AT } from '../constants'; +import AbuseReportActions from './abuse_report_actions.vue'; +import AbuseReportDetails from './abuse_report_details.vue'; export default { name: 'AbuseReportRow', components: { + AbuseReportDetails, + GlLink, + GlSprintf, + AbuseReportActions, ListItem, }, props: { @@ -15,14 +24,31 @@ export default { }, }, computed: { - updatedAt() { - const template = __('Updated %{timeAgo}'); - return sprintf(template, { timeAgo: getTimeago().format(this.report.updatedAt) }); + displayDate() { + 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 }; + + return sprintf(template, { timeAgo: getTimeago().format(timeAgo) }); + }, + reported() { + const { reportedUser } = this.report; + return sprintf('%{userLinkStart}%{reported}%{userLinkEnd}', { + reported: reportedUser.name, + }); + }, + reporter() { + const { reporter } = this.report; + return sprintf('%{reporterLinkStart}%{reporter}%{reporterLinkEnd}', { + reporter: reporter.name, + }); }, title() { - const { reportedUser, reporter, category } = this.report; + const { category } = this.report; const template = __('%{reported} reported for %{category} by %{reporter}'); - return sprintf(template, { reported: reportedUser.name, reporter: reporter.name, category }); + return sprintf(template, { reported: this.reported, reporter: this.reporter, category }); }, }, }; @@ -31,11 +57,25 @@ export default { <template> <list-item data-testid="abuse-report-row"> <template #left-primary> - <div class="gl-font-weight-normal" data-testid="title">{{ title }}</div> + <div class="gl-font-weight-normal gl-mb-2" data-testid="title"> + <gl-sprintf :message="title"> + <template #userLink="{ content }"> + <gl-link :href="report.reportedUserPath">{{ content }}</gl-link> + </template> + <template #reporterLink="{ content }"> + <gl-link :href="report.reporterPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + + <template #left-secondary> + <abuse-report-details :report="report" /> </template> <template #right-secondary> - <div data-testid="updated-at">{{ updatedAt }}</div> + <div data-testid="abuse-report-date">{{ displayDate }}</div> + <abuse-report-actions :report="report" /> </template> </list-item> </template> 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 b60fe3ae9b8..e1989cadd86 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 @@ -8,7 +8,7 @@ import { SORT_OPTIONS, isValidSortKey, } from '~/admin/abuse_reports/constants'; -import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils'; +import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils'; export default { name: 'AbuseReportsFilteredSearchBar', @@ -32,7 +32,7 @@ export default { // 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. - if (!query.status) { + if (!isValidStatus(query.status)) { query.status = 'open'; updateHistory({ url: setUrlParams(query), replace: true }); } diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js index ee2e9ab2cbf..ee002f269ac 100644 --- a/app/assets/javascripts/admin/abuse_reports/constants.js +++ b/app/assets/javascripts/admin/abuse_reports/constants.js @@ -40,25 +40,24 @@ export const FILTERED_SEARCH_TOKEN_STATUS = { }; export const DEFAULT_SORT = 'created_at_desc'; - -export const SORT_OPTIONS = [ - { - id: 10, - title: __('Created date'), - sortDirection: { - descending: DEFAULT_SORT, - ascending: 'created_at_asc', - }, +export const SORT_UPDATED_AT = Object.freeze({ + id: 20, + title: __('Updated date'), + sortDirection: { + descending: 'updated_at_desc', + ascending: 'updated_at_asc', }, - { - id: 20, - title: __('Updated date'), - sortDirection: { - descending: 'updated_at_desc', - ascending: 'updated_at_asc', - }, +}); +const SORT_CREATED_AT = Object.freeze({ + id: 10, + title: __('Created date'), + sortDirection: { + descending: DEFAULT_SORT, + ascending: 'created_at_asc', }, -]; +}); + +export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT]; export const isValidSortKey = (key) => SORT_OPTIONS.some( @@ -79,3 +78,12 @@ export const FILTERED_SEARCH_TOKENS = [ FILTERED_SEARCH_TOKEN_REPORTER, FILTERED_SEARCH_TOKEN_STATUS, ]; + +export const ACTIONS_I18N = { + blockUserConfirm: __('USER WILL BE BLOCKED! Are you sure?'), + blockUser: __('Block user'), + alreadyBlocked: __('Already blocked'), + removeUserAndReportConfirm: __('USER %{user} WILL BE REMOVED! Are you sure?'), + removeUserAndReport: __('Remove user & report'), + removeReport: __('Remove report'), +}; diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js index 84221901089..d30e8fb0ae5 100644 --- a/app/assets/javascripts/admin/abuse_reports/utils.js +++ b/app/assets/javascripts/admin/abuse_reports/utils.js @@ -1,6 +1,9 @@ -import { FILTERED_SEARCH_TOKEN_CATEGORY } from './constants'; +import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants'; export const buildFilteredSearchCategoryToken = (categories) => { const options = categories.map((c) => ({ value: c, title: c })); return { ...FILTERED_SEARCH_TOKEN_CATEGORY, options }; }; + +export const isValidStatus = (status) => + FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status); 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 4482198675d..3168d693234 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -3,6 +3,7 @@ import { GlButton, GlBroadcastMessage, GlForm, + GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup, GlFormInput, @@ -18,7 +19,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { THEMES, TYPES, TYPE_BANNER } from '../constants'; -import MessageFormGroup from './message_form_group.vue'; import DatetimePicker from './datetime_picker.vue'; const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } }; @@ -31,13 +31,13 @@ export default { GlButton, GlBroadcastMessage, GlForm, + GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup, GlFormInput, GlFormSelect, GlFormText, GlFormTextarea, - MessageFormGroup, }, directives: { SafeHtml, @@ -189,7 +189,7 @@ export default { <div v-safe-html:[$options.safeHtmlConfig]="messagePreview"></div> </gl-broadcast-message> - <message-form-group :label="$options.i18n.message" label-for="message-textarea"> + <gl-form-group :label="$options.i18n.message" label-for="message-textarea"> <gl-form-textarea id="message-textarea" v-model="message" @@ -198,23 +198,23 @@ export default { :placeholder="$options.i18n.messagePlaceholder" data-testid="message-input" /> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.type" label-for="type-select"> + <gl-form-group :label="$options.i18n.type" label-for="type-select"> <gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" /> - </message-form-group> + </gl-form-group> <template v-if="isBanner"> - <message-form-group :label="$options.i18n.theme" label-for="theme-select"> + <gl-form-group :label="$options.i18n.theme" label-for="theme-select"> <gl-form-select id="theme-select" v-model="theme" :options="$options.messageThemes" data-testid="theme-select" /> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox"> + <gl-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox"> <gl-form-checkbox id="dismissable-checkbox" v-model="dismissable" @@ -223,10 +223,10 @@ export default { > <span>{{ $options.i18n.dismissableDescription }}</span> </gl-form-checkbox> - </message-form-group> + </gl-form-group> </template> - <message-form-group + <gl-form-group v-if="glFeatures.roleTargetedBroadcastMessages" :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes" @@ -235,24 +235,24 @@ export default { <gl-form-text> {{ $options.i18n.targetRolesDescription }} </gl-form-text> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> + <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> <gl-form-input id="target-path-input" v-model="targetPath" /> <gl-form-text> {{ $options.i18n.targetPathDescription }} </gl-form-text> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.startsAt"> + <gl-form-group :label="$options.i18n.startsAt"> <datetime-picker v-model="startsAt" /> - </message-form-group> + </gl-form-group> - <message-form-group :label="$options.i18n.endsAt"> + <gl-form-group :label="$options.i18n.endsAt"> <datetime-picker v-model="endsAt" /> - </message-form-group> + </gl-form-group> - <div class="form-actions gl-my-3"> + <div class="gl-my-5"> <gl-button type="submit" variant="confirm" diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue deleted file mode 100644 index eec51c0c28b..00000000000 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue +++ /dev/null @@ -1,34 +0,0 @@ -<script> -import { GlFormGroup } from '@gitlab/ui'; - -export default { - name: 'MessageFormGroup', - components: { - GlFormGroup, - }, - props: { - label: { - type: String, - required: true, - }, - labelFor: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> -<template> - <div> - <gl-form-group - :label="label" - :label-for="labelFor" - label-cols-sm="2" - label-class="gl-mt-3" - label-align-sm="right" - > - <slot></slot> - </gl-form-group> - </div> -</template> diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue index 0099c8da8e6..af09c7618e2 100644 --- a/app/assets/javascripts/admin/users/components/actions/activate.vue +++ b/app/assets/javascripts/admin/users/components/actions/activate.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -15,7 +15,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -52,7 +52,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 52560ebe5b1..2060528c7a0 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -17,7 +17,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -54,7 +54,9 @@ export default { </script> <template> - <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item data-qa-selector="approve_user_button" @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index 203d076914f..d7bdceb4798 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; @@ -30,7 +30,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -67,7 +67,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue index d50b76aaa92..534e1c76b8f 100644 --- a/app/assets/javascripts/admin/users/components/actions/block.vue +++ b/app/assets/javascripts/admin/users/components/actions/block.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -18,7 +18,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -53,7 +53,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue index ab1069601d2..40911131d6d 100644 --- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue +++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -25,7 +25,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -62,7 +62,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index d4f9ff4e529..83aa78c9f03 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -1,11 +1,11 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -49,9 +49,11 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <span class="gl-text-red-500"> - <slot></slot> - </span> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <span class="gl-text-red-500"> + <slot></slot> + </span> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index 413804c9a3b..24f0cac73f5 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { associationsCount } from '~/api/user_api'; import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub'; @@ -9,7 +9,7 @@ export default { loading: __('Loading'), }, components: { - GlDropdownItem, + GlDisclosureDropdownItem, GlLoadingIcon, }, props: { @@ -71,13 +71,15 @@ export default { </script> <template> - <gl-dropdown-item :disabled="loading" :aria-busy="loading" @click.capture.native.stop="onClick"> - <div v-if="loading" class="gl-display-flex gl-align-items-center"> - <gl-loading-icon class="gl-mr-3" /> - {{ $options.i18n.loading }} - </div> - <span v-else class="gl-text-red-500"> - <slot></slot> - </span> - </gl-dropdown-item> + <gl-disclosure-dropdown-item :disabled="loading" :aria-busy="loading" @action="onClick"> + <template #list-item> + <div v-if="loading" class="gl-display-flex gl-align-items-center"> + <gl-loading-icon class="gl-mr-3" /> + {{ $options.i18n.loading }} + </div> + <span v-else class="gl-text-red-500"> + <slot></slot> + </span> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue index 2b9c4acfcb5..7f786991709 100644 --- a/app/assets/javascripts/admin/users/components/actions/reject.vue +++ b/app/assets/javascripts/admin/users/components/actions/reject.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; @@ -28,7 +28,7 @@ const messageHtml = ` export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -65,7 +65,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue index 42b6fb3bdd4..f84c7594f87 100644 --- a/app/assets/javascripts/admin/users/components/actions/unban.vue +++ b/app/assets/javascripts/admin/users/components/actions/unban.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; @@ -11,7 +11,7 @@ const messageHtml = `<p>${s__( export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -48,7 +48,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index f94e128a945..064f05ef8b1 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -1,12 +1,12 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -42,7 +42,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue index c78c260b4fe..039ab3d651e 100644 --- a/app/assets/javascripts/admin/users/components/actions/unlock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue @@ -1,12 +1,12 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { components: { - GlDropdownItem, + GlDisclosureDropdownItem, }, props: { username: { @@ -41,7 +41,9 @@ export default { </script> <template> - <gl-dropdown-item @click="onClick"> - <slot></slot> - </gl-dropdown-item> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index c1fb80959cf..38c7d3f9b90 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -1,10 +1,9 @@ <script> import { GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlTooltipDirective, } from '@gitlab/ui'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; @@ -17,10 +16,9 @@ import Actions from './actions'; export default { components: { GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, ...Actions, }, directives: { @@ -63,6 +61,9 @@ export default { hasEditAction() { return this.userActions.includes('edit'); }, + hasEditActionOnly() { + return this.hasEditAction === true && this.hasDeleteActions === false; + }, userPaths() { return generateUserPaths(this.paths, this.user.username); }, @@ -93,10 +94,13 @@ export default { class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2" :data-testid="`user-actions-${user.id}`" > - <div v-if="hasEditAction" class="gl-p-2"> - <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{ - $options.i18n.edit - }}</gl-button> + <div v-if="hasEditAction" class="gl-p-2" :class="{ 'gl-mr-3': hasEditActionOnly }"> + <gl-button + v-if="showButtonLabels" + v-bind="editButtonAttrs" + :class="{ 'gl-mr-7': hasEditActionOnly }" + >{{ $options.i18n.edit }}</gl-button + > <gl-button v-else v-gl-tooltip="$options.i18n.edit" @@ -107,12 +111,15 @@ export default { </div> <div v-if="hasDropdownActions" class="gl-p-2"> - <gl-dropdown - :text="$options.i18n.userAdministration" + <gl-disclosure-dropdown + icon="ellipsis_v" + category="tertiary" + :toggle-text="$options.i18n.userAdministration" + text-sr-only data-testid="dropdown-toggle" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" - left + no-caret > <template v-for="action in dropdownSafeActions"> <component @@ -125,28 +132,32 @@ export default { > {{ $options.i18n[action] }} </component> - <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action"> - {{ $options.i18n[action] }} - </gl-dropdown-item> - </template> - - <gl-dropdown-divider v-if="hasDeleteActions" /> - - <template v-for="action in dropdownDeleteActions"> - <component - :is="getActionComponent(action)" - v-if="getActionComponent(action)" + <gl-disclosure-dropdown-item + v-else-if="isLdapAction(action)" :key="action" - :paths="userPaths" - :username="user.name" - :user-id="user.id" - :user-deletion-obstacles="obstaclesForUserDeletion" - :data-testid="`delete-${action}`" + :data-testid="action" > {{ $options.i18n[action] }} - </component> + </gl-disclosure-dropdown-item> </template> - </gl-dropdown> + + <gl-disclosure-dropdown-group v-if="hasDeleteActions" bordered> + <template v-for="action in dropdownDeleteActions"> + <component + :is="getActionComponent(action)" + v-if="getActionComponent(action)" + :key="action" + :paths="userPaths" + :username="user.name" + :user-id="user.id" + :user-deletion-obstacles="obstaclesForUserDeletion" + :data-testid="`delete-${action}`" + > + {{ $options.i18n[action] }} + </component> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </div> </div> </template> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index e55622d40ba..2d2c598f953 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -135,7 +135,7 @@ export default { </template> <template #cell(settings)="{ item: user }"> - <user-actions :user="user" :paths="paths" /> + <user-actions :user="user" :paths="paths" :show-button-labels="true" /> </template> </gl-table> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 5229d4c9ae2..170bd6895aa 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -12,6 +12,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql'; +import { STATUS_CLOSED } from '~/issues/constants'; import { sortObjectToString } from '~/lib/utils/table_utility'; import { fetchPolicies } from '~/lib/graphql'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; @@ -229,7 +230,7 @@ export default { }, getIssueMeta({ issue: { iid, state } }) { return { - state: state === 'closed' ? `(${this.$options.i18n.closed})` : '', + state: state === STATUS_CLOSED ? `(${this.$options.i18n.closed})` : '', link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid), }; }, diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index c98d3865621..4ab772e4523 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -37,12 +37,10 @@ export const ALERTS_STATUS_TABS = [ }, ]; -/* eslint-disable @gitlab/require-i18n-strings */ - /** * Tracks snowplow event when user views alerts list */ export const trackAlertListViewsOptions = { - category: 'Alert Management', + category: 'Alert Management', // eslint-disable-line @gitlab/require-i18n-strings action: 'view_alerts_list', }; diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index 2733a59f62d..1a586bd1e91 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -142,7 +142,7 @@ export default { {{ $options.i18n.columns.fallbackKeyTitle }} <gl-icon v-gl-tooltip - name="question" + name="question-o" class="gl-text-gray-500" :title="$options.i18n.fallbackTooltip" /> diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js index e45ea772ddd..4df5ed425a5 100644 --- a/app/assets/javascripts/alerts_settings/services/index.js +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import axios from '~/lib/utils/axios_utils'; export default { @@ -9,7 +8,7 @@ export default { return axios.post(endpoint, data, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}`, // eslint-disable-line @gitlab/require-i18n-strings }, }); }, diff --git a/app/assets/javascripts/analytics/cycle_analytics/bundle.js b/app/assets/javascripts/analytics/cycle_analytics/bundle.js new file mode 100644 index 00000000000..9fe31620938 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/bundle.js @@ -0,0 +1 @@ +export { default } from '~/analytics/cycle_analytics'; diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index 704b4ce9c8a..365cbeaf6a2 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -107,7 +107,9 @@ export default { }, showLinkToDashboard() { return Boolean( - this.features?.groupLevelAnalyticsDashboard && this.features?.groupAnalyticsDashboardsPage, + this.features?.groupLevelAnalyticsDashboard && + this.features?.groupAnalyticsDashboardsPage && + this.groupPath, ); }, dashboardsPath() { @@ -129,6 +131,9 @@ export default { page: this.pagination?.page || null, }; }, + filterBarNamespacePath() { + return this.groupPath || this.namespace.fullPath; + }, }, methods: { ...mapActions([ @@ -168,10 +173,11 @@ export default { <div> <h3>{{ $options.i18n.pageTitle }}</h3> <value-stream-filters - :group-path="groupPath" + :namespace-path="filterBarNamespacePath" :has-project-filter="false" :start-date="createdAfter" :end-date="createdBefore" + :group-path="groupPath" @setDateRange="onSetDateRange" /> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> 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 54b632968e2..133513d6c21 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -30,7 +30,7 @@ export default { UrlSync, }, props: { - groupPath: { + namespacePath: { type: String, required: true, }, @@ -141,7 +141,7 @@ export default { <div> <filtered-search-bar class="gl-flex-grow-1" - :namespace="groupPath" + :namespace="namespacePath" recent-searches-storage-key="value-stream-analytics" :search-input-placeholder="__('Filter results')" :tokens="tokens" diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue index 4c7e18f9895..b9d1c4b0fe0 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue @@ -31,6 +31,10 @@ export default { required: false, default: true, }, + namespacePath: { + type: String, + required: true, + }, groupPath: { type: String, required: true, @@ -69,7 +73,7 @@ export default { <filter-bar data-testid="vsa-filter-bar" class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none" - :group-path="groupPath" + :namespace-path="namespacePath" /> <div v-if="hasDateRangeFilter || hasProjectFilter" diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js index 9265ff952e0..d7c3804113e 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/utils.js +++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js @@ -1,3 +1,4 @@ +import { extractVSAFeaturesFromGON } from '~/analytics/shared/utils'; import { parseSeconds } from '~/lib/utils/datetime_utility'; import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility'; import { joinPaths } from '~/lib/utils/url_utility'; @@ -64,28 +65,6 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => stages.filter(({ hidden = false }) => hidden === isHidden); /** - * @typedef {Object} MetricData - * @property {String} title - Title of the metric measured - * @property {String} value - String representing the decimal point value, e.g '1.5' - * @property {String} [unit] - String representing the decimal point value, e.g '1.5' - * - * @typedef {Object} TransformedMetricData - * @property {String} label - Title of the metric measured - * @property {String} value - String representing the decimal point value, e.g '1.5' - * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute - * @property {String} description - String to display for a description - * @property {String} unit - String representing the decimal point value, e.g '1.5' - */ - -const extractFeatures = (gon) => ({ - // licensed feature toggles - cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), - groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard), - // feature flags - groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage), -}); - -/** * Builds the initial data object for Value Stream Analytics with data loaded from the backend * * @param {Object} dataset - dataset object paseed to the frontend via data-* properties @@ -99,11 +78,10 @@ export const buildCycleAnalyticsInitialData = ({ createdBefore, namespaceName, namespaceFullPath, - gon, } = {}) => { return { projectId: parseInt(projectId, 10), - groupPath: `groups/${groupPath}`, + groupPath, namespace: { name: namespaceName, fullPath: namespaceFullPath, @@ -111,7 +89,7 @@ export const buildCycleAnalyticsInitialData = ({ createdAfter: new Date(createdAfter), createdBefore: new Date(createdBefore), selectedStage: stage ? JSON.parse(stage) : null, - features: extractFeatures(gon), + features: extractVSAFeaturesFromGON(), }; }; diff --git a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue index 95a6447ebaf..6c79c8af54a 100644 --- a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue +++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue @@ -13,6 +13,7 @@ export default { }, i18n: { title: __('Related'), + // eslint-disable-next-line @gitlab/require-valid-i18n-helpers linkText: __('Value Streams Dashboard | DORA'), }, }; diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index a85f3fb3730..88a0f6f30cb 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -129,12 +129,28 @@ export const fetchMetricsData = (requests = [], requestPath, params) => { * @param {Array} projectPaths - Array of project paths to include in the `query` parameter * @returns a URL or blank string if there is no groupPath set */ -export const generateValueStreamsDashboardLink = (groupPath, projectPaths = []) => { - if (groupPath.length) { +export const generateValueStreamsDashboardLink = (namespacePath, projectPaths = []) => { + if (namespacePath.length) { const query = projectPaths.length ? `?query=${projectPaths.join(',')}` : ''; const dashboardsSlug = '/-/analytics/dashboards/value_streams_dashboard'; - const segments = [gon.relative_url_root || '', '/', groupPath, dashboardsSlug]; + const segments = [gon.relative_url_root || '', '/', namespacePath, dashboardsSlug]; return joinPaths(...segments).concat(query); } return ''; }; + +/** + * Extracts the relevant feature and license flags needed for VSA + * + * @param {Object} gon the global `window.gon` object populated when the page loads + * @returns an object containing the extracted feature flags and their boolean status + */ +export const extractVSAFeaturesFromGON = () => ({ + // licensed feature toggles + cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), + cycleAnalyticsForProjects: Boolean(gon?.licensed_features?.cycleAnalyticsForProjects), + groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard), + // feature flags + groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage), + vsaGroupAndProjectParity: Boolean(gon?.features?.vsaGroupAndProjectParity), +}); diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index 5c0d101ef5b..c72a913aacd 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -21,7 +21,7 @@ export function getProjects(query, options, callback = () => {}) { defaults.membership = true; } - if (gon.features.fullPathProjectSearch && query?.includes('/')) { + if (query?.includes('/')) { defaults.search_namespaces = true; } @@ -35,6 +35,13 @@ export function getProjects(query, options, callback = () => {}) { }); } +export function createProject(projectData) { + const url = buildApiUrl(PROJECTS_PATH); + return axios.post(url, projectData).then(({ data }) => { + return data; + }); +} + export function importProjectMembers(sourceId, targetId) { const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH) .replace(':id', sourceId) diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index bcb0f079d3d..3ebb07807d2 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -1,6 +1,4 @@ import { DEFAULT_PER_PAGE } from '~/api'; -import { createAlert } from '~/alert'; -import { __ } from '~/locale'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; @@ -44,22 +42,12 @@ export function getUserStatus(id, options) { }); } -export function getUserProjects(userId, query, options, callback) { +export function getUserProjects(userId, options) { const url = buildApiUrl(USER_PROJECTS_PATH).replace(':id', userId); - const defaults = { - search: query, - per_page: DEFAULT_PER_PAGE, - }; - return axios - .get(url, { - params: { ...defaults, ...options }, - }) - .then(({ data }) => callback(data)) - .catch(() => - createAlert({ - message: __('Something went wrong while fetching projects'), - }), - ); + + return axios.get(url, { + params: options, + }); } export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) { diff --git a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue deleted file mode 100644 index cc08551fdb7..00000000000 --- a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue +++ /dev/null @@ -1,182 +0,0 @@ -<script> -import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; -import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; -import { removeArtifactFromStore } from '../graphql/cache_update'; -import { - I18N_BULK_DELETE_BANNER, - I18N_BULK_DELETE_CLEAR_SELECTION, - I18N_BULK_DELETE_DELETE_SELECTED, - I18N_BULK_DELETE_MODAL_TITLE, - I18N_BULK_DELETE_BODY, - I18N_BULK_DELETE_ACTION, - I18N_BULK_DELETE_PARTIAL_ERROR, - I18N_BULK_DELETE_ERROR, - I18N_MODAL_CANCEL, - BULK_DELETE_MODAL_ID, -} from '../constants'; - -export default { - name: 'ArtifactsBulkDelete', - components: { - GlButton, - GlModal, - GlSprintf, - }, - inject: ['projectId'], - props: { - selectedArtifacts: { - type: Array, - required: true, - }, - queryVariables: { - type: Object, - required: true, - }, - }, - data() { - return { - isModalVisible: false, - isDeleting: false, - }; - }, - computed: { - checkedCount() { - return this.selectedArtifacts.length || 0; - }, - modalActionPrimary() { - return { - text: I18N_BULK_DELETE_ACTION(this.checkedCount), - attributes: { - loading: this.isDeleting, - variant: 'danger', - }, - }; - }, - modalActionCancel() { - return { - text: I18N_MODAL_CANCEL, - attributes: { - loading: this.isDeleting, - }, - }; - }, - }, - methods: { - async onConfirmDelete(e) { - // don't close modal until deletion is complete - if (e) { - e.preventDefault(); - } - this.isDeleting = true; - - try { - await this.$apollo.mutate({ - mutation: bulkDestroyJobArtifactsMutation, - variables: { - projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), - ids: this.selectedArtifacts, - }, - update: (store, { data }) => { - const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts; - if (errors?.length) { - createAlert({ - message: I18N_BULK_DELETE_PARTIAL_ERROR, - captureError: true, - error: new Error(errors.join(' ')), - }); - } - if (destroyedIds?.length) { - this.$emit('deleted', destroyedCount); - - // Remove deleted artifacts from the cache - destroyedIds.forEach((id) => { - removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables); - }); - store.gc(); - - this.$emit('clearSelectedArtifacts'); - } - }, - }); - } catch (error) { - this.onError(error); - } finally { - this.isDeleting = false; - this.isModalVisible = false; - } - }, - onError(error) { - createAlert({ - message: I18N_BULK_DELETE_ERROR, - captureError: true, - error, - }); - }, - handleClearSelection() { - this.$emit('clearSelectedArtifacts'); - }, - handleModalShow() { - this.isModalVisible = true; - }, - handleModalHide() { - this.isModalVisible = false; - }, - }, - i18n: { - banner: I18N_BULK_DELETE_BANNER, - clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION, - deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED, - modalTitle: I18N_BULK_DELETE_MODAL_TITLE, - modalBody: I18N_BULK_DELETE_BODY, - }, - BULK_DELETE_MODAL_ID, -}; -</script> -<template> - <div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> - <div class="gl-display-flex gl-align-items-center"> - <div> - <gl-sprintf :message="$options.i18n.banner(checkedCount)"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </div> - <div class="gl-ml-auto"> - <gl-button - variant="default" - data-testid="bulk-delete-clear-button" - @click="handleClearSelection" - > - {{ $options.i18n.clearSelection }} - </gl-button> - <gl-button - variant="danger" - data-testid="bulk-delete-delete-button" - @click="handleModalShow" - > - {{ $options.i18n.deleteSelected }} - </gl-button> - </div> - </div> - <gl-modal - size="sm" - :modal-id="$options.BULK_DELETE_MODAL_ID" - :visible="isModalVisible" - :title="$options.i18n.modalTitle(checkedCount)" - :action-primary="modalActionPrimary" - :action-cancel="modalActionCancel" - @hide="handleModalHide" - @primary="onConfirmDelete" - > - <gl-sprintf - data-testid="bulk-delete-modal-content" - :message="$options.i18n.modalBody(checkedCount)" - /> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue new file mode 100644 index 00000000000..7808620cca1 --- /dev/null +++ b/app/assets/javascripts/authentication/password/components/password_input.vue @@ -0,0 +1,82 @@ +<script> +import { GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { SHOW_PASSWORD, HIDE_PASSWORD, PASSWORD_TITLE } from '../constants'; + +export default { + name: 'PasswordInput', + i18n: { + showPassword: SHOW_PASSWORD, + hidePassword: HIDE_PASSWORD, + }, + components: { + GlFormInput, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + resourceName: { + type: String, + required: true, + }, + minimumPasswordLength: { + type: String, + required: true, + }, + qaSelector: { + type: String, + required: true, + }, + }, + data() { + return { + isMasked: true, + }; + }, + computed: { + passwordTitle() { + return sprintf(PASSWORD_TITLE, { minimum_password_length: this.minimumPasswordLength }); + }, + type() { + return this.isMasked ? 'password' : 'text'; + }, + toggleVisibilityLabel() { + return this.isMasked ? this.$options.i18n.showPassword : this.$options.i18n.hidePassword; + }, + toggleVisibilityIcon() { + return this.isMasked ? 'eye' : 'eye-slash'; + }, + }, + methods: { + handleToggleVisibilityButtonClick() { + this.isMasked = !this.isMasked; + }, + }, +}; +</script> + +<template> + <div class="gl-field-error-anchor input-icon-wrapper"> + <gl-form-input + :id="`${resourceName}_password`" + class="js-password-complexity-validation gl-pr-8!" + required + autocomplete="new-password" + :name="`${resourceName}[password]`" + :minlength="minimumPasswordLength" + :data-qa-selector="qaSelector" + :title="passwordTitle" + :type="type" + /> + <gl-button + v-gl-tooltip="toggleVisibilityLabel" + class="input-icon-right gl-right-0!" + category="tertiary" + :aria-label="toggleVisibilityLabel" + :icon="toggleVisibilityIcon" + @click="handleToggleVisibilityButtonClick" + /> + </div> +</template> diff --git a/app/assets/javascripts/authentication/password/constants.js b/app/assets/javascripts/authentication/password/constants.js new file mode 100644 index 00000000000..97e1a882d9d --- /dev/null +++ b/app/assets/javascripts/authentication/password/constants.js @@ -0,0 +1,8 @@ +import { __, s__ } from '~/locale'; + +export const SHOW_PASSWORD = __('Show password'); +export const HIDE_PASSWORD = __('Hide password'); + +export const PASSWORD_TITLE = s__( + 'SignUp|Minimum length is %{minimum_password_length} characters.', +); diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js new file mode 100644 index 00000000000..36e3b74263c --- /dev/null +++ b/app/assets/javascripts/authentication/password/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import GlFieldErrors from '~/gl_field_errors'; +import PasswordInput from './components/password_input.vue'; + +export const initTogglePasswordVisibility = () => { + const el = document.querySelector('.js-password'); + + if (!el) { + return null; + } + + const { form } = el; + const { resourceName, minimumPasswordLength, qaSelector } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'PasswordInputRoot', + render(createElement) { + return createElement(PasswordInput, { + props: { + resourceName, + minimumPasswordLength, + qaSelector, + }, + }); + }, + }); + + // Since we replaced password input, we need to re-initialize the field errors handler + return new GlFieldErrors(form); +}; diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue index 98ed2a31730..907b68e6ffc 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue @@ -7,7 +7,6 @@ export const i18n = { currentPassword: __('Current password'), confirmTitle: __('Are you sure?'), confirmWebAuthn: __('This will invalidate your registered applications and WebAuthn devices.'), - confirm: __('This will invalidate your registered applications and WebAuthn devices.'), disableTwoFactor: __('Disable two-factor authentication'), disable: __('Disable'), cancel: __('Cancel'), @@ -41,7 +40,6 @@ export default { GlModal, }, inject: [ - 'webauthnEnabled', 'isCurrentPasswordRequired', 'profileTwoFactorAuthPath', 'profileTwoFactorAuthMethod', @@ -59,11 +57,7 @@ export default { }, computed: { confirmText() { - if (this.webauthnEnabled) { - return i18n.confirmWebAuthn; - } - - return i18n.confirm; + return i18n.confirmWebAuthn; }, }, methods: { diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js index 7d21c19ac4c..cec80335ba0 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/index.js +++ b/app/assets/javascripts/authentication/two_factor_auth/index.js @@ -13,7 +13,6 @@ export const initManageTwoFactorForm = () => { } const { - webauthnEnabled = false, currentPasswordRequired, profileTwoFactorAuthPath = '', profileTwoFactorAuthMethod = '', @@ -26,7 +25,6 @@ export const initManageTwoFactorForm = () => { return new Vue({ el, provide: { - webauthnEnabled, isCurrentPasswordRequired, profileTwoFactorAuthPath, profileTwoFactorAuthMethod, diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index beda251aa1e..107796a31e0 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,19 +1,10 @@ <script> -import { - GlDropdown, - GlButton, - GlIcon, - GlForm, - GlFormGroup, - GlLink, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { createAlert } from '~/alert'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; import Autosave from '~/autosave'; -import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { @@ -22,7 +13,6 @@ export default { GlIcon, GlForm, GlFormGroup, - GlLink, GlFormCheckbox, MarkdownField, ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'), @@ -102,9 +92,6 @@ export default { }, }, restrictedToolbarItems: ['full-screen'], - helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', { - anchor: 'submit-a-review', - }), }; </script> @@ -126,14 +113,6 @@ export default { <gl-form-group label-for="review-note-body" label-class="gl-mb-2"> <template #label> {{ __('Summary comment (optional)') }} - <gl-link - :href="$options.helpPagePath" - :aria-label="__('More information')" - target="_blank" - class="gl-ml-2" - > - <gl-icon name="question-o" /> - </gl-link> </template> <div class="common-note-form gfm-form"> <div diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js index 2a8786134cc..f6e9bfd6690 100644 --- a/app/assets/javascripts/batch_comments/index.js +++ b/app/assets/javascripts/batch_comments/index.js @@ -7,6 +7,8 @@ import store from '~/mr_notes/stores'; export const initReviewBar = () => { const el = document.getElementById('js-review-bar'); + if (!el) return; + Vue.use(VueApollo); // eslint-disable-next-line no-new @@ -18,7 +20,7 @@ export const initReviewBar = () => { ReviewBar: () => import('./components/review_bar.vue'), }, provide: { - newSavedRepliesPath: el.dataset.savedRepliesNewPath, + newCommentTemplatePath: el.dataset.newCommentTemplatePath, }, computed: { ...mapGetters('batchComments', ['draftsCount']), diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js index 11fe01ca48d..89f1ad9c89e 100644 --- a/app/assets/javascripts/behaviors/date_picker.js +++ b/app/assets/javascripts/behaviors/date_picker.js @@ -9,7 +9,7 @@ export default function initDatePickers() { const calendar = new Pikaday({ field: $datePicker.get(0), - theme: 'gitlab-theme animate-picker', + theme: 'gl-datepicker-theme animate-picker', format: 'yyyy-mm-dd', container: $datePicker.parent().get(0), parse: (dateString) => parsePikadayDate(dateString), diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index b1bf6ebcb13..b2348cf0bad 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -97,7 +97,7 @@ class SafeMathRenderer { <button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button> </div> </div> - <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <button type="button" class="close js-close" aria-label="Close"> ${spriteIcon('close', 's16')} </button> </div> @@ -184,17 +184,24 @@ class SafeMathRenderer { attachEvents() { document.body.addEventListener('click', (event) => { - if (!event.target.classList.contains('js-lazy-render-math')) { + const alert = event.target.closest('.js-lazy-render-math-container'); + + if (!alert) { return; } - const parent = event.target.closest('.js-lazy-render-math-container'); - - const pre = parent.nextElementSibling; - - parent.remove(); + // Handle alert close + if (event.target.closest('.js-close')) { + alert.remove(); + return; + } - this.renderElement(pre); + // Handle "render anyway" + if (event.target.classList.contains('js-lazy-render-math')) { + const pre = alert.nextElementSibling; + alert.remove(); + this.renderElement(pre); + } }); } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 6a7ce4f1c41..301dd1c5669 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -204,7 +204,11 @@ export default class Shortcuts { } static focusSearch(e) { - $('#search').focus(); + if (gon.use_new_navigation) { + document.querySelector('#super-sidebar-search')?.click(); + } else { + document.querySelector('#search')?.focus(); + } if (e.preventDefault) { e.preventDefault(); diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js index 2c1c6339fdb..834aa3e5354 100644 --- a/app/assets/javascripts/blob/sketch_viewer.js +++ b/app/assets/javascripts/blob/sketch_viewer.js @@ -1,8 +1,7 @@ -/* eslint-disable no-new */ import SketchLoader from './sketch'; export default () => { const el = document.getElementById('js-sketch-viewer'); - new SketchLoader(el); + new SketchLoader(el); // eslint-disable-line no-new }; diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 7eb699eacbe..59b7f82c10e 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -1,5 +1,3 @@ -/* eslint-disable class-methods-use-this */ - import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -70,6 +68,7 @@ export default class TemplateSelector { return this.requestFile(item); } + // eslint-disable-next-line class-methods-use-this requestFile() { // This `requestFile` method is an abstract method that should // be added by all subclasses. diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 01d35a0980f..7e667409556 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new */ - import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { createAlert } from '~/alert'; @@ -54,6 +52,7 @@ export default () => { import('./edit_blob') .then(({ default: EditBlob } = {}) => { + // eslint-disable-next-line no-new new EditBlob({ assetsPath: `${urlRoot}${assetsPath}`, filePath, @@ -80,7 +79,7 @@ export default () => { window.onbeforeunload = null; }); - new NewCommitForm(editBlobForm); + new NewCommitForm(editBlobForm); // eslint-disable-line no-new // returning here blocks page navigation window.onbeforeunload = () => ''; diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 48dfcf81f1e..c7e6cb38d15 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -4,6 +4,7 @@ import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility'; 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 activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export default { components: { @@ -11,7 +12,7 @@ export default { BoardSettingsSidebar, BoardTopBar, }, - inject: ['initialBoardId', 'initialFilterParams'], + inject: ['initialBoardId', 'initialFilterParams', 'isIssueBoard', 'isApolloBoard'], data() { return { boardId: this.initialBoardId, @@ -19,11 +20,31 @@ export default { isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), }; }, + apollo: { + activeBoardItem: { + query: activeBoardItemQuery, + variables() { + return { + isIssue: this.isIssueBoard, + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, + computed: { ...mapGetters(['isSidebarOpen']), isSwimlanesOn() { return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false; }, + isAnySidebarOpen() { + if (this.isApolloBoard) { + return this.activeBoardItem?.id; + } + return this.isSidebarOpen; + }, }, created() { window.addEventListener('popstate', refreshCurrentPage); @@ -45,7 +66,7 @@ export default { </script> <template> - <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> + <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }"> <board-top-bar :board-id="boardId" :is-swimlanes-on="isSwimlanesOn" diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 3071c1f334e..18495f285da 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,6 +1,8 @@ <script> import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; +import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import BoardCardInner from './board_card_inner.vue'; export default { @@ -9,7 +11,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], - inject: ['disabled', 'isApolloBoard'], + inject: ['disabled', 'isIssueBoard', 'isApolloBoard'], props: { list: { type: Object, @@ -37,14 +39,30 @@ export default { default: true, }, }, + apollo: { + activeBoardItem: { + query: activeBoardItemQuery, + variables() { + return { + isIssue: this.isIssueBoard, + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { ...mapState(['selectedBoardItems', 'activeId']), + activeItemId() { + return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId; + }, isActive() { - return this.item.id === this.activeId; + return this.item.id === this.activeItemId; }, multiSelectVisible() { return ( - !this.activeId && + !this.activeItemId && this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 ); }, @@ -83,10 +101,23 @@ export default { if (isMultiSelect && gon?.features?.boardMultiSelect) { this.toggleBoardItemMultiSelection(this.item); } else { - this.toggleBoardItem({ boardItem: this.item }); + if (this.isApolloBoard) { + this.toggleItem(); + } else { + this.toggleBoardItem({ boardItem: this.item }); + } this.track('click_card', { label: 'right_sidebar' }); } }, + toggleItem() { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: this.item, + isIssue: this.isIssueBoard, + }, + }); + }, }, }; </script> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 88f51c71e06..befd04c29ae 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -275,7 +275,7 @@ export default { <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> <span v-if="item.referencePath" - class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > <work-item-type-icon diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 84a8781db1c..946f3712834 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -206,6 +206,7 @@ export default { <epics-swimlanes v-else-if="boardListsToUse.length" ref="swimlanes" + :board-id="boardId" :lists="boardListsToUse" :can-admin-list="canAdminList" :filters="filterParams" diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 675878683ab..1b97214ff8b 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -3,10 +3,12 @@ import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; 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 BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { ISSUABLE, INCIDENT } from '~/boards/constants'; +import { INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; @@ -16,7 +18,6 @@ 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -39,7 +40,6 @@ export default { SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), }, - mixins: [glFeatureFlagMixin()], inject: { multipleAssigneesFeatureAvailable: { default: false, @@ -71,31 +71,46 @@ export default { isGroupBoard: { default: false, }, + isApolloBoard: { + default: false, + }, }, inheritAttrs: false, + apollo: { + activeBoardCard: { + query: activeBoardItemQuery, + variables: { + isIssue: true, + }, + update(data) { + if (!data.activeBoardItem?.id) { + return { id: '', iid: '' }; + } + return { + ...data.activeBoardItem, + assignees: data.activeBoardItem.assignees?.nodes || [], + }; + }, + skip() { + return !this.isApolloBoard; + }, + }, + }, computed: { - ...mapGetters([ - 'isSidebarOpen', - 'activeBoardItem', - 'groupPathForActiveIssue', - 'projectPathForActiveIssue', - ]), + ...mapGetters(['activeBoardItem']), ...mapState(['sidebarType']), - isIssuableSidebar() { - return this.sidebarType === ISSUABLE; + activeBoardIssuable() { + return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem; }, - isIncidentSidebar() { - return this.activeBoardItem.type === INCIDENT; + isSidebarOpen() { + return Boolean(this.activeBoardIssuable?.id); }, - showSidebar() { - return this.isIssuableSidebar && this.isSidebarOpen; + isIncidentSidebar() { + return this.activeBoardIssuable?.type === INCIDENT; }, sidebarTitle() { return this.isIncidentSidebar ? __('Incident details') : __('Issue details'); }, - fullPath() { - return this.activeBoardItem?.referencePath?.split('#')[0] || ''; - }, parentType() { return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT; }, @@ -120,6 +135,14 @@ export default { ? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue) : this.labelsFilterBasePath; }, + groupPathForActiveIssue() { + const { referencePath = '' } = this.activeBoardIssuable; + return referencePath.slice(0, referencePath.lastIndexOf('/')); + }, + projectPathForActiveIssue() { + const { referencePath = '' } = this.activeBoardIssuable; + return referencePath.slice(0, referencePath.indexOf('#')); + }, }, methods: { ...mapActions([ @@ -131,7 +154,19 @@ export default { 'setActiveItemHealthStatus', ]), handleClose() { - this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); + if (this.isApolloBoard) { + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: null, + }, + }); + } else { + this.toggleBoardItem({ + boardItem: this.activeBoardIssuable, + sidebarType: this.sidebarType, + }); + } }, handleUpdateSelectedLabels({ labels, id }) { this.setActiveBoardItemLabels({ @@ -143,7 +178,7 @@ export default { }, handleLabelRemove(removeLabelId) { this.setActiveBoardItemLabels({ - iid: this.activeBoardItem.iid, + iid: this.activeBoardIssuable.iid, projectPath: this.projectPathForActiveIssue, removeLabelIds: [removeLabelId], }); @@ -156,7 +191,7 @@ export default { <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append> <gl-drawer v-bind="$attrs" - :open="showSidebar" + :open="isSidebarOpen" class="boards-sidebar" variant="sidebar" @close="handleClose" @@ -167,25 +202,27 @@ export default { <template #header> <sidebar-todo-widget class="gl-mt-3" - :issuable-id="activeBoardItem.id" - :issuable-iid="activeBoardItem.iid" - :full-path="fullPath" + :issuable-id="activeBoardIssuable.id" + :issuable-iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" /> </template> <template #default> - <board-sidebar-title data-testid="sidebar-title" /> + <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" /> <sidebar-assignees-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :initial-assignees="activeBoardItem.assignees" + v-if="activeBoardItem.assignees" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" + :initial-assignees="activeBoardIssuable.assignees" :allow-multiple-assignees="multipleAssigneesFeatureAvailable" :editable="canUpdate" - @assignees-updated="setAssignees" + @assignees-updated="!isApolloBoard && setAssignees($event)" /> <sidebar-dropdown-widget v-if="epicFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" + :key="`epic-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" @@ -194,7 +231,8 @@ export default { /> <div> <sidebar-dropdown-widget - :iid="activeBoardItem.iid" + :key="`milestone-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="projectPathForActiveIssue" @@ -203,7 +241,8 @@ export default { /> <sidebar-iteration-widget v-if="iterationFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" + :key="`iteration-${activeBoardItem.iid}`" + :iid="activeBoardIssuable.iid" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" @@ -213,14 +252,14 @@ export default { </div> <board-sidebar-time-tracker /> <sidebar-date-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" data-testid="sidebar-due-date" /> <sidebar-labels-widget class="block labels" - :iid="activeBoardItem.iid" + :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :allow-label-remove="allowLabelEdit" :allow-multiselect="true" @@ -232,40 +271,40 @@ export default { workspace-type="project" :issuable-type="issuableType" :label-create-type="labelType" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" + @onLabelRemove="!isApolloBoard && handleLabelRemove($event)" + @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)" > {{ __('None') }} </sidebar-labels-widget> <sidebar-severity-widget v-if="isIncidentSidebar" - :iid="activeBoardItem.iid" - :project-path="fullPath" - :initial-severity="activeBoardItem.severity" + :iid="activeBoardIssuable.iid" + :project-path="projectPathForActiveIssue" + :initial-severity="activeBoardIssuable.severity" /> <sidebar-weight-widget v-if="weightFeatureAvailable && !isIncidentSidebar" - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @weightUpdated="setActiveItemWeight($event)" + @weightUpdated="!isApolloBoard && setActiveItemWeight($event)" /> <sidebar-health-status-widget v-if="healthStatusFeatureAvailable" - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @statusUpdated="setActiveItemHealthStatus($event)" + @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)" /> <sidebar-confidentiality-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @confidentialityUpdated="setActiveItemConfidential($event)" + @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)" /> <sidebar-subscriptions-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" + :iid="activeBoardIssuable.iid" + :full-path="projectPathForActiveIssue" :issuable-type="issuableType" data-testid="sidebar-notifications" /> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 2e14afad963..46612320136 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -22,7 +22,7 @@ import { TOKEN_TYPE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { AssigneeFilterType } from '~/boards/constants'; +import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants'; import { TYPENAME_ITERATION } from '~/graphql_shared/constants'; import eventHub from '../eventhub'; @@ -33,6 +33,11 @@ export default { components: { FilteredSearch }, inject: ['initialFilterParams', 'isApolloBoard'], props: { + isSwimlanesOn: { + type: Boolean, + required: false, + default: false, + }, tokens: { type: Array, required: true, @@ -321,6 +326,7 @@ export default { release_tag: releaseTag, confidential, health_status: healthStatus, + group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined, }, (value) => { if (value || value === false) { diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 9ea801dc9a2..604e71f5993 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -226,12 +226,10 @@ export default { } this.cancel(); - if (!this.isApolloBoard) { - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); - } + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); } catch { this.setError({ message: this.$options.i18n.saveErrorMessage }); } finally { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index a47db661445..5f082066ad4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { mapActions, mapState } from 'vuex'; +import { STATUS_CLOSED } from '~/issues/constants'; import { sprintf, __ } from '~/locale'; import { defaultSortableOptions } from '~/sortable/constants'; import { sortableStart, sortableEnd } from '~/sortable/utils'; @@ -158,10 +159,10 @@ export default { return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore; }, epicCreateFormVisible() { - return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; + return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm; }, issueCreateFormVisible() { - return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; + return !this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showIssueForm; }, listRef() { // When list is draggable, the reference to the list needs to be accessed differently @@ -418,7 +419,6 @@ export default { v-if="loadingMore" size="sm" :label="$options.i18n.loadingMoreboardItems" - data-testid="count-loading-icon" /> <span v-if="showingAllItems">{{ showingAllItemsText }}</span> <span v-else>{{ paginatedIssueText }}</span> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index f4358315d45..7dc3e464af0 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -229,9 +229,6 @@ export default { context: { isSingleRequest: true, }, - skip() { - return this.isEpicBoard; - }, }, }, created() { @@ -426,7 +423,7 @@ export default { <div v-if="list.maxIssueCount !== 0"> • <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> - <template #issuesSize>{{ itemsTooltipLabel }}</template> + <template #issuesSize>{{ itemsCount }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template> </gl-sprintf> </div> diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index fad57758be1..c186346b2ac 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -98,6 +98,7 @@ export default { <issue-board-filtered-search v-if="isIssueBoard" :board="board" + :is-swimlanes-on="isSwimlanesOn" @setFilters="$emit('setFilters', $event)" /> <epic-board-filtered-search diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index cdcc7b8e5a6..3c056f296e1 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -52,6 +52,11 @@ export default { required: false, default: () => {}, }, + isSwimlanesOn: { + type: Boolean, + required: false, + default: false, + }, }, computed: { tokensCE() { @@ -203,6 +208,7 @@ export default { data-testid="issue-board-filtered-search" :tokens="tokens" :board="board" + :is-swimlanes-on="isSwimlanesOn" @setFilters="$emit('setFilters', $event)" /> </template> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index c3f7c7d3ca2..1f28974afd1 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -95,9 +95,12 @@ export default { class="board-card-info-icon gl-mr-2" name="calendar" /> - <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ - body - }}</time> + <time + :class="{ 'text-danger': isPastDue }" + datetime="date" + class="gl-font-sm board-card-info-text" + >{{ body }}</time + > </span> <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index bc12717a92d..611e875fa40 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -38,7 +38,7 @@ export default { <span> <span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help"> <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" /> - <time class="board-card-info-text">{{ timeEstimate }}</time> + <time class="gl-font-sm board-card-info-text">{{ timeEstimate }}</time> </span> <gl-tooltip :target="() => $refs.issueTimeEstimate" 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 43a2b13b81c..020edcb01b8 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -5,6 +5,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { titleQueries } from 'ee_else_ce/boards/constants'; export default { components: { @@ -19,6 +20,13 @@ export default { directives: { autofocusonshow, }, + inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'], + props: { + activeItem: { + type: Object, + required: true, + }, + }, data() { return { title: '', @@ -27,7 +35,10 @@ export default { }; }, computed: { - ...mapGetters({ item: 'activeBoardItem' }), + ...mapGetters(['activeBoardItem']), + item() { + return this.isApolloBoard ? this.activeItem : this.activeBoardItem; + }, pendingChangesStorageKey() { return this.getPendingChangesKey(this.item); }, @@ -67,8 +78,9 @@ export default { }, async setPendingState() { const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey); + const shouldOpen = pendingChanges !== this.title; - if (pendingChanges) { + if (pendingChanges && shouldOpen) { this.title = pendingChanges; this.showChangesAlert = true; await this.$nextTick(); @@ -83,6 +95,26 @@ export default { this.showChangesAlert = false; localStorage.removeItem(this.pendingChangesStorageKey); }, + async setActiveBoardItemTitle() { + if (!this.isApolloBoard) { + await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); + return; + } + const { fullPath, issuableType, isEpicBoard, title } = this; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: this.projectPath }; + await this.$apollo.mutate({ + mutation: titleQueries[issuableType].mutation, + variables: { + input: { + ...workspacePath, + iid: String(this.item.iid), + title, + }, + }, + }); + }, async setTitle() { this.$refs.sidebarItem.collapse(); @@ -92,7 +124,7 @@ export default { try { this.loading = true; - await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); + await this.setActiveBoardItemTitle(); localStorage.removeItem(this.pendingChangesStorageKey); this.showChangesAlert = false; } catch (e) { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index b557dc9205e..d12270e58a4 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -12,6 +12,11 @@ import groupBoardQuery from './graphql/group_board.query.graphql'; import projectBoardQuery from './graphql/project_board.query.graphql'; import listIssuesQuery from './graphql/lists_issues.query.graphql'; +export const BoardType = { + project: 'project', + group: 'group', +}; + export const ListType = { assignee: 'assignee', milestone: 'milestone', diff --git a/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql new file mode 100644 index 00000000000..81b1b68a038 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" + +query activeBoardItem { + activeBoardItem @client { + ...Issue + } +} diff --git a/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql new file mode 100644 index 00000000000..cce558c649e --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" + +mutation setActiveBoardItem($boardItem: Issue) { + setActiveBoardItem(boardItem: $boardItem) @client { + ...Issue + } +} diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index e895df01f2c..4d1c4be73a3 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,5 +1,3 @@ -/* eslint-disable func-names */ - import $ from 'jquery'; import { hide, initTooltips, show } from '~/tooltips'; import { parseBoolean } from './lib/utils/common_utils'; @@ -24,6 +22,7 @@ export default class BuildArtifacts { // eslint-disable-next-line class-methods-use-this setupEntryClick() { + // eslint-disable-next-line func-names return $('.tree-holder').on('click', 'tr[data-link]', function () { visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink)); }); diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/ci/artifacts/components/app.vue index 3a07be65341..3a07be65341 100644 --- a/app/assets/javascripts/artifacts/components/app.vue +++ b/app/assets/javascripts/ci/artifacts/components/app.vue diff --git a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue index 14edd73824e..14edd73824e 100644 --- a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue index f37c4c6f107..f37c4c6f107 100644 --- a/app/assets/javascripts/artifacts/components/artifact_row.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue diff --git a/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue new file mode 100644 index 00000000000..b864fc00bdb --- /dev/null +++ b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue @@ -0,0 +1,65 @@ +<script> +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { + I18N_BULK_DELETE_BANNER, + I18N_BULK_DELETE_CLEAR_SELECTION, + I18N_BULK_DELETE_DELETE_SELECTED, +} from '../constants'; + +export default { + name: 'ArtifactsBulkDelete', + components: { + GlButton, + GlSprintf, + }, + props: { + selectedArtifacts: { + type: Array, + required: true, + }, + }, + computed: { + checkedCount() { + return this.selectedArtifacts.length || 0; + }, + }, + i18n: { + banner: I18N_BULK_DELETE_BANNER, + clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION, + deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED, + }, +}; +</script> +<template> + <div + v-if="selectedArtifacts.length > 0" + class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100" + data-testid="bulk-delete-container" + > + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="$options.i18n.banner(checkedCount)"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button + variant="default" + data-testid="bulk-delete-clear-button" + @click="$emit('clearSelectedArtifacts')" + > + {{ $options.i18n.clearSelection }} + </gl-button> + <gl-button + variant="danger" + data-testid="bulk-delete-delete-button" + @click="$emit('showBulkDeleteModal')" + > + {{ $options.i18n.deleteSelected }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue index 7d675251ffd..7d675251ffd 100644 --- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue +++ b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue diff --git a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue new file mode 100644 index 00000000000..00f5b2eab7d --- /dev/null +++ b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue @@ -0,0 +1,73 @@ +<script> +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { + I18N_BULK_DELETE_MODAL_TITLE, + I18N_BULK_DELETE_BODY, + I18N_BULK_DELETE_ACTION, + I18N_MODAL_CANCEL, + BULK_DELETE_MODAL_ID, +} from '../constants'; + +export default { + name: 'BulkDeleteModal', + components: { + GlModal, + GlSprintf, + }, + props: { + visible: { + type: Boolean, + required: true, + }, + artifactsToDelete: { + type: Array, + required: true, + }, + isDeleting: { + type: Boolean, + required: true, + }, + }, + computed: { + checkedCount() { + return this.artifactsToDelete.length || 0; + }, + modalActionPrimary() { + return { + text: I18N_BULK_DELETE_ACTION(this.checkedCount), + attributes: { + loading: this.isDeleting, + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: I18N_MODAL_CANCEL, + attributes: { + disabled: this.isDeleting, + }, + }; + }, + }, + BULK_DELETE_MODAL_ID, + i18n: { + modalTitle: I18N_BULK_DELETE_MODAL_TITLE, + modalBody: I18N_BULK_DELETE_BODY, + }, +}; +</script> +<template> + <gl-modal + size="sm" + :modal-id="$options.BULK_DELETE_MODAL_ID" + :visible="visible" + :title="$options.i18n.modalTitle(checkedCount)" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + v-bind="$attrs" + v-on="$listeners" + > + <gl-sprintf :message="$options.i18n.modalBody(checkedCount)" /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue index d2c96b1a201..d2c96b1a201 100644 --- a/app/assets/javascripts/artifacts/components/feedback_banner.vue +++ b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index ba4026190a2..a93964eef99 100644 --- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -11,12 +11,15 @@ import { GlFormCheckbox, } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils'; +import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; +import { removeArtifactFromStore } from '../graphql/cache_update'; import { STATUS_BADGE_VARIANTS, I18N_DOWNLOAD, @@ -36,10 +39,13 @@ import { JOBS_PER_PAGE, INITIAL_LAST_PAGE_SIZE, BULK_DELETE_FEATURE_FLAG, + I18N_BULK_DELETE_ERROR, + I18N_BULK_DELETE_PARTIAL_ERROR, I18N_BULK_DELETE_CONFIRMATION_TOAST, } from '../constants'; import JobCheckbox from './job_checkbox.vue'; import ArtifactsBulkDelete from './artifacts_bulk_delete.vue'; +import BulkDeleteModal from './bulk_delete_modal.vue'; import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; import FeedbackBanner from './feedback_banner.vue'; @@ -67,11 +73,12 @@ export default { TimeAgo, JobCheckbox, ArtifactsBulkDelete, + BulkDeleteModal, ArtifactsTableRowDetails, FeedbackBanner, }, mixins: [glFeatureFlagsMixin()], - inject: ['projectPath', 'canDestroyArtifacts'], + inject: ['projectId', 'projectPath', 'canDestroyArtifacts'], apollo: { jobArtifacts: { query: getJobArtifactsQuery, @@ -106,6 +113,9 @@ export default { expandedJobs: [], selectedArtifacts: [], pagination: INITIAL_PAGINATION_STATE, + isBulkDeleteModalVisible: false, + jobArtifactsToDelete: [], + isBulkDeleting: false, }; }, computed: { @@ -144,6 +154,12 @@ export default { canBulkDestroyArtifacts() { return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts; }, + isDeletingArtifactsForJob() { + return this.jobArtifactsToDelete.length > 0; + }, + artifactsToDelete() { + return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts; + }, }, methods: { refetchArtifacts() { @@ -191,12 +207,70 @@ export default { this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1); } }, + onConfirmBulkDelete(e) { + // don't close modal until deletion is complete + if (e) { + e.preventDefault(); + } + this.isBulkDeleting = true; + + this.$apollo + .mutate({ + mutation: bulkDestroyJobArtifactsMutation, + variables: { + projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), + ids: this.artifactsToDelete, + }, + update: (store, { data }) => { + const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts; + if (errors?.length) { + createAlert({ + message: I18N_BULK_DELETE_PARTIAL_ERROR, + captureError: true, + error: new Error(errors.join(' ')), + }); + } + if (destroyedIds?.length) { + this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(destroyedCount)); + + // Remove deleted artifacts from the cache + destroyedIds.forEach((id) => { + removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables); + }); + store.gc(); + + if (!this.isDeletingArtifactsForJob) { + this.clearSelectedArtifacts(); + } + } + }, + }) + .catch((error) => { + this.onError(error); + }) + .finally(() => { + this.isBulkDeleting = false; + this.isBulkDeleteModalVisible = false; + this.jobArtifactsToDelete = []; + }); + }, + onError(error) { + createAlert({ + message: I18N_BULK_DELETE_ERROR, + captureError: true, + error, + }); + }, + handleBulkDeleteModalShow() { + this.isBulkDeleteModalVisible = true; + }, + handleBulkDeleteModalHidden() { + this.isBulkDeleteModalVisible = false; + this.jobArtifactsToDelete = []; + }, clearSelectedArtifacts() { this.selectedArtifacts = []; }, - showDeletedToast(deletedCount) { - this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount)); - }, downloadPath(job) { return job.archive?.downloadPath; }, @@ -206,6 +280,13 @@ export default { browseButtonDisabled(job) { return !job.browseArtifactsPath; }, + deleteButtonDisabled(job) { + return !job.hasArtifacts || !this.canBulkDestroyArtifacts; + }, + deleteArtifactsForJob(job) { + this.jobArtifactsToDelete = job.artifacts.nodes.map((node) => node.id); + this.handleBulkDeleteModalShow(); + }, }, fields: [ { @@ -257,11 +338,17 @@ export default { <div> <feedback-banner /> <artifacts-bulk-delete - v-if="canBulkDestroyArtifacts && anyArtifactsSelected" + v-if="canBulkDestroyArtifacts" :selected-artifacts="selectedArtifacts" - :query-variables="queryVariables" @clearSelectedArtifacts="clearSelectedArtifacts" - @deleted="showDeletedToast" + @showBulkDeleteModal="handleBulkDeleteModalShow" + /> + <bulk-delete-modal + :visible="isBulkDeleteModalVisible" + :artifacts-to-delete="artifactsToDelete" + :is-deleting="isBulkDeleting" + @primary="onConfirmBulkDelete" + @hidden="handleBulkDeleteModalHidden" /> <gl-table :items="jobArtifacts" @@ -382,10 +469,11 @@ export default { <gl-button v-if="canDestroyArtifacts" icon="remove" + :disabled="deleteButtonDisabled(item)" :title="$options.i18n.delete" :aria-label="$options.i18n.delete" data-testid="job-artifacts-delete-button" - disabled + @click="deleteArtifactsForJob(item)" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue index ce49b3f8678..ce49b3f8678 100644 --- a/app/assets/javascripts/artifacts/components/job_checkbox.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js index 4ac20d963d1..4ac20d963d1 100644 --- a/app/assets/javascripts/artifacts/constants.js +++ b/app/assets/javascripts/ci/artifacts/constants.js diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js index 9fa6114c7d4..9fa6114c7d4 100644 --- a/app/assets/javascripts/artifacts/graphql/cache_update.js +++ b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js diff --git a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql index 421b9258ca0..421b9258ca0 100644 --- a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql index 529224b47e6..529224b47e6 100644 --- a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql index 23da65ad0bb..23da65ad0bb 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql index 5737f9f8e8d..5737f9f8e8d 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js index 6e795fd9bd7..6e795fd9bd7 100644 --- a/app/assets/javascripts/artifacts/index.js +++ b/app/assets/javascripts/ci/artifacts/index.js diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js index ebcf0af8d2a..ebcf0af8d2a 100644 --- a/app/assets/javascripts/artifacts/utils.js +++ b/app/assets/javascripts/ci/artifacts/utils.js diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue index 7387a490177..09b02068388 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -1,16 +1,25 @@ <script> -import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { debounce, uniq } from 'lodash'; +import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, s__, sprintf } from '~/locale'; import { convertEnvironmentScope } from '../utils'; +import { ENVIRONMENT_QUERY_LIMIT } from '../constants'; export default { name: 'CiEnvironmentsDropdown', components: { + GlCollapsibleListbox, GlDropdownDivider, GlDropdownItem, - GlCollapsibleListbox, + GlSprintf, }, + mixins: [glFeatureFlagsMixin()], props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, environments: { type: Array, required: true, @@ -33,24 +42,52 @@ export default { }, filteredEnvironments() { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.environments.filter((environment) => { + return environment.toLowerCase().includes(lowerCasedSearchTerm); + }); + }, + isEnvScopeLimited() { + return this.glFeatures?.ciLimitEnvironmentScope; + }, + searchedEnvironments() { + // If FF is enabled, search query will be fired so this component will already + // receive filtered environments during the refetch. + // If FF is disabled, search the existing list of environments in the frontend + let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments; + + // If there is no search term, make sure to include * + if (this.isEnvScopeLimited && !this.searchTerm) { + filtered = uniq([...filtered, '*']); + } - return this.environments - .filter((environment) => { - return environment.toLowerCase().includes(lowerCasedSearchTerm); - }) - .map((environment) => ({ - value: environment, - text: environment, - })); + return filtered.sort().map((environment) => ({ + value: environment, + text: environment, + })); + }, + shouldShowSearchLoading() { + return this.areEnvironmentsLoading && this.isEnvScopeLimited; }, shouldRenderCreateButton() { return this.searchTerm && !this.environments.includes(this.searchTerm); }, + shouldRenderDivider() { + return ( + (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading + ); + }, environmentScopeLabel() { return convertEnvironmentScope(this.selectedEnvironmentScope); }, }, methods: { + debouncedSearch: debounce(function debouncedSearch(searchTerm) { + const newSearchTerm = searchTerm.trim(); + this.searchTerm = newSearchTerm; + if (this.isEnvScopeLimited) { + this.$emit('search-environment-scope', newSearchTerm); + } + }, 500), selectEnvironment(selected) { this.$emit('select-environment', selected); this.selectedEnvironment = selected; @@ -60,22 +97,46 @@ export default { this.selectEnvironment(this.searchTerm); }, }, + ENVIRONMENT_QUERY_LIMIT, + i18n: { + maxEnvsNote: s__( + 'CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query.', + ), + }, }; </script> <template> <gl-collapsible-listbox v-model="selectedEnvironment" + block searchable - :items="filteredEnvironments" + :items="searchedEnvironments" + :searching="shouldShowSearchLoading" :toggle-text="environmentScopeLabel" - @search="searchTerm = $event.trim()" + @search="debouncedSearch" @select="selectEnvironment" > - <template v-if="shouldRenderCreateButton" #footer> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope"> - {{ composedCreateButtonLabel }} - </gl-dropdown-item> + <template #footer> + <gl-dropdown-divider v-if="shouldRenderDivider" /> + <div v-if="isEnvScopeLimited" data-testid="max-envs-notice"> + <gl-dropdown-item class="gl-list-style-none" disabled> + <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm"> + <template #limit> + {{ $options.ENVIRONMENT_QUERY_LIMIT }} + </template> + </gl-sprintf> + </gl-dropdown-item> + </div> + <div v-if="shouldRenderCreateButton"> + <!-- TODO: Rethink create wildcard button. https://gitlab.com/gitlab-org/gitlab/-/issues/396928 --> + <gl-dropdown-item + class="gl-list-style-none" + data-testid="create-wildcard-button" + @click="createEnvironmentScope" + > + {{ composedCreateButtonLabel }} + </gl-dropdown-item> + </div> </template> </gl-collapsible-listbox> </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 16034cce381..b3ecaceba69 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 @@ -74,6 +74,10 @@ export default { 'maskableRegex', ], props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, areScopedVariablesAvailable: { type: Boolean, required: false, @@ -142,7 +146,11 @@ export default { isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, - joinedEnvironments() { + environmentsList() { + if (this.glFeatures?.ciLimitEnvironmentScope) { + return this.environments; + } + return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments); }, maskedFeedback() { @@ -368,10 +376,12 @@ export default { </template> <ci-environments-dropdown v-if="areScopedVariablesAvailable" + :are-environments-loading="areEnvironmentsLoading" :selected-environment-scope="variable.environmentScope" - :environments="joinedEnvironments" + :environments="environmentsList" @select-environment="setEnvironmentScope" @create-environment-scope="createEnvironmentScope" + @search-environment-scope="$emit('search-environment-scope', $event)" /> <gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly /> @@ -450,7 +460,7 @@ export default { data-testid="aws-guidance-tip" @dismiss="dismissTip" > - <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3"> + <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"> @@ -505,7 +515,6 @@ export default { ref="deleteCiVariable" variant="danger" category="secondary" - data-qa-selector="ci_variable_delete_button" @click="deleteVarAndClose" >{{ __('Delete variable') }}</gl-button > 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 257c3309e10..26e20c690bc 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 @@ -9,6 +9,10 @@ export default { CiVariableModal, }, props: { + areEnvironmentsLoading: { + type: Boolean, + required: true, + }, areScopedVariablesAvailable: { type: Boolean, required: false, @@ -100,6 +104,7 @@ export default { /> <ci-variable-modal v-if="showModal" + :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :environments="environments" :hide-environment-scope="hideEnvironmentScope" @@ -110,6 +115,7 @@ export default { @delete-variable="deleteVariable" @hideModal="hideModal" @update-variable="updateVariable" + @search-environment-scope="$emit('search-environment-scope', $event)" /> </div> </div> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 9db9bea63b2..ee2c0a771cf 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -6,8 +6,10 @@ import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, + ENVIRONMENT_QUERY_LIMIT, SORT_DIRECTIONS, UPDATE_MUTATION_ACTION, + mapMutationActionToToast, environmentFetchErrorText, genericMutationErrorText, variableFetchErrorText, @@ -162,6 +164,7 @@ export default { variables() { return { fullPath: this.fullPath, + ...this.environmentQueryVariables, }; }, update(data) { @@ -173,10 +176,26 @@ export default { }, }, computed: { + areEnvironmentsLoading() { + return this.$apollo.queries.environments.loading; + }, + environmentQueryVariables() { + if (this.glFeatures?.ciLimitEnvironmentScope) { + return { + first: ENVIRONMENT_QUERY_LIMIT, + search: '', + }; + } + + return {}; + }, isLoading() { + // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when + // environment query is loading and FF is enabled + // https://gitlab.com/gitlab-org/gitlab/-/issues/396990 return ( (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) || - this.$apollo.queries.environments.loading || + this.areEnvironmentsLoading || this.isLoadingMoreItems ); }, @@ -228,6 +247,11 @@ export default { updateVariable(variable) { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, + async searchEnvironmentScope(searchTerm) { + if (this.glFeatures?.ciLimitEnvironmentScope) { + this.$apollo.queries.environments.refetch({ search: searchTerm }); + } + }, async variableMutation(mutationAction, variable) { try { const currentMutation = this.mutationData[mutationAction]; @@ -245,11 +269,15 @@ export default { if (data.ciVariableMutation?.errors?.length) { const { errors } = data.ciVariableMutation; createAlert({ message: errors[0] }); - } else if (this.refetchAfterMutation) { - // The writing to cache for admin variable is not working - // because there is no ID in the cache at the top level. - // We therefore need to manually refetch. - this.$apollo.queries.ciVariables.refetch(); + } else { + this.$toast.show(mapMutationActionToToast[mutationAction](variable.key)); + + if (this.refetchAfterMutation) { + // The writing to cache for admin variable is not working + // because there is no ID in the cache at the top level. + // We therefore need to manually refetch. + this.$apollo.queries.ciVariables.refetch(); + } } } catch (e) { createAlert({ message: genericMutationErrorText }); @@ -264,6 +292,7 @@ export default { <template> <ci-variable-settings + :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :entity="entity" :environments="environments" @@ -277,6 +306,7 @@ export default { @handle-prev-page="handlePrevPage" @handle-next-page="handleNextPage" @sort-changed="handleSortChanged" + @search-environment-scope="searchEnvironmentScope" @update-variable="updateVariable" /> </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 5e367ff33b2..6f6c55e07c7 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 @@ -175,12 +175,7 @@ export default { v-if="glFeatures.ciVariablesPages" class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3" > - <gl-button - v-if="!isTableEmpty" - data-qa-selector="reveal_ci_variable_value_button" - @click="toggleHiddenState" - >{{ valuesButtonText }}</gl-button - > + <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button> <gl-button v-gl-modal-directive="$options.modalId" class="gl-mx-3" @@ -317,12 +312,7 @@ export default { @click="setSelectedVariable()" >{{ __('Add variable') }}</gl-button > - <gl-button - v-if="!isTableEmpty" - data-qa-selector="reveal_ci_variable_value_button" - @click="toggleHiddenState" - >{{ valuesButtonText }}</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"> <gl-keyset-pagination diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index c77d8c67bc8..c8f67bd3436 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -1,6 +1,7 @@ -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; +export const ENVIRONMENT_QUERY_LIMIT = 30; export const SORT_DIRECTIONS = { ASC: 'KEY_ASC', @@ -97,6 +98,19 @@ export const ADD_MUTATION_ACTION = 'add'; export const UPDATE_MUTATION_ACTION = 'update'; export const DELETE_MUTATION_ACTION = 'delete'; +export const ADD_VARIABLE_TOAST = (key) => + sprintf(s__('CiVariable|Variable %{key} has been successfully added.'), { key }); +export const UPDATE_VARIABLE_TOAST = (key) => + sprintf(s__('CiVariable|Variable %{key} has been updated.'), { key }); +export const DELETE_VARIABLE_TOAST = (key) => + sprintf(s__('CiVariable|Variable %{key} has been deleted.'), { key }); + +export const mapMutationActionToToast = { + [ADD_MUTATION_ACTION]: ADD_VARIABLE_TOAST, + [UPDATE_MUTATION_ACTION]: UPDATE_VARIABLE_TOAST, + [DELETE_MUTATION_ACTION]: DELETE_VARIABLE_TOAST, +}; + export const EXPANDED_VARIABLES_NOTE = __( '%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.', ); diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql index 921e0ca25b9..26d1b6a3aaa 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql @@ -1,7 +1,7 @@ -query getProjectEnvironments($fullPath: ID!) { +query getProjectEnvironments($fullPath: ID!, $first: Int, $search: String) { project(fullPath: $fullPath) { id - environments { + environments(first: $first, search: $search) { nodes { id name diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue index 4775836fcc6..3fe9103c2b3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue @@ -146,7 +146,7 @@ export default { </gl-sprintf> </gl-form-checkbox> </gl-form-group> - <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1"> + <div class="gl-display-flex gl-py-5"> <gl-button type="submit" class="js-no-auto-disable gl-mr-3" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue new file mode 100644 index 00000000000..25bbd6b3180 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue @@ -0,0 +1,104 @@ +<script> +import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; +import { get, toPath } from 'lodash'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlFormGroup, + GlAccordionItem, + GlFormInput, + GlButton, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + formOptions() { + return [ + { + key: 'artifacts.paths', + title: i18n.ARTIFACTS_AND_CACHE, + paths: this.job.artifacts.paths, + generateInputDataTestId: (index) => `artifacts-paths-input-${index}`, + generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`, + addButtonDataTestId: 'add-artifacts-paths-button', + }, + { + key: 'artifacts.exclude', + title: i18n.ARTIFACTS_EXCLUDE_PATHS, + paths: this.job.artifacts.exclude, + generateInputDataTestId: (index) => `artifacts-exclude-input-${index}`, + generateDeleteButtonDataTestId: (index) => `delete-artifacts-exclude-button-${index}`, + addButtonDataTestId: 'add-artifacts-exclude-button', + }, + { + key: 'cache.paths', + title: i18n.CACHE_PATHS, + paths: this.job.cache.paths, + generateInputDataTestId: (index) => `cache-paths-input-${index}`, + generateDeleteButtonDataTestId: (index) => `delete-cache-paths-button-${index}`, + addButtonDataTestId: 'add-cache-paths-button', + }, + ]; + }, + }, + methods: { + deleteStringArrayItem(path) { + const parentPath = toPath(path).slice(0, -1); + const array = get(this.job, parentPath); + if (array.length <= 1) { + return; + } + this.$emit('update-job', path); + }, + }, +}; +</script> +<template> + <gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE"> + <div v-for="entry in formOptions" :key="entry.key" class="form-group"> + <div class="gl-display-flex"> + <label class="gl-font-weight-bold gl-mb-3">{{ entry.title }}</label> + </div> + <div + v-for="(path, index) in entry.paths" + :key="index" + class="gl-display-flex gl-align-items-center gl-mb-3" + > + <div class="gl-flex-grow-1 gl-flex-basis-0 gl-mr-3"> + <gl-form-input + class="gl-w-full!" + :value="path" + :data-testid="entry.generateInputDataTestId(index)" + @input="$emit('update-job', `${entry.key}[${index}]`, $event)" + /> + </div> + <gl-button + category="tertiary" + icon="remove" + :data-testid="entry.generateDeleteButtonDataTestId(index)" + @click="deleteStringArrayItem(`${entry.key}[${index}]`)" + /> + </div> + <gl-button + category="secondary" + variant="confirm" + :data-testid="entry.addButtonDataTestId" + @click="$emit('update-job', `${entry.key}[${entry.paths.length}]`, '')" + >{{ $options.i18n.ADD_PATH }}</gl-button + > + </div> + <gl-form-group :label="$options.i18n.CACHE_KEY"> + <gl-form-input + :value="job.cache.key" + data-testid="cache-key-input" + @input="$emit('update-job', 'cache.key', $event)" + /> + </gl-form-group> + </gl-accordion-item> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue index c2ae7d7be49..c23a0b866d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue @@ -20,14 +20,20 @@ export default { <template> <gl-accordion-item :title="$options.i18n.IMAGE"> <div class="gl-display-flex"> - <gl-form-group class="gl-flex-grow-1 gl-mr-3" :label="$options.i18n.IMAGE_NAME"> + <gl-form-group + class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" + :label="$options.i18n.IMAGE_NAME" + > <gl-form-input :value="job.image.name" data-testid="image-name-input" @input="$emit('update-job', 'image.name', $event)" /> </gl-form-group> - <gl-form-group class="gl-flex-grow-1" :label="$options.i18n.IMAGE_ENTRYPOINT"> + <gl-form-group + class="gl-flex-grow-1 gl-flex-basis-half" + :label="$options.i18n.IMAGE_ENTRYPOINT" + > <gl-form-input :value="job.image.entrypoint.join(' ')" data-testid="image-entrypoint-input" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue index a25b3ca09fd..b49355d539c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue @@ -7,7 +7,6 @@ import { GlTokenSelector, GlFormCombobox, } from '@gitlab/ui'; -import { mapState } from 'vuex'; import { i18n } from '../constants'; export default { @@ -37,9 +36,10 @@ export default { type: Boolean, required: true, }, - }, - computed: { - ...mapState(['availableStages']), + availableStages: { + type: Array, + required: true, + }, }, }; </script> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue new file mode 100644 index 00000000000..d068b370852 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue @@ -0,0 +1,105 @@ +<script> +import { + GlFormGroup, + GlAccordionItem, + GlFormInput, + GlFormSelect, + GlFormCheckbox, +} from '@gitlab/ui'; +import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants'; + +export default { + i18n, + whenOptions: Object.values(JOB_RULES_WHEN), + unitOptions: Object.values(JOB_RULES_START_IN), + components: { + GlAccordionItem, + GlFormInput, + GlFormSelect, + GlFormCheckbox, + GlFormGroup, + }, + props: { + job: { + type: Object, + required: true, + }, + isStartValid: { + type: Boolean, + required: true, + }, + }, + data() { + return { + startInNumber: 1, + startInUnit: JOB_RULES_START_IN.second.value, + }; + }, + computed: { + isDelayed() { + return this.job.rules[0].when === JOB_RULES_WHEN.delayed.value; + }, + }, + methods: { + updateStartIn() { + const plural = this.startInNumber > 1 ? 's' : ''; + this.$emit( + 'update-job', + 'rules[0].start_in', + `${this.startInNumber} ${this.startInUnit}${plural}`, + ); + }, + }, +}; +</script> +<template> + <gl-accordion-item :title="$options.i18n.RULES"> + <div class="gl-display-flex"> + <gl-form-group class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" :label="$options.i18n.WHEN"> + <gl-form-select + class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" + :options="$options.whenOptions" + data-testid="rules-when-select" + :value="job.rules[0].when" + @input="$emit('update-job', 'rules[0].when', $event)" + /> + </gl-form-group> + <gl-form-group + class="gl-flex-grow-1 gl-flex-basis-half" + :invalid-feedback="$options.i18n.INVALID_START_IN" + :state="isStartValid" + > + <div class="gl-display-flex gl-mt-5"> + <gl-form-input + v-model="startInNumber" + class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" + data-testid="rules-start-in-number-input" + type="number" + :state="isStartValid" + :class="{ 'gl-visibility-hidden': !isDelayed }" + number + @input="updateStartIn" + /> + <gl-form-select + v-model="startInUnit" + class="gl-flex-grow-1 gl-flex-basis-half" + data-testid="rules-start-in-unit-select" + :state="isStartValid" + :class="{ 'gl-visibility-hidden': !isDelayed }" + :options="$options.unitOptions" + @input="updateStartIn" + /> + </div> + </gl-form-group> + </div> + <gl-form-group> + <gl-form-checkbox + :checked="job.rules[0].allow_failure" + data-testid="rules-allow-failure-checkbox" + @input="$emit('update-job', 'rules[0].allow_failure', $event)" + > + {{ $options.i18n.ALLOW_FAILURE }} + </gl-form-checkbox> + </gl-form-group> + </gl-accordion-item> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js index 994a6e719fe..df3a2c64e25 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js @@ -2,6 +2,59 @@ import { __, s__ } from '~/locale'; export const DRAWER_CONTAINER_CLASS = '.content-wrapper'; +export const JOB_RULES_WHEN = { + onSuccess: { + value: 'on_success', + text: s__('JobAssistant|on_success'), + }, + onFailure: { + value: 'on_failure', + text: s__('JobAssistant|on_failure'), + }, + manual: { + value: 'manual', + text: s__('JobAssistant|manual'), + }, + always: { + value: 'always', + text: s__('JobAssistant|always'), + }, + delayed: { + value: 'delayed', + text: s__('JobAssistant|delayed'), + }, + never: { + value: 'never', + text: s__('JobAssistant|never'), + }, +}; + +export const JOB_RULES_START_IN = { + second: { + value: 'second', + text: s__('JobAssistant|second(s)'), + }, + minute: { + value: 'minute', + text: s__('JobAssistant|minute(s)'), + }, + day: { + value: 'day', + text: s__('JobAssistant|day(s)'), + }, + week: { + value: 'week', + text: s__('JobAssistant|week(s)'), + }, +}; + +export const SECONDS_MULTIPLE_MAP = { + second: 1, + minute: 60, + day: 3600 * 24, + week: 3600 * 24 * 7, +}; + export const JOB_TEMPLATE = { name: '', stage: '', @@ -25,6 +78,13 @@ export const JOB_TEMPLATE = { paths: [''], key: '', }, + rules: [ + { + allow_failure: false, + when: 'on_success', + start_in: '', + }, + ], }; export const i18n = { @@ -38,4 +98,14 @@ export const i18n = { IMAGE_NAME: s__('JobAssistant|Image name (optional)'), IMAGE_ENTRYPOINT: s__('JobAssistant|Image entrypoint (optional)'), THIS_FIELD_IS_REQUIRED: __('This field is required'), + CACHE_PATHS: s__('JobAssistant|Cache paths (optional)'), + CACHE_KEY: s__('JobAssistant|Cache key (optional)'), + ARTIFACTS_EXCLUDE_PATHS: s__('JobAssistant|Artifacts exclude paths (optional)'), + ARTIFACTS_PATHS: s__('JobAssistant|Artifacts paths (optional)'), + ARTIFACTS_AND_CACHE: s__('JobAssistant|Artifacts and cache'), + ADD_PATH: s__('JobAssistant|Add path'), + RULES: s__('JobAssistant|Rules'), + WHEN: s__('JobAssistant|When'), + ALLOW_FAILURE: s__('JobAssistant|Allow failure'), + INVALID_START_IN: s__('JobAssistant|Error - Valid value is between 1 second and 1 week'), }; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue index 9f68b97b329..8cde20bc22e 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue @@ -1,16 +1,16 @@ <script> import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui'; -import { stringify } from 'yaml'; -import { mapMutations, mapState } from 'vuex'; -import { set, omit, trim } from 'lodash'; +import { stringify, parse } from 'yaml'; +import { get, omit, toPath } from 'lodash'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; -import { UPDATE_CI_CONFIG } from '~/ci/pipeline_editor/store/mutation_types'; -import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql'; -import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants'; -import { removeEmptyObj, trimFields } from './utils'; +import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql'; +import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants'; +import { removeEmptyObj, trimFields, validateEmptyValue, validateStartIn } from './utils'; import JobSetupItem from './accordion_items/job_setup_item.vue'; import ImageItem from './accordion_items/image_item.vue'; +import ArtifactsAndCacheItem from './accordion_items/artifacts_and_cache_item.vue'; +import RulesItem from './accordion_items/rules_item.vue'; export default { i18n, @@ -20,6 +20,8 @@ export default { GlButton, JobSetupItem, ImageItem, + ArtifactsAndCacheItem, + RulesItem, }, props: { isVisible: { @@ -32,24 +34,38 @@ export default { required: false, default: 200, }, + ciConfigData: { + type: Object, + required: true, + }, + ciFileContent: { + type: String, + required: true, + }, }, data() { return { isNameValid: true, isScriptValid: true, + isStartValid: true, job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), }; }, apollo: { runners: { - query: getAllRunners, + query: getRunnerTags, update(data) { return data?.runners?.nodes || []; }, }, }, computed: { - ...mapState(['currentCiFileContent']), + availableStages() { + if (this.ciConfigData?.mergedYaml) { + return parse(this.ciConfigData.mergedYaml).stages; + } + return []; + }, tagOptions() { const options = []; this.runners?.forEach((runner) => options.push(...runner.tagList)); @@ -63,25 +79,36 @@ export default { drawerHeightOffset() { return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); }, + isJobValid() { + return this.isNameValid && this.isScriptValid && this.isStartValid; + }, + }, + + watch: { + 'job.name': function jobNameWatch(name) { + this.isNameValid = validateEmptyValue(name); + }, + 'job.script': function jobScriptWatch(script) { + this.isScriptValid = validateEmptyValue(script); + }, + 'job.rules.0.start_in': function JobRulesStartInWatch(startIn) { + this.isStartValid = validateStartIn(this.job.rules[0].when, startIn); + }, }, methods: { - ...mapMutations({ - updateCiConfig: UPDATE_CI_CONFIG, - }), closeDrawer() { this.clearJob(); this.$emit('close-job-assistant-drawer'); }, addCiConfig() { - this.isNameValid = this.validate(this.job.name); - this.isScriptValid = this.validate(this.job.script); + this.validateJob(); - if (!this.isNameValid || !this.isScriptValid) { + if (!this.isJobValid) { return; } const newJobString = this.generateYmlString(); - this.updateCiConfig(`${this.currentCiFileContent}\n${newJobString}`); + this.$emit('updateCiConfig', `${this.ciFileContent}\n${newJobString}`); eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM); this.closeDrawer(); @@ -89,27 +116,53 @@ export default { generateYmlString() { let job = JSON.parse(JSON.stringify(this.job)); const jobName = job.name; - job = omit(job, ['name']); + job = this.removeUnnecessaryKeys(job); job.tags = job.tags.map((tag) => tag.name); // Tag item is originally an option object, we need a string here to match `.gitlab-ci.yml` rules const cleanedJob = trimFields(removeEmptyObj(job)); return stringify({ [jobName]: cleanedJob }); }, + removeUnnecessaryKeys(job) { + const keys = ['name']; + + // rules[0].allow_failure value should not be passed down + // if it equals the default value + if (this.job.rules[0].allow_failure === false) { + keys.push('rules[0].allow_failure'); + } + // rules[0].when value should not be passed down + // if it equals the default value + if (this.job.rules[0].when === JOB_RULES_WHEN.onSuccess.value) { + keys.push('rules[0].when'); + } + // rules[0].start_in value should not be passed down + // if rules[0].start_in doesn't equal 'delayed' + if (this.job.rules[0].when !== JOB_RULES_WHEN.delayed.value) { + keys.push('rules[0].start_in'); + } + return omit(job, keys); + }, clearJob() { this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE)); - this.isNameValid = true; - this.isScriptValid = true; + this.$nextTick(() => { + this.isNameValid = true; + this.isScriptValid = true; + this.isStartValid = true; + }); }, updateJob(key, value) { - set(this.job, key, value); - if (key === 'name') { - this.isNameValid = this.validate(this.job.name); - } - if (key === 'script') { - this.isScriptValid = this.validate(this.job.script); + const path = toPath(key); + const targetObj = path.length === 1 ? this.job : get(this.job, path.slice(0, -1)); + const lastKey = path[path.length - 1]; + if (value !== undefined) { + this.$set(targetObj, lastKey, value); + } else { + this.$delete(targetObj, lastKey); } }, - validate(value) { - return trim(value) !== ''; + validateJob() { + this.isNameValid = validateEmptyValue(this.job.name); + this.isScriptValid = validateEmptyValue(this.job.script); + this.isStartValid = validateStartIn(this.job.rules[0].when, this.job.rules[0].start_in); }, }, }; @@ -131,9 +184,12 @@ export default { :job="job" :is-name-valid="isNameValid" :is-script-valid="isScriptValid" + :available-stages="availableStages" @update-job="updateJob" /> <image-item :job="job" @update-job="updateJob" /> + <artifacts-and-cache-item :job="job" @update-job="updateJob" /> + <rules-item :job="job" :is-start-valid="isStartValid" @update-job="updateJob" /> </gl-accordion> <template #footer> <div class="gl-display-flex gl-justify-content-end"> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js index 83e7574c4de..a604d79259d 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js @@ -1,4 +1,8 @@ import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash'; +import { + JOB_RULES_WHEN, + SECONDS_MULTIPLE_MAP, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; const isEmptyValue = (val) => (isObject(val) || isString(val)) && isEmpty(val); const trimText = (val) => (isString(val) ? trim(val) : val); @@ -20,3 +24,30 @@ export const trimFields = (data) => { } return trimText(data); }; + +export const validateEmptyValue = (value) => { + return trim(value) !== ''; +}; + +export const validateStartIn = (when, startIn) => { + const hasNoValue = when !== JOB_RULES_WHEN.delayed.value; + if (hasNoValue) { + return true; + } + + let [startInNumber, startInUnit] = startIn.split(' '); + + startInNumber = Number(startInNumber); + if (!Number.isInteger(startInNumber)) { + return false; + } + + const isPlural = startInUnit.slice(-1) === 's'; + if (isPlural) { + startInUnit = startInUnit.slice(0, -1); + } + + const multiple = SECONDS_MULTIPLE_MAP[startInUnit]; + + return startInNumber * multiple >= 1 && startInNumber * multiple <= SECONDS_MULTIPLE_MAP.week; +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index dd25c4d433b..e775dc5147a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js @@ -86,25 +86,8 @@ export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/- export const COMMIT_SHA_POLL_INTERVAL = 1000; -export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section'; -export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked'; -export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked'; -export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked'; export const I18N = { title: s__('Pipelines|Get started with GitLab CI/CD'), - runners: { - title: s__('Pipelines|Runners are available to run your jobs now'), - subtitle: s__( - 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.', - ), - }, - noRunners: { - title: s__('Pipelines|No runners detected'), - subtitle: s__( - 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.', - ), - cta: s__('Pipelines|Install GitLab Runner'), - }, learnBasics: { title: s__('Pipelines|Learn the basics of pipelines and .yml files'), subtitle: s__( diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql new file mode 100644 index 00000000000..aab30257d13 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql @@ -0,0 +1,8 @@ +query getRunnerTags { + runners { + nodes { + id + tagList + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index d65a7c321ce..09acd805410 100644 --- a/app/assets/javascripts/ci/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js @@ -12,7 +12,6 @@ import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphq import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; -import createStore from './store'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); @@ -112,11 +111,8 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { }, }); - const store = createStore(); - return new Vue({ el, - store, apolloProvider, provide: { ciConfigPath, diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index 7b3c4d6f74f..ff848a973e3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue @@ -1,12 +1,10 @@ <script> import { GlLoadingIcon, GlModal } from '@gitlab/ui'; -import { mapState, mapMutations } from 'vuex'; -import { parse } from 'yaml'; import { fetchPolicies } from '~/lib/graphql'; import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; + import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; -import { UPDATE_CI_CONFIG, UPDATE_AVAILABLE_STAGES } from './store/mutation_types'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; @@ -46,6 +44,7 @@ export default { data() { return { ciConfigData: {}, + currentCiFileContent: '', failureType: null, failureReasons: [], hasBranchLoaded: false, @@ -95,7 +94,7 @@ export default { const fileContent = rawBlob ?? ''; this.lastCommittedContent = fileContent; - this.updateCiConfig(fileContent); + this.currentCiFileContent = fileContent; // If rawBlob is defined and returns a string, it means that there is // a CI config file with empty content. If `rawBlob` is not defined @@ -156,10 +155,6 @@ export default { this.isLintUnavailable = false; } } - - if (data?.ciConfig?.mergedYaml) { - this.updateAvailableStages(parse(data.ciConfig.mergedYaml).stages); - } }, error() { // We are not using `reportFailure` here because we don't @@ -236,7 +231,6 @@ export default { }, }, computed: { - ...mapState(['currentCiFileContent']), hasUnsavedChanges() { return this.lastCommittedContent !== this.currentCiFileContent; }, @@ -300,10 +294,6 @@ export default { this.checkShouldSkipStartScreen(); }, methods: { - ...mapMutations({ - updateCiConfig: UPDATE_CI_CONFIG, - updateAvailableStages: UPDATE_AVAILABLE_STAGES, - }), checkShouldSkipStartScreen() { const params = queryToObject(window.location.search); this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); @@ -354,7 +344,7 @@ export default { }, resetContent() { this.showResetConfirmationModal = false; - this.updateCiConfig(this.lastCommittedContent); + this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { @@ -371,6 +361,9 @@ export default { showErrorAlert({ type, reasons = [] }) { this.reportFailure(type, reasons); }, + updateCiConfig(ciFileContent) { + this.currentCiFileContent = ciFileContent; + }, updateCommitSha() { this.isFetchingCommitSha = true; this.$apollo.queries.commitSha.refetch(); 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 59863edbe0b..1329042ee4c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -195,6 +195,8 @@ export default { @close-drawer="closeDrawer" /> <job-assistant-drawer + :ci-config-data="ciConfigData" + :ci-file-content="ciFileContent" :is-visible="showJobAssistantDrawer" :z-index="jobAssistantIndex" v-on="$listeners" diff --git a/app/assets/javascripts/ci/pipeline_editor/store/index.js b/app/assets/javascripts/ci/pipeline_editor/store/index.js deleted file mode 100644 index d7d5aed79e2..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export default () => - new Vuex.Store({ - mutations, - state: state(), - }); diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js deleted file mode 100644 index 035d3c90c14..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js +++ /dev/null @@ -1,2 +0,0 @@ -export const UPDATE_CI_CONFIG = 'UPDATE_CI_CONFIG'; -export const UPDATE_AVAILABLE_STAGES = 'UPDATE_AVAILABLE_STAGES'; diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js deleted file mode 100644 index 552c1df9a2c..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.UPDATE_CI_CONFIG](state, content) { - state.currentCiFileContent = content; - }, - [types.UPDATE_AVAILABLE_STAGES](state, stages) { - state.availableStages = stages || []; - }, -}; diff --git a/app/assets/javascripts/ci/pipeline_editor/store/state.js b/app/assets/javascripts/ci/pipeline_editor/store/state.js deleted file mode 100644 index 34146cd54c4..00000000000 --- a/app/assets/javascripts/ci/pipeline_editor/store/state.js +++ /dev/null @@ -1,4 +0,0 @@ -export default () => ({ - currentCiFileContent: '', - availableStages: [], -}); diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql index 648cd8b66b5..f93f5ad4f11 100644 --- a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql +++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql @@ -1,7 +1,7 @@ query ciConfigVariables($fullPath: ID!, $ref: String!) { project(fullPath: $fullPath) { id - ciConfigVariables(sha: $ref) { + ciConfigVariables(ref: $ref) { description key value diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index 56461165588..92f461c72d7 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -23,7 +23,7 @@ export default { </script> <template> - <div> + <div data-testid="last-pipeline-status"> <ci-badge-link v-if="hasPipeline" :status="lastPipelineStatus" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue index 48d59bf6e7c..9c0fc148dac 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue @@ -23,7 +23,7 @@ export default { </script> <template> - <div> + <div data-testid="next-run-cell"> <time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" /> <span v-else data-testid="pipeline-schedule-inactive"> {{ s__('PipelineSchedules|Inactive') }} 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 0b95e2037e8..b97914f8c26 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 @@ -68,7 +68,12 @@ export default { </script> <template> - <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md"> + <gl-table-lite + :fields="$options.fields" + :items="schedules" + :tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }" + stacked="md" + > <template #table-colgroup="{ fields }"> <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> </template> diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue index 79600012838..43d0dae6e78 100644 --- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue @@ -6,7 +6,7 @@ import { s__ } from '~/locale'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; -import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM } from '../constants'; +import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, INSTANCE_TYPE } from '../constants'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; export default { @@ -34,21 +34,21 @@ export default { }, methods: { onSaved(runner) { - const registerUrl = setUrlParams( - { [PARAM_KEY_PLATFORM]: this.platform }, - runner.registerAdminUrl, - ); + const params = { [PARAM_KEY_PLATFORM]: this.platform }; + const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl); + saveAlertToLocalStorage({ message: s__('Runners|Runner created.'), variant: VARIANT_SUCCESS, }); - redirectTo(registerUrl); + redirectTo(ephemeralRegisterUrl); }, onError(error) { createAlert({ message: error.message }); }, }, modalId: 'runners-legacy-registration-instructions-modal', + INSTANCE_TYPE, }; </script> @@ -84,6 +84,6 @@ export default { <hr aria-hidden="true" /> - <runner-create-form @saved="onSaved" @error="onError" /> + <runner-create-form :runner-type="$options.INSTANCE_TYPE" @saved="onSaved" @error="onError" /> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 97dfbe1a051..24c1b4f5c3b 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -1,6 +1,8 @@ <script> import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; @@ -14,6 +16,7 @@ import { I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, I18N_CREATED_AT_LABEL, + I18N_CREATED_AT_BY_LABEL, } from '../../constants'; import RunnerSummaryField from './runner_summary_field.vue'; @@ -28,6 +31,7 @@ export default { RunnerTypeBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), + UserAvatarLink, TooltipOnTruncate, }, directives: { @@ -43,6 +47,16 @@ export default { jobCount() { return formatJobCount(this.runner.jobCount); }, + createdBy() { + return this.runner?.createdBy; + }, + createdByImgAlt() { + const name = this.createdBy?.name; + if (name) { + return sprintf(__("%{name}'s avatar"), { name }); + } + return null; + }, }, i18n: { I18N_NO_DESCRIPTION, @@ -50,6 +64,7 @@ export default { I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, I18N_CREATED_AT_LABEL, + I18N_CREATED_AT_BY_LABEL, }, }; </script> @@ -106,11 +121,30 @@ export default { </runner-summary-field> <runner-summary-field icon="calendar"> - <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> - <template #timeAgo> - <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> - </template> - </gl-sprintf> + <template v-if="createdBy"> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_BY_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + <template #avatar> + <user-avatar-link + :link-href="createdBy.webUrl" + :img-src="createdBy.avatarUrl" + img-css-classes="gl-vertical-align-top" + :img-size="16" + :img-alt="createdByImgAlt" + :tooltip-text="createdBy.username" + /> + </template> + </gl-sprintf> + </template> + <template v-else> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> </runner-summary-field> </div> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue index 1bbbd55089a..20681873436 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -24,7 +24,7 @@ export default { </script> <template> - <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2"> + <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-4"> <gl-icon v-if="icon" :name="icon" /> <!-- display tooltip as a label for screen readers --> <span class="gl-sr-only">{{ tooltip }}</span> 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 2f3c172666d..69021dde0e9 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue @@ -70,7 +70,7 @@ export default { captureException({ error, component: this.$options.name }); }, pollInterval() { - if (this.runner?.status === STATUS_ONLINE) { + if (this.isRunnerOnline) { // stop polling return 0; } @@ -97,9 +97,6 @@ export default { } return s__('Runners|Register runner'); }, - status() { - return this.runner?.status; - }, tokenMessage() { if (this.token) { return s__( @@ -116,22 +113,40 @@ export default { registerCommand() { return registerCommand({ platform: this.platform, - registrationToken: this.token, - description: this.description, + token: this.token, }); }, runCommand() { return runCommand({ platform: this.platform }); }, + isRunnerOnline() { + return this.runner?.status === STATUS_ONLINE; + }, + }, + created() { + window.addEventListener('beforeunload', this.onBeforeunload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onBeforeunload); }, methods: { toggleDrawer() { this.$emit('toggleDrawer'); }, + onBeforeunload(event) { + if (this.isRunnerOnline) { + return undefined; + } + + const str = s__('Runners|You may lose access to the runner token if you leave this page.'); + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.returnValue = str; // Chrome requires returnValue to be set + return str; + }, }, EXECUTORS_HELP_URL, SERVICE_COMMANDS_HELP_URL, - STATUS_ONLINE, I18N_REGISTRATION_SUCCESS, }; </script> @@ -226,7 +241,7 @@ export default { </gl-sprintf> </p> </section> - <section v-if="status == $options.STATUS_ONLINE"> + <section v-if="isRunnerOnline"> <h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2> <p class="gl-pl-6"> diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js index 94d75bc4562..c8a75506c9c 100644 --- a/app/assets/javascripts/ci/runner/components/registration/utils.js +++ b/app/assets/javascripts/ci/runner/components/registration/utils.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { DEFAULT_PLATFORM, LINUX_PLATFORM, @@ -28,20 +27,6 @@ const OS = { }, }; -const escapedParam = (param, shell = 'bash') => { - let escaped; - if (shell === 'bash') { - // replace single-quotes by the sequence '\'' - escaped = param.replaceAll("'", "'\\''"); - } else if (shell === 'powershell') { - // replace single-quotes by the sequence '' - // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.3 - escaped = param.replaceAll("'", "''"); - } - // surround with single quotes. - return `'${escaped}'`; -}; - export const commandPrompt = ({ platform }) => { return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt; }; @@ -50,32 +35,19 @@ export const executable = ({ platform }) => { return (OS[platform] || OS[DEFAULT_PLATFORM]).executable; }; -const shell = ({ platform }) => { - return (OS[platform] || OS[DEFAULT_PLATFORM]).shell; -}; - -export const registerCommand = ({ - platform, - url = gon.gitlab_url, - registrationToken, - description, -}) => { - const lines = [`${executable({ platform })} register`]; +export const registerCommand = ({ platform, url = gon.gitlab_url, token }) => { + const lines = [`${executable({ platform })} register`]; // eslint-disable-line @gitlab/require-i18n-strings if (url) { lines.push(` --url ${url}`); } - if (registrationToken) { - lines.push(` --registration-token ${registrationToken}`); - } - if (description) { - const escapedDescription = escapedParam(description, shell({ platform })); - lines.push(` --description ${escapedDescription}`); + if (token) { + lines.push(` --token ${token}`); } return lines; }; export const runCommand = ({ platform }) => { - return `${executable({ platform })} run`; + return `${executable({ platform })} run`; // eslint-disable-line @gitlab/require-i18n-strings }; const importInstallScript = ({ platform = DEFAULT_PLATFORM }) => { diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue index 5d2a3c53842..d3e02f5cd6e 100644 --- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue @@ -4,7 +4,7 @@ import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql'; import { modelToUpdateMutationVariables } from 'ee_else_ce/ci/runner/runner_update_form_utils'; import { captureException } from '../sentry_utils'; -import { DEFAULT_ACCESS_LEVEL } from '../constants'; +import { RUNNER_TYPES, DEFAULT_ACCESS_LEVEL, GROUP_TYPE, INSTANCE_TYPE } from '../constants'; export default { name: 'RunnerCreateForm', @@ -13,6 +13,18 @@ export default { GlButton, RunnerFormFields, }, + props: { + runnerType: { + type: String, + required: true, + validator: (t) => RUNNER_TYPES.includes(t), + }, + groupId: { + type: String, + required: false, + default: null, + }, + }, data() { return { saving: false, @@ -27,6 +39,23 @@ export default { }, }; }, + computed: { + mutationInput() { + const { input } = modelToUpdateMutationVariables(this.runner); + + if (this.runnerType === GROUP_TYPE) { + return { + ...input, + runnerType: GROUP_TYPE, + groupId: this.groupId, + }; + } + return { + ...input, + runnerType: INSTANCE_TYPE, + }; + }, + }, methods: { async onSubmit() { this.saving = true; @@ -37,7 +66,9 @@ export default { }, } = await this.$apollo.mutate({ mutation: runnerCreateMutation, - variables: modelToUpdateMutationVariables(this.runner), + variables: { + input: this.mutationInput, + }, }); if (errors?.length) { diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 6237dcd0c03..1cae9df713b 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -93,6 +93,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( export const I18N_VERSION_LABEL = s__('Runners|Version %{version}'); export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}'); export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}'); +export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{avatar}'); export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited'); export const I18N_ADMIN = s__('Runners|Administrator'); @@ -141,6 +142,7 @@ export const PARAM_KEY_PLATFORM = 'platform'; export const INSTANCE_TYPE = 'INSTANCE_TYPE'; export const GROUP_TYPE = 'GROUP_TYPE'; export const PROJECT_TYPE = 'PROJECT_TYPE'; +export const RUNNER_TYPES = [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE]; // CiRunnerStatus diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index 6f72509f599..0a449ef0435 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment ListItemShared on CiRunner { id description @@ -10,6 +12,9 @@ fragment ListItemShared on CiRunner { jobCount tagList createdAt + createdBy { + ...User + } contactedAt status(legacyMode: null) jobExecutionStatus diff --git a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql index d14a594e378..07236808dca 100644 --- a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql +++ b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql @@ -2,7 +2,7 @@ mutation runnerCreate($input: RunnerCreateInput!) { runnerCreate(input: $input) { runner { id - registerAdminUrl + ephemeralRegisterUrl } errors } diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue new file mode 100644 index 00000000000..35c75a917c7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue @@ -0,0 +1,98 @@ +<script> +import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import { DEFAULT_PLATFORM, GROUP_TYPE, PARAM_KEY_PLATFORM } from '../constants'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; + +export default { + name: 'GroupNewRunnerApp', + components: { + GlLink, + GlSprintf, + RunnerInstructionsModal, + RunnerPlatformsRadioGroup, + RunnerCreateForm, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + groupId: { + type: String, + required: true, + }, + legacyRegistrationToken: { + type: String, + required: true, + }, + }, + data() { + return { + platform: DEFAULT_PLATFORM, + }; + }, + methods: { + onSaved(runner) { + const params = { [PARAM_KEY_PLATFORM]: this.platform }; + const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl); + + saveAlertToLocalStorage({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + redirectTo(ephemeralRegisterUrl); + }, + onError(error) { + createAlert({ message: error.message }); + }, + }, + modalId: 'runners-legacy-registration-instructions-modal', + GROUP_TYPE, +}; +</script> + +<template> + <div> + <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1> + <p> + <gl-sprintf + :message=" + s__( + 'Runners|Create a group runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{ + content + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="legacyRegistrationToken" + /> + </template> + </gl-sprintf> + </p> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Platform') }} + </h2> + <runner-platforms-radio-group v-model="platform" /> + + <hr aria-hidden="true" /> + + <runner-create-form + :runner-type="$options.GROUP_TYPE" + :group-id="groupId" + @saved="onSaved" + @error="onError" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/group_new_runner/index.js b/app/assets/javascripts/ci/runner/group_new_runner/index.js new file mode 100644 index 00000000000..b314c3aa1e7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_new_runner/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import GroupNewRunnerApp from './group_new_runner_app.vue'; + +Vue.use(VueApollo); + +export const initGroupNewRunner = (selector = '#js-group-new-runner') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { legacyRegistrationToken, groupId } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(GroupNewRunnerApp, { + props: { + groupId, + legacyRegistrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue new file mode 100644 index 00000000000..533d31b70a3 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue @@ -0,0 +1,69 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { getParameterByName, updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; +import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM } from '../constants'; +import RegistrationInstructions from '../components/registration/registration_instructions.vue'; +import PlatformsDrawer from '../components/registration/platforms_drawer.vue'; + +export default { + name: 'GroupRegisterRunnerApp', + components: { + GlButton, + RegistrationInstructions, + PlatformsDrawer, + }, + props: { + runnerId: { + type: String, + required: true, + }, + runnersPath: { + type: String, + required: true, + }, + }, + data() { + return { + platform: getParameterByName(PARAM_KEY_PLATFORM) || DEFAULT_PLATFORM, + isDrawerOpen: false, + }; + }, + watch: { + platform(platform) { + updateHistory({ + url: mergeUrlParams({ [PARAM_KEY_PLATFORM]: platform }, window.location.href), + }); + }, + }, + methods: { + onSelectPlatform(platform) { + this.platform = platform; + }, + onToggleDrawer(val = !this.isDrawerOpen) { + this.isDrawerOpen = val; + }, + }, +}; +</script> +<template> + <div> + <registration-instructions + :runner-id="runnerId" + :platform="platform" + @toggleDrawer="onToggleDrawer" + > + <template #runner-list-name>{{ s__('Runners|Group area › Runners') }}</template> + </registration-instructions> + + <platforms-drawer + :platform="platform" + :open="isDrawerOpen" + @selectPlatform="onSelectPlatform" + @close="onToggleDrawer(false)" + /> + + <gl-button :href="runnersPath" variant="confirm">{{ + s__('Runners|Go to runners page') + }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/group_register_runner/index.js b/app/assets/javascripts/ci/runner/group_register_runner/index.js new file mode 100644 index 00000000000..a00db8853a2 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_register_runner/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; +import GroupRegisterRunnerApp from './group_register_runner_app.vue'; + +Vue.use(VueApollo); + +export const initGroupRegisterRunner = (selector = '#js-group-register-runner') => { + showAlertFromLocalStorage(); + + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId, runnersPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(GroupRegisterRunnerApp, { + props: { + runnerId, + runnersPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 294d06a66e7..f8386214698 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -1,5 +1,5 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { updateHistory } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; @@ -42,6 +42,7 @@ import { captureException } from '../sentry_utils'; export default { name: 'GroupRunnersApp', components: { + GlButton, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -58,6 +59,11 @@ export default { mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { + newRunnerPath: { + type: String, + required: false, + default: null, + }, registrationToken: { type: String, required: false, @@ -150,6 +156,10 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, + shouldShowCreateRunnerWorkflow() { + // create_runner_workflow_for_namespace feature flag + return this.glFeatures.createRunnerWorkflowForNamespace; + }, }, watch: { search: { @@ -219,8 +229,13 @@ export default { nav-class="gl-border-none!" /> + <template v-if="shouldShowCreateRunnerWorkflow"> + <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> + {{ s__('Runners|New group runner') }} + </gl-button> + </template> <registration-dropdown - v-if="registrationToken" + v-else-if="registrationToken" class="gl-ml-auto" :registration-token="registrationToken" :type="$options.GROUP_TYPE" diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js index 46514d5afe8..4fcf484317d 100644 --- a/app/assets/javascripts/ci/runner/group_runners/index.js +++ b/app/assets/javascripts/ci/runner/group_runners/index.js @@ -18,6 +18,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { const { registrationToken, runnerInstallHelpPage, + newRunnerPath, groupId, groupFullPath, onlineContactTimeoutSecs, @@ -49,6 +50,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { props: { registrationToken, groupFullPath, + newRunnerPath, }, }); }, diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue index ca65665b9ed..24a776e1a29 100644 --- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue +++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue @@ -164,7 +164,7 @@ export default { :href="$options.emptyHelpLink" :title="$options.i18n.emptyTooltip" :aria-label="$options.i18n.emptyTooltip" - ><gl-icon name="question" :size="14" + ><gl-icon name="question-o" :size="14" /></gl-link> </template> </gl-empty-state> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index e0e3b961c51..d7e98638a11 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -8,9 +8,13 @@ import { GlTooltipDirective, GlPopover, } from '@gitlab/ui'; +import semverLt from 'semver/functions/lt'; +import semverInc from 'semver/functions/inc'; +import semverPrerelease from 'semver/functions/prerelease'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants'; import { getAgentConfigPath } from '../clusters_util'; import DeleteAgentButton from './delete_agent_button.vue'; @@ -81,6 +85,11 @@ export default { tdClass, }, { + key: 'agentID', + label: this.$options.i18n.agentIdLabel, + tdClass, + }, + { key: 'configuration', label: this.$options.i18n.configurationLabel, tdClass, @@ -116,6 +125,9 @@ export default { getPopoverTestId(item) { return `popover-${item.name}`; }, + getAgentId(item) { + return getIdFromGraphQLId(item.id); + }, getAgentConfigPath, getAgentVersions(agent) { const agentConnections = agent.connections?.nodes || []; @@ -134,18 +146,26 @@ export default { isVersionMismatch(agent) { return agent.versions.length > 1; }, + // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version + // using the following heuristics: + // - KAS Version is used as *server* version if available, otherwise the GitLab version is used. + // - returns `outdated` if the agent has a different major version than the server + // - returns `outdated` if the agents minor version is at least two proper versions older than the server + // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version + // + // Note that it does NOT support if the agent is newer than the server version. isVersionOutdated(agent) { if (!agent.versions.length) return false; - const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.'); - const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.'); - - const majorVersionMismatch = agentMajorVersion !== serverMajorVersion; + const agentVersion = this.getAgentVersionString(agent); + let allowableAgentVersion = semverInc(agentVersion, 'minor'); - // We should warn user if their current GitLab and agent versions are more than 1 minor version apart: - const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1; + const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion)); + if (isServerPrerelease) { + allowableAgentVersion = semverInc(allowableAgentVersion, 'minor'); + } - return majorVersionMismatch || minorVersionMismatch; + return semverLt(allowableAgentVersion, this.serverVersion); }, getVersionPopoverTitle(agent) { @@ -265,6 +285,12 @@ export default { </gl-popover> </template> + <template #cell(agentID)="{ item }"> + <span data-testid="cluster-agent-id"> + {{ getAgentId(item) }} + </span> + </template> + <template #cell(configuration)="{ item }"> <span data-testid="cluster-agent-configuration-link"> <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> @@ -279,7 +305,7 @@ export default { :title="$options.i18n.defaultConfigTooltip" :aria-label="$options.i18n.defaultConfigTooltip" class="gl-vertical-align-middle" - ><gl-icon name="question" :size="14" /></gl-link + ><gl-icon name="question-o" :size="14" /></gl-link ></span> </span> </template> diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue index e4b481f0908..9e0d2cc73ec 100644 --- a/app/assets/javascripts/saved_replies/components/app.vue +++ b/app/assets/javascripts/comment_templates/components/app.vue @@ -6,12 +6,12 @@ export default {}; <div class="row gl-mt-5"> <div class="col-lg-4"> <h4 class="gl-mt-0"> - {{ __('Saved Replies') }} + {{ __('Comment templates') }} </h4> <p> {{ __( - 'Saved replies can be used when creating comments inside issues, merge requests, and epics.', + 'Comment templates can be used when creating comments inside issues, merge requests, and epics.', ) }} </p> diff --git a/app/assets/javascripts/saved_replies/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue index efec9b96764..47efccc3d0c 100644 --- a/app/assets/javascripts/saved_replies/components/form.vue +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -38,7 +38,7 @@ export default { errors: [], saving: false, showValidation: false, - updateSavedReply: { + updateCommentTemplate: { name: this.name, content: this.content, }, @@ -46,12 +46,12 @@ export default { }, computed: { isNameValid() { - if (this.showValidation) return Boolean(this.updateSavedReply.name); + if (this.showValidation) return Boolean(this.updateCommentTemplate.name); return true; }, isContentValid() { - if (this.showValidation) return Boolean(this.updateSavedReply.content); + if (this.showValidation) return Boolean(this.updateCommentTemplate.content); return true; }, @@ -73,15 +73,15 @@ export default { mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation, variables: { id: this.id, - name: this.updateSavedReply.name, - content: this.updateSavedReply.content, + name: this.updateCommentTemplate.name, + content: this.updateCommentTemplate.content, }, update: (store, { data: { savedReplyMutation } }) => { if (savedReplyMutation.errors.length) { this.errors = savedReplyMutation.errors.map((e) => e); } else { this.$emit('saved'); - this.updateSavedReply = { name: '', content: '' }; + this.updateCommentTemplate = { name: '', content: '' }; this.showValidation = false; } }, @@ -112,8 +112,8 @@ export default { <template> <gl-form - class="new-note common-note-form" - data-testid="saved-reply-form" + class="new-note common-note-form gl-mb-6" + data-testid="comment-template-form" @submit.prevent="onSubmit" > <gl-alert @@ -128,26 +128,26 @@ export default { <gl-form-group :label="__('Name')" :state="isNameValid" - :invalid-feedback="__('Please enter a name for the saved reply.')" - data-testid="saved-reply-name-form-group" + :invalid-feedback="__('Please enter a name for the comment template.')" + data-testid="comment-template-name-form-group" > <gl-form-input - v-model="updateSavedReply.name" - :placeholder="__('Enter a name for your saved reply')" - data-testid="saved-reply-name-input" + v-model="updateCommentTemplate.name" + :placeholder="__('Enter a name for your comment template')" + data-testid="comment-template-name-input" /> </gl-form-group> <gl-form-group :label="__('Content')" :state="isContentValid" - :invalid-feedback="__('Please enter the saved reply content.')" - data-testid="saved-reply-content-form-group" + :invalid-feedback="__('Please enter the comment template content.')" + data-testid="comment-template-content-form-group" > <markdown-field :enable-preview="false" :is-submitting="saving" :add-spacing-classes="false" - :textarea-value="updateSavedReply.content" + :textarea-value="updateCommentTemplate.content" :markdown-docs-path="$options.markdownDocsPath" :restricted-tool-bar-items="$options.restrictedToolbarItems" :force-autosize="false" @@ -155,13 +155,13 @@ export default { > <template #textarea> <textarea - v-model="updateSavedReply.content" + v-model="updateCommentTemplate.content" dir="auto" class="note-textarea js-gfm-input js-autosize markdown-area" data-supports-quick-actions="false" :aria-label="__('Content')" - :placeholder="__('Write saved reply content here…')" - data-testid="saved-reply-content-input" + :placeholder="__('Write comment template content here…')" + data-testid="comment-template-content-input" @keydown.meta.enter="onSubmit" @keydown.ctrl.enter="onSubmit" ></textarea> @@ -173,7 +173,7 @@ export default { class="gl-mr-3 js-no-auto-disable" type="submit" :loading="saving" - data-testid="saved-reply-form-submit-btn" + data-testid="comment-template-form-submit-btn" > {{ __('Save') }} </gl-button> diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue index dbe326d429a..52bebfd050c 100644 --- a/app/assets/javascripts/saved_replies/components/list.vue +++ b/app/assets/javascripts/comment_templates/components/list.vue @@ -44,16 +44,16 @@ export default { </script> <template> - <div> + <div class="gl-border-t gl-pt-4"> <gl-loading-icon v-if="loading" size="lg" /> <template v-else> <h5 class="gl-font-lg" data-testid="title"> - <gl-sprintf :message="__('My saved replies (%{count})')"> + <gl-sprintf :message="__('My comment templates (%{count})')"> <template #count>{{ count }}</template> </gl-sprintf> </h5> <ul class="gl-list-style-none gl-p-0 gl-m-0"> - <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" /> + <list-item v-for="template in savedReplies" :key="template.id" :template="template" /> </ul> <gl-keyset-pagination v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue new file mode 100644 index 00000000000..d763700db42 --- /dev/null +++ b/app/assets/javascripts/comment_templates/components/list_item.vue @@ -0,0 +1,116 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlDisclosureDropdown, GlTooltip, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql'; + +export default { + components: { + GlDisclosureDropdown, + GlTooltip, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + template: { + type: Object, + required: true, + }, + }, + data() { + return { + isDeleting: false, + modalId: uniqueId('delete-comment-template-'), + toggleId: uniqueId('actions-toggle-'), + }; + }, + computed: { + id() { + return getIdFromGraphQLId(this.template.id); + }, + dropdownItems() { + return [ + { + text: __('Edit'), + action: () => this.$router.push({ name: 'edit', params: { id: this.id } }), + extraAttrs: { + 'data-testid': 'comment-template-edit-btn', + }, + }, + { + text: __('Delete'), + action: () => this.$refs['delete-modal'].show(), + extraAttrs: { + 'data-testid': 'comment-template-delete-btn', + class: 'gl-text-red-500!', + }, + }, + ]; + }, + }, + methods: { + onDelete() { + this.isDeleting = true; + + this.$apollo.mutate({ + mutation: deleteSavedReplyMutation, + variables: { + id: this.template.id, + }, + update: (cache) => { + const cacheId = cache.identify(this.template); + cache.evict({ id: cacheId }); + }, + }); + }, + }, + actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } }, + actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } }, +}; +</script> + +<template> + <li class="gl-pt-4 gl-pb-5 gl-border-b"> + <div class="gl-display-flex gl-align-items-center"> + <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6> + <div class="gl-ml-auto"> + <gl-disclosure-dropdown + :items="dropdownItems" + :toggle-id="toggleId" + icon="ellipsis_v" + no-caret + text-sr-only + placement="right" + :toggle-text="__('Comment template actions')" + :loading="isDeleting" + category="tertiary" + /> + <gl-tooltip :target="toggleId"> + {{ __('Comment template actions') }} + </gl-tooltip> + </div> + </div> + <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div> + <gl-modal + ref="delete-modal" + :title="__('Delete comment template')" + :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>{{ template.name }}</strong></template + > + </gl-sprintf> + </gl-modal> + </li> +</template> diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/comment_templates/index.js index 5022ff62b10..8cd763e7a9e 100644 --- a/app/assets/javascripts/saved_replies/index.js +++ b/app/assets/javascripts/comment_templates/index.js @@ -5,11 +5,11 @@ import createDefaultClient from '~/lib/graphql'; import routes from './routes'; import App from './components/app.vue'; -export const initSavedReplies = () => { +export const initCommentTemplates = () => { Vue.use(VueApollo); Vue.use(VueRouter); - const el = document.getElementById('js-saved-replies-root'); + const el = document.getElementById('js-comment-templates-root'); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/saved_replies/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue index 94215389844..343efdccefa 100644 --- a/app/assets/javascripts/saved_replies/pages/edit.vue +++ b/app/assets/javascripts/comment_templates/pages/edit.vue @@ -32,7 +32,7 @@ export default { }, }) { if (!savedReply) { - createAlert({ message: __('Unable to find saved reply') }); + createAlert({ message: __('Unable to find comment template') }); this.redirectToRoot(); } }, @@ -54,7 +54,7 @@ export default { <template> <div> <h5 class="gl-mt-0 gl-font-lg"> - {{ __('Edit saved reply') }} + {{ __('Edit comment template') }} </h5> <gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" /> <create-form diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue index 3e96fc0714e..72a94dafc58 100644 --- a/app/assets/javascripts/saved_replies/pages/index.vue +++ b/app/assets/javascripts/comment_templates/pages/index.vue @@ -53,7 +53,7 @@ export default { <template> <div> <h5 class="gl-mt-0 gl-font-lg"> - {{ __('Add new saved reply') }} + {{ __('Add new comment template') }} </h5> <create-form @saved="refetchSavedReplies" /> <list diff --git a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql index c4e632d0f16..c4e632d0f16 100644 --- a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql +++ b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql diff --git a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql index 76571ba628c..76571ba628c 100644 --- a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql +++ b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql diff --git a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql index 66f5f43af49..66f5f43af49 100644 --- a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql +++ b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql index d8e76b5e2a8..d8e76b5e2a8 100644 --- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql +++ b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql diff --git a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql index 14a47d7bc9c..14a47d7bc9c 100644 --- a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql +++ b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/comment_templates/routes.js index 7687c6f335a..7687c6f335a 100644 --- a/app/assets/javascripts/saved_replies/routes.js +++ b/app/assets/javascripts/comment_templates/routes.js diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index e5e23f2fb5e..17c9f55a8a0 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -1,10 +1,6 @@ import $ from 'jquery'; // bootstrap jQuery plugins -import 'bootstrap/js/dist/alert'; -import 'bootstrap/js/dist/button'; -import 'bootstrap/js/dist/collapse'; -import 'bootstrap/js/dist/modal'; import 'bootstrap/js/dist/dropdown'; import 'bootstrap/js/dist/tab'; diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js index defc2cbe276..f43a2d5d8ff 100644 --- a/app/assets/javascripts/constants.js +++ b/app/assets/javascripts/constants.js @@ -1,6 +1,5 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - export const getModifierKey = (removeSuffix = false) => { + // eslint-disable-next-line @gitlab/require-i18n-strings const winKey = `Ctrl${removeSuffix ? '' : '+'}`; return window.gl?.client?.isMac ? '⌘' : winKey; }; diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue index 06b80a65528..cef446c4cf8 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue @@ -35,9 +35,6 @@ export default { ); }, }, - toggleLinkCommandParams: { - href: '', - }, }; </script> <template> @@ -122,8 +119,7 @@ export default { data-testid="link" content-type="link" icon-name="link" - editor-command="toggleLink" - :editor-command-params="$options.toggleLinkCommandParams" + editor-command="editLink" category="tertiary" size="medium" :label="__('Insert link')" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue index a4713eb3275..a3065be3772 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue @@ -8,6 +8,7 @@ import { GlButtonGroup, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; +import { getMarkType, getMarkRange } from '@tiptap/core'; import Link from '../../extensions/link'; import EditorStateObserver from '../editor_state_observer.vue'; import BubbleMenu from './bubble_menu.vue'; @@ -31,12 +32,36 @@ export default { return { linkHref: undefined, linkCanonicalSrc: undefined, - linkTitle: undefined, + linkText: undefined, isEditing: false, }; }, methods: { + linkIsEmpty() { + return ( + !this.linkCanonicalSrc && + !this.linkHref && + (!this.linkText || this.linkText === this.linkTextInDoc()) + ); + }, + + linkTextInDoc() { + const { state } = this.tiptapEditor; + const type = getMarkType(Link.name, state.schema); + let { selection: range } = state; + if (range.from === range.to) { + range = + getMarkRange(state.selection.$from, type) || + getMarkRange(state.selection.$to, type) || + {}; + } + + if (!range.from || !range.to) return ''; + + return state.doc.textBetween(range.from, range.to, ' '); + }, + shouldShow() { return this.tiptapEditor.isActive(Link.name); }, @@ -52,31 +77,51 @@ export default { this.isEditing = false; this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc); - - if (!this.linkCanonicalSrc && !this.linkHref) { - this.removeLink(); - } }, cancelEditingLink() { this.endEditingLink(); - this.updateLinkToState(); + + if (this.linkIsEmpty()) { + this.removeLink(); + } else { + this.updateLinkToState(); + } }, async saveEditedLink() { - if (!this.linkCanonicalSrc) { + const chain = this.tiptapEditor.chain().focus(); + + const attrs = { + href: this.linkCanonicalSrc, + canonicalSrc: this.linkCanonicalSrc, + }; + + // if nothing was entered by the user and the link is empty, remove it + // since we don't want to insert an empty link + if (this.linkIsEmpty()) { this.removeLink(); - } else { - this.tiptapEditor - .chain() - .focus() + return; + } + + if (!this.linkText) { + this.linkText = this.linkCanonicalSrc; + } + + // if link text was updated, insert a new link in the doc with the new text + if (this.linkTextInDoc() !== this.linkText) { + chain .extendMarkRange(Link.name) - .updateAttributes(Link.name, { - href: this.linkCanonicalSrc, - canonicalSrc: this.linkCanonicalSrc, - title: this.linkTitle, + .setMeta('preventAutolink', true) + .insertContent({ + marks: [{ type: Link.name, attrs }], + type: 'text', + text: this.linkText, }) .run(); + } else { + // if link text was not updated, just update the attributes + chain.updateAttributes(Link.name, attrs).run(); } this.endEditingLink(); @@ -84,22 +129,27 @@ export default { updateLinkToState() { const editor = this.tiptapEditor; - - const { href, title, canonicalSrc } = editor.getAttributes(Link.name); + const { href, canonicalSrc } = editor.getAttributes(Link.name); + const text = this.linkTextInDoc(); if ( canonicalSrc === this.linkCanonicalSrc && href === this.linkHref && - title === this.linkTitle + text === this.linkText ) { return; } - this.linkTitle = title; + this.linkText = text; this.linkHref = href; this.linkCanonicalSrc = canonicalSrc || href; + }, - this.isEditing = !this.linkCanonicalSrc; + onTransaction({ transaction }) { + this.linkText = this.linkTextInDoc(); + if (transaction.getMeta('creatingLink')) { + this.isEditing = true; + } }, copyLinkHref() { @@ -107,31 +157,49 @@ export default { }, removeLink() { - this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run(); + const chain = this.tiptapEditor.chain().focus(); + if (this.linkTextInDoc()) { + chain.unsetLink().run(); + } else { + chain + .insertContent({ + type: 'text', + text: ' ', + }) + .extendMarkRange(Link.name) + .unsetLink() + .deleteSelection() + .run(); + } }, resetBubbleMenuState() { - this.linkTitle = undefined; + this.linkText = undefined; this.linkHref = undefined; this.linkCanonicalSrc = undefined; }, }, tippyOptions: { placement: 'bottom', + appendTo: () => document.body, }, }; </script> <template> - <bubble-menu - data-testid="link-bubble-menu" - class="gl-shadow gl-rounded-base gl-bg-white" - plugin-key="bubbleMenuLink" - :should-show="shouldShow" - :tippy-options="$options.tippyOptions" - @show="updateLinkToState" - @hidden="resetBubbleMenuState" + <editor-state-observer + :debounce="0" + @transaction="onTransaction" + @selectionUpdate="updateLinkToState" > - <editor-state-observer @selectionUpdate="updateLinkToState"> + <bubble-menu + data-testid="link-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + plugin-key="bubbleMenuLink" + :should-show="shouldShow" + :tippy-options="$options.tippyOptions" + @show="updateLinkToState" + @hidden="resetBubbleMenuState" + > <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> <gl-link v-gl-tooltip @@ -178,12 +246,12 @@ export default { /> </gl-button-group> <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink"> + <gl-form-group :label="__('Text')" label-for="link-text"> + <gl-form-input id="link-text" v-model="linkText" data-testid="link-text" /> + </gl-form-group> <gl-form-group :label="__('URL')" label-for="link-href"> <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" /> </gl-form-group> - <gl-form-group :label="__('Title')" label-for="link-title"> - <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" /> - </gl-form-group> <div class="gl-display-flex gl-justify-content-end"> <gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink"> {{ __('Cancel') }} @@ -193,6 +261,6 @@ export default { </gl-button> </div> </gl-form> - </editor-state-observer> - </bubble-menu> + </bubble-menu> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 9e08a257abf..f9d48708473 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -82,6 +82,16 @@ export default { required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -109,6 +119,8 @@ export default { autofocus, drawioEnabled, editable, + enableAutocomplete, + autocompleteDataSources, } = this; // This is a non-reactive attribute intentionally since this is a complex object. @@ -118,6 +130,8 @@ export default { extensions, serializerConfig, drawioEnabled, + enableAutocomplete, + autocompleteDataSources, tiptapOptions: { autofocus, editable, diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue index ccb46e3b593..62f2113a8f4 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -16,14 +16,21 @@ const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEv export default { inject: ['tiptapEditor', 'eventHub'], + props: { + debounce: { + type: Number, + required: false, + default: 100, + }, + }, created() { this.disposables = []; Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => { - const eventHandler = debounce( - (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params), - 100, - ); + let eventHandler = (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params); + if (this.debounce) { + eventHandler = debounce(eventHandler, this.debounce); + } this.tiptapEditor?.on(tiptapEvent, eventHandler); diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index a5be63fa89f..cd9fdeeca46 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -1,22 +1,17 @@ <script> -import { GlTabs, GlTab } from '@gitlab/ui'; import trackUIControl from '../services/track_ui_control'; import ToolbarButton from './toolbar_button.vue'; -import ToolbarImageButton from './toolbar_image_button.vue'; -import ToolbarLinkButton from './toolbar_link_button.vue'; +import ToolbarAttachmentButton from './toolbar_attachment_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; import ToolbarMoreDropdown from './toolbar_more_dropdown.vue'; export default { components: { - GlTabs, - GlTab, ToolbarButton, ToolbarTextStyleDropdown, - ToolbarLinkButton, ToolbarTableButton, - ToolbarImageButton, + ToolbarAttachmentButton, ToolbarMoreDropdown, }, methods: { @@ -27,84 +22,87 @@ export default { }; </script> <template> - <gl-tabs content-class="gl-display-none"> - <gl-tab title-link-class="gl-py-4 gl-px-3" :title="__('Write')" /> - <template #tabs-end> - <div class="gl-ml-auto gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end"> - <toolbar-text-style-dropdown - data-testid="text-styles" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bold" - content-type="bold" - icon-name="bold" - editor-command="toggleBold" - :label="__('Bold text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="italic" - content-type="italic" - icon-name="italic" - editor-command="toggleItalic" - :label="__('Italic text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="blockquote" - content-type="blockquote" - icon-name="quote" - editor-command="toggleBlockquote" - :label="__('Insert a quote')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="code" - content-type="code" - icon-name="code" - editor-command="toggleCode" - :label="__('Code')" - @execute="trackToolbarControlExecution" - /> - <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" /> - <toolbar-button - data-testid="bullet-list" - content-type="bulletList" - icon-name="list-bulleted" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleBulletList" - :label="__('Add a bullet list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="ordered-list" - content-type="orderedList" - icon-name="list-numbered" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleOrderedList" - :label="__('Add a numbered list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="task-list" - content-type="taskList" - icon-name="list-task" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleTaskList" - :label="__('Add a checklist')" - @execute="trackToolbarControlExecution" - /> - <toolbar-image-button - ref="imageButton" - data-testid="image" - @execute="trackToolbarControlExecution" - /> - <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> - <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> - </div> - </template> - </gl-tabs> + <div + class="gl-w-full gl-border-b gl-display-flex gl-justify-content-end" + data-testid="formatting-toolbar" + > + <div class="gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end"> + <toolbar-text-style-dropdown + data-testid="text-styles" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + :label="__('Bold text')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + :label="__('Italic text')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" + editor-command="toggleBlockquote" + :label="__('Insert a quote')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + :label="__('Code')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="editLink" + :label="__('Insert link')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bullet-list" + content-type="bulletList" + icon-name="list-bulleted" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleBulletList" + :label="__('Add a bullet list')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="ordered-list" + content-type="orderedList" + icon-name="list-numbered" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleOrderedList" + :label="__('Add a numbered list')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="task-list" + content-type="taskList" + icon-name="list-task" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleTaskList" + :label="__('Add a checklist')" + @execute="trackToolbarControlExecution" + /> + <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <toolbar-attachment-button data-testid="attachment" @execute="trackToolbarControlExecution" /> + <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> + </div> + </div> </template> <style> .gl-spinner-container { diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 37e6ef61d50..4074e50a706 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -1,10 +1,11 @@ <script> -import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; export default { components: { GlDropdownItem, GlAvatarLabeled, + GlLoadingIcon, }, props: { @@ -32,6 +33,12 @@ export default { type: Function, required: true, }, + + loading: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -208,65 +215,75 @@ export default { </script> <template> - <ul - :class="{ show: items.length > 0 }" - class="gl-dropdown dropdown-menu gl-relative" - data-testid="content-editor-suggestions-dropdown" - > - <div class="gl-dropdown-inner gl-overflow-y-auto"> - <gl-dropdown-item - v-for="(item, index) in items" - ref="dropdownItems" - :key="index" - :class="{ 'gl-bg-gray-50': index === selectedIndex }" - @click="selectItem(index)" - > - <gl-avatar-labeled - v-if="isUser" - :label="item.username" - :sub-label="avatarSubLabel(item)" - :src="item.avatar_url" - :entity-name="item.username" - :shape="item.type === 'Group' ? 'rect' : 'circle'" - :size="32" - /> - <span v-if="isIssue || isMergeRequest"> - <small>{{ item.iid }}</small> - {{ item.title }} - </span> - <span v-if="isVulnerability || isSnippet"> - <small>{{ item.id }}</small> - {{ item.title }} - </span> - <span v-if="isEpic"> - <small>{{ item.reference }}</small> - {{ item.title }} - </span> - <span v-if="isMilestone"> - {{ item.title }} - </span> - <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> - <span - data-testid="label-color-box" - class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" - :style="{ backgroundColor: item.color }" - ></span> - {{ item.title }} - </span> - <span v-if="isCommand"> - /{{ item.name }} <small> {{ item.params[0] }} </small><br /> - <em> - <small> {{ item.description }} </small> - </em> - </span> - <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> - <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> - <div class="gl-flex-grow-1"> - {{ item.name }}<br /> - <small>{{ item.d }}</small> + <div> + <ul + v-if="!loading" + :class="{ show: items.length > 0 }" + class="gl-dropdown dropdown-menu gl-relative gl-m-0!" + data-testid="content-editor-suggestions-dropdown" + > + <div class="gl-dropdown-inner gl-overflow-y-auto"> + <gl-dropdown-item + v-for="(item, index) in items" + ref="dropdownItems" + :key="index" + :class="{ 'gl-bg-gray-50': index === selectedIndex }" + @click="selectItem(index)" + > + <gl-avatar-labeled + v-if="isUser" + :label="item.username" + :sub-label="avatarSubLabel(item)" + :src="item.avatar_url" + :entity-name="item.username" + :shape="item.type === 'Group' ? 'rect' : 'circle'" + :size="32" + /> + <span v-if="isIssue || isMergeRequest"> + <small>{{ item.iid }}</small> + {{ item.title }} + </span> + <span v-if="isVulnerability || isSnippet"> + <small>{{ item.id }}</small> + {{ item.title }} + </span> + <span v-if="isEpic"> + <small>{{ item.reference }}</small> + {{ item.title }} + </span> + <span v-if="isMilestone"> + {{ item.title }} + </span> + <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> + <span + data-testid="label-color-box" + class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" + :style="{ backgroundColor: item.color }" + ></span> + {{ item.title }} + </span> + <span v-if="isCommand"> + /{{ item.name }} <small> {{ item.params[0] }} </small><br /> + <em> + <small> {{ item.description }} </small> + </em> + </span> + <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> + <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> + <div class="gl-flex-grow-1"> + {{ item.name }}<br /> + <small>{{ item.d }}</small> + </div> </div> + </gl-dropdown-item> + </div> + </ul> + <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!"> + <div class="gl-dropdown-inner gl-overflow-y-auto"> + <div class="gl-px-5"> + <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }} </div> - </gl-dropdown-item> + </div> </div> - </ul> + </div> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue new file mode 100644 index 00000000000..efb9a5b07b5 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -0,0 +1,61 @@ +<script> +import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import Link from '../extensions/link'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor'], + data() { + return { + linkHref: '', + }; + }, + methods: { + emitExecute(source = 'url') { + this.$emit('execute', { contentType: Link.name, value: source }); + }, + openFileUpload() { + this.$refs.fileSelector.click(); + }, + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .uploadAttachment({ + file: e.target.files[0], + }) + .run(); + + // Reset the file input so that the same file can be uploaded again + this.$refs.fileSelector.value = ''; + this.emitExecute('upload'); + }, + }, +}; +</script> +<template> + <span class="gl-display-inline-flex"> + <gl-button + v-gl-tooltip + :text="__('Attach a file or image')" + :title="__('Attach a file or image')" + category="tertiary" + icon="paperclip" + lazy + @click="openFileUpload" + /> + <input + ref="fileSelector" + type="file" + name="content_editor_image" + class="gl-display-none" + data-qa-selector="file_upload_field" + @change="onFileSelect" + /> + </span> +</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue deleted file mode 100644 index 8ed4dfce6de..00000000000 --- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue +++ /dev/null @@ -1,109 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownForm, - GlButton, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; -import { acceptedMimes } from '../services/upload_helpers'; -import { extractFilename } from '../services/utils'; - -export default { - components: { - GlDropdown, - GlDropdownForm, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlButton, - }, - directives: { - GlTooltip, - }, - inject: ['tiptapEditor'], - data() { - return { - imgSrc: '', - }; - }, - methods: { - resetFields() { - this.imgSrc = ''; - this.$refs.fileSelector.value = ''; - }, - insertImage() { - this.tiptapEditor - .chain() - .focus() - .setImage({ - src: this.imgSrc, - canonicalSrc: this.imgSrc, - alt: extractFilename(this.imgSrc), - }) - .run(); - - this.resetFields(); - this.emitExecute(); - }, - emitExecute(source = 'url') { - this.$emit('execute', { contentType: 'image', value: source }); - }, - openFileUpload() { - this.$refs.fileSelector.click(); - }, - onFileSelect(e) { - this.tiptapEditor - .chain() - .focus() - .uploadAttachment({ - file: e.target.files[0], - }) - .run(); - - this.resetFields(); - this.emitExecute('upload'); - }, - }, - acceptedMimes: acceptedMimes.image, -}; -</script> -<template> - <span class="gl-display-inline-flex"> - <gl-dropdown - v-gl-tooltip - :text="__('Insert image')" - :title="__('Insert image')" - size="small" - category="tertiary" - icon="media" - lazy - text-sr-only - data-testid="insert-image-toolbar-button" - @hidden="resetFields()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')"> - <template #append> - <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item @click="openFileUpload"> - {{ __('Upload image') }} - </gl-dropdown-item> - </gl-dropdown> - <input - ref="fileSelector" - type="file" - name="content_editor_image" - :accept="$options.acceptedMimes" - class="gl-display-none" - data-qa-selector="file_upload_field" - @change="onFileSelect" - /> - </span> -</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue deleted file mode 100644 index 4fb1e8ce16f..00000000000 --- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue +++ /dev/null @@ -1,129 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownForm, - GlButton, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; -import Link from '../extensions/link'; -import { hasSelection } from '../services/utils'; -import EditorStateObserver from './editor_state_observer.vue'; - -export default { - components: { - GlDropdown, - GlDropdownForm, - GlFormInputGroup, - GlDropdownDivider, - GlDropdownItem, - GlButton, - EditorStateObserver, - }, - directives: { - GlTooltip, - }, - inject: ['tiptapEditor'], - data() { - return { - linkHref: '', - isActive: false, - }; - }, - methods: { - resetFields() { - this.imgSrc = ''; - this.$refs.fileSelector.value = ''; - }, - openFileUpload() { - this.$refs.fileSelector.click(); - }, - updateLinkState({ editor }) { - const { canonicalSrc, href } = editor.getAttributes(Link.name); - - this.isActive = editor.isActive(Link.name); - this.linkHref = canonicalSrc || href; - }, - updateLink() { - this.tiptapEditor - .chain() - .focus() - .unsetLink() - .setLink({ - href: this.linkHref, - canonicalSrc: this.linkHref, - }) - .run(); - - this.$emit('execute', { contentType: Link.name }); - }, - selectLink() { - const { tiptapEditor } = this; - - // a selection has already been made by the user, so do nothing - if (!hasSelection(tiptapEditor)) { - tiptapEditor.chain().focus().extendMarkRange(Link.name).run(); - } - }, - removeLink() { - this.tiptapEditor.chain().focus().unsetLink().run(); - - this.$emit('execute', { contentType: Link.name }); - }, - onFileSelect(e) { - this.tiptapEditor - .chain() - .focus() - .uploadAttachment({ - file: e.target.files[0], - }) - .run(); - - this.resetFields(); - this.$emit('execute', { contentType: Link.name }); - }, - }, -}; -</script> -<template> - <editor-state-observer @transaction="updateLinkState"> - <span class="gl-display-inline-flex"> - <gl-dropdown - v-gl-tooltip - :title="__('Insert link')" - :text="__('Insert link')" - :toggle-class="{ active: isActive }" - size="small" - category="tertiary" - icon="link" - text-sr-only - lazy - @show="selectLink()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> - <template #append> - <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item v-if="isActive" @click="removeLink"> - {{ __('Remove link') }} - </gl-dropdown-item> - <gl-dropdown-item v-else @click="openFileUpload"> - {{ __('Upload file') }} - </gl-dropdown-item> - </gl-dropdown> - <input - ref="fileSelector" - type="file" - name="content_editor_attachment" - class="gl-display-none" - @change="onFileSelect" - /> - </span> - </editor-state-observer> -</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index 4b1929e1a20..bf2740f9864 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -83,7 +83,7 @@ export default { text-sr-only lazy > - <gl-dropdown-form class="gl-px-3!"> + <gl-dropdown-form class="gl-px-3! gl-pb-2!"> <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> <gl-button v-for="c of list(maxCols)" diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index 81f9b1f0af5..55cf38dfcbb 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -80,7 +80,7 @@ export default { <template> <editor-state-observer @transaction="updateDiagramPreview"> <node-view-wrapper - :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`" + :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`" as="pre" > <div diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue new file mode 100644 index 00000000000..4126c65d87f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue @@ -0,0 +1,45 @@ +<script> +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { GlLink } from '@gitlab/ui'; + +export default { + name: 'DetailsWrapper', + components: { + NodeViewWrapper, + GlLink, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + computed: { + text() { + return this.node.attrs.text; + }, + isCommand() { + return this.node.attrs.referenceType === 'command'; + }, + isMember() { + return this.node.attrs.referenceType === 'user'; + }, + isCurrentUser() { + return gon.current_username === this.text.substring(1); + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <span v-if="isCommand">{{ text }}</span> + <gl-link + v-else + href="#" + class="gfm" + :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }" + @click.prevent.stop + >{{ text }}</gl-link + > + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue index 4206c866032..4206c866032 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/label.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index 6a3740a5952..490025a9ac6 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -12,6 +12,11 @@ export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule'; export const TEXT_STYLE_DROPDOWN_ITEMS = [ { + contentType: 'paragraph', + editorCommand: 'setParagraph', + label: __('Normal text'), + }, + { contentType: 'heading', commandParams: { level: 1 }, editorCommand: 'setHeading', @@ -35,11 +40,6 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [ commandParams: { level: 4 }, label: __('Heading 4'), }, - { - contentType: 'paragraph', - editorCommand: 'setParagraph', - label: __('Normal text'), - }, ]; export const ALERT_EVENT = 'alert'; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index e985e561fda..314d5230b01 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -18,6 +18,8 @@ export const extractHrefFromMarkdownLink = (match) => { }; export default Link.extend({ + inclusive: false, + addOptions() { return { ...this.parent?.(), @@ -64,4 +66,18 @@ export default Link.extend({ }, }; }, + addCommands() { + return { + ...this.parent?.(), + editLink: (attrs) => ({ chain }) => { + chain().setMeta('creatingLink', true).setLink(attrs).run(); + }, + }; + }, + + addKeyboardShortcuts() { + return { + 'Mod-k': () => this.editor.commands.editLink(), + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index 0a9a0d8d4c1..82fa5ce6c1d 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -37,8 +37,18 @@ export default Extension.create({ const { state, view } = editor; const { tr, selection } = state; + const { firstChild } = document.content; + const content = + document.content.childCount === 1 && firstChild.type.name === 'paragraph' + ? firstChild.content + : document.content; + + if (selection.to - selection.from > 0) { + tr.replaceWith(selection.from, selection.to, content); + } else { + tr.insert(selection.from, content); + } - tr.replaceWith(selection.from - 1, selection.to, document.content); view.dispatch(tr); }) .catch(() => { @@ -53,13 +63,29 @@ export default Extension.create({ }; }, addProseMirrorPlugins() { + let pasteRaw = false; + return [ new Plugin({ key: new PluginKey('pasteMarkdown'), props: { - handlePaste: (_, event) => { + handleKeyDown: (_, event) => { + pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey; + }, + + handlePaste: (view, event) => { const { clipboardData } = event; const content = clipboardData.getData(TEXT_FORMAT); + const { state } = view; + const { tr, selection } = state; + const { from, to } = selection; + + if (pasteRaw) { + tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to); + view.dispatch(tr); + return true; + } + const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT); const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT); const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index ed343d8acf8..01ffc217894 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,5 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import { Node } from '@tiptap/core'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -44,7 +42,7 @@ export default Node.create({ parseHTML() { return [ { - tag: `.${this.options.mediaType}-container`, + tag: `.${this.options.mediaType}-container`, // eslint-disable-line @gitlab/require-i18n-strings }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 707beaf1231..b56aa8596a0 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,6 @@ import { Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import ReferenceWrapper from '../components/wrappers/reference.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const getAnchor = (element) => { @@ -49,7 +51,7 @@ export default Node.create({ ]; }, - renderHTML({ node }) { - return ['a', { href: '#' }, node.attrs.text]; + addNodeView() { + return new VueNodeViewRenderer(ReferenceWrapper); }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js index 9dff0b7a689..0441f8ef8d2 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -1,6 +1,6 @@ import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; -import LabelWrapper from '../components/wrappers/label.vue'; +import LabelWrapper from '../components/wrappers/reference_label.vue'; import Reference from './reference'; export default Reference.extend({ diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index eb53a3a61b3..e72b5c7365c 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -57,14 +57,25 @@ function createSuggestionPlugin({ let component; let popup; + const onUpdate = (props) => { + component?.updateProps({ ...props, loading: false }); + + if (!props.clientRect) return; + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }; + return { - onStart: (props) => { + onBeforeStart: (props) => { component = new VueRenderer(SuggestionsDropdown, { propsData: { ...props, char, nodeType, nodeProps, + loading: true, }, editor: props.editor, }); @@ -84,17 +95,8 @@ function createSuggestionPlugin({ }); }, - onUpdate(props) { - component?.updateProps(props); - - if (!props.clientRect) { - return; - } - - popup?.[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, + onStart: onUpdate, + onUpdate, onKeyDown(props) { if (props.event.key === 'Escape') { @@ -118,12 +120,18 @@ function createSuggestionPlugin({ export default Node.create({ name: 'suggestions', + addOptions() { + return { + autocompleteDataSources: {}, + }; + }, + addProseMirrorPlugins() { return [ createSuggestionPlugin({ editor: this.editor, char: '@', - dataSource: gl.GfmAutoComplete?.dataSources.members, + dataSource: this.options.autocompleteDataSources.members, nodeType: 'reference', nodeProps: { referenceType: 'user', @@ -133,7 +141,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '#', - dataSource: gl.GfmAutoComplete?.dataSources.issues, + dataSource: this.options.autocompleteDataSources.issues, nodeType: 'reference', nodeProps: { referenceType: 'issue', @@ -143,7 +151,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '$', - dataSource: gl.GfmAutoComplete?.dataSources.snippets, + dataSource: this.options.autocompleteDataSources.snippets, nodeType: 'reference', nodeProps: { referenceType: 'snippet', @@ -153,7 +161,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '~', - dataSource: gl.GfmAutoComplete?.dataSources.labels, + dataSource: this.options.autocompleteDataSources.labels, nodeType: 'reference_label', nodeProps: { referenceType: 'label', @@ -163,7 +171,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '&', - dataSource: gl.GfmAutoComplete?.dataSources.epics, + dataSource: this.options.autocompleteDataSources.epics, nodeType: 'reference', nodeProps: { referenceType: 'epic', @@ -173,7 +181,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '[vulnerability:', - dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities, + dataSource: this.options.autocompleteDataSources.vulnerabilities, nodeType: 'reference', nodeProps: { referenceType: 'vulnerability', @@ -183,7 +191,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '!', - dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + dataSource: this.options.autocompleteDataSources.mergeRequests, nodeType: 'reference', nodeProps: { referenceType: 'merge_request', @@ -193,7 +201,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '%', - dataSource: gl.GfmAutoComplete?.dataSources.milestones, + dataSource: this.options.autocompleteDataSources.milestones, nodeType: 'reference', nodeProps: { referenceType: 'milestone', @@ -203,7 +211,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '/', - dataSource: gl.GfmAutoComplete?.dataSources.commands, + dataSource: this.options.autocompleteDataSources.commands, nodeType: 'reference', nodeProps: { referenceType: 'command', diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 9d536793287..f1d4f85dcb0 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -88,6 +88,8 @@ export const createContentEditor = ({ serializerConfig = { marks: {}, nodes: {} }, tiptapOptions, drawioEnabled = false, + enableAutocomplete, + autocompleteDataSources = {}, } = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); @@ -144,7 +146,6 @@ export const createContentEditor = ({ Sourcemap, Strike, Subscript, - Suggestions, Superscript, TableCell, TableHeader, @@ -160,6 +161,7 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; + if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources })); if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown })); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index e27a427372c..9ff50b45088 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; +import Loading from '../extensions/loading'; import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; @@ -194,6 +195,7 @@ const defaultSerializerConfig = { inline: true, }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [Loading.name]: () => {}, [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: renderReference, @@ -227,6 +229,7 @@ const defaultSerializerConfig = { [TableRow.name]: renderTableRow, [TaskItem.name]: preserveUnchanged((state, node) => { state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + if (!node.textContent) state.write(' '); state.renderContent(node); }), [TaskList.name]: preserveUnchanged((state, node) => { diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index fe1b32c5b0a..11a11ed43bd 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -28,6 +28,8 @@ export const getMarkdownSource = (element) => { const range = getRangeFromSourcePos(element.dataset.sourcepos); let elSource = ''; + if (!source.length) return undefined; + for (let i = range.start.row; i <= range.end.row; i += 1) { if (i === range.start.row) { elSource += source[i].substring(range.start.col); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 540815f57c9..664473fccfe 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -309,12 +309,15 @@ export function renderHardBreak(state, node, parent, index) { export function renderImage(state, node) { const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; + let realSrc = canonicalSrc || src || ''; + // eslint-disable-next-line @gitlab/require-i18n-strings + if (realSrc.startsWith('data:')) realSrc = ''; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; const sourceExpression = isReference ? `[${canonicalSrc}]` - : `(${state.esc(canonicalSrc || src)}${quotedTitle})`; + : `(${state.esc(realSrc)}${quotedTitle})`; const sizeAttributes = []; if (width) { @@ -604,7 +607,7 @@ export const link = { return '['; } - const attrs = { href: state.esc(href || canonicalSrc) }; + const attrs = { href: state.esc(href || canonicalSrc || '') }; if (title) { attrs.title = title; @@ -620,14 +623,14 @@ export const link = { const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; if (isReference) { - return `][${state.esc(canonicalSrc || href)}]`; + return `][${state.esc(canonicalSrc || href || '')}]`; } if (linkType(sourceMarkdown) === LINK_HTML) { return closeTag('a'); } - return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`; + return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`; }, }; @@ -638,9 +641,8 @@ const generateStrikeTag = (wrapTagName = openTag) => { switch (type) { case '~~': return type; - /* eslint-disable @gitlab/require-i18n-strings */ - case '<del': - case '<strike': + case '<del': // eslint-disable-line @gitlab/require-i18n-strings + case '<strike': // eslint-disable-line @gitlab/require-i18n-strings case '<s': return wrapTagName(type.substring(1)); default: diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index c9097b9384f..94f27dbf048 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -131,7 +131,7 @@ export default { </dl> </div> </div> - <div class="table-section section-30 section-wrap"> + <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"> <template v-if="projects.length > 0"> @@ -168,7 +168,7 @@ export default { <span v-else class="text-secondary">{{ __('None') }}</span> </div> </div> - <div class="table-section section-15 text-right"> + <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"> <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)"> @@ -176,7 +176,23 @@ export default { </span> </div> </div> - <div class="table-section section-15 table-button-footer deploy-key-actions"> + <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"> + <span + v-if="deployKey.expires_at" + v-gl-tooltip + :title="tooltipTitle(deployKey.expires_at)" + data-testid="expires-at-tooltip" + > + <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span> + </span> + <span v-else> + <span data-testid="expires-never">{{ __('Never') }}</span> + </span> + </div> + </div> + <div class="table-section section-10 table-button-footer deploy-key-actions"> <div class="btn-group table-action-buttons"> <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary"> {{ __('Enable') }} diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 77ec1ef590f..e04cbbe72b9 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -34,10 +34,12 @@ export default { <div role="rowheader" class="table-section section-40"> {{ s__('DeployKeys|Deploy key') }} </div> - <div role="rowheader" class="table-section section-30"> + <div role="rowheader" class="table-section section-20"> {{ s__('DeployKeys|Project usage') }} </div> - <div role="rowheader" class="table-section section-15 text-right">{{ __('Created') }}</div> + <div role="rowheader" class="table-section section-15">{{ __('Created') }}</div> + <div role="rowheader" class="table-section section-15">{{ __('Expires') }}</div> + <!-- leave 10% space for actions ---> </div> <deploy-key v-for="deployKey in keys" diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js index 8ca4dc587a8..2cb9e9a56a3 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - import fuzzaldrinPlus from 'fuzzaldrin-plus'; import $ from 'jquery'; import { debounce } from 'lodash'; @@ -59,6 +57,7 @@ export class GitLabDropdownFilter { return BLUR_KEYCODES.indexOf(keyCode) !== -1; } + // eslint-disable-next-line consistent-return filter(searchText) { let group; let results; @@ -114,9 +113,10 @@ export class GitLabDropdownFilter { const matches = fuzzaldrinPlus.match($el.text().trim(), searchText); if (!$el.is('.dropdown-header')) { if (matches.length) { - return $el.show().removeClass('option-hidden'); + $el.show().removeClass('option-hidden'); + } else { + $el.hide().addClass('option-hidden'); } - return $el.hide().addClass('option-hidden'); } }); } else { diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 0008c3504ce..537c810bcff 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -1179,9 +1179,11 @@ export default class Notes { const form = textarea.parents('form'); const reopenbtn = form.find('.js-note-target-reopen'); const closebtn = form.find('.js-note-target-close'); + const savebtn = form.find('.js-comment-save-button'); const commentTypeComponent = form.get(0)?.commentTypeComponent; if (textarea.val().trim().length > 0) { + savebtn.enable(); reopentext = reopenbtn.attr('data-alternative-text'); closetext = closebtn.attr('data-alternative-text'); if (reopenbtn.text() !== reopentext) { @@ -1200,6 +1202,7 @@ export default class Notes { commentTypeComponent.disabled = false; } } else { + savebtn.disable(); reopentext = reopenbtn.data('originalText'); closetext = closebtn.data('originalText'); if (reopenbtn.text() !== reopentext) { @@ -1395,7 +1398,7 @@ export default class Notes { */ static isNewNote(noteEntity, note_ids) { if (note_ids.length === 0) { - Notes.loadNotesIds(note_ids); + note_ids = Notes.getNotesIds(); } const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1; if (isNewEntry) { @@ -1405,16 +1408,17 @@ export default class Notes { } /** - * Load notes ids + * Get notes ids */ - static loadNotesIds(note_ids) { - const $notesList = $('.main-notes-list li[id^=note_]'); - for (const $noteItem of $notesList) { - if (Notes.isNodeTypeElement($noteItem)) { - const noteId = parseInt($noteItem.id.split('_')[1], 10); - note_ids.push(noteId); - } - } + static getNotesIds() { + /** + * The selector covers following notes + * - notes and thread below the snippets and commit page + * - notes on the file of commit page + * - notes on an image file of commit page + */ + const notesList = [...document.querySelectorAll('.notes:not(.notes-form) li[id]')]; + return notesList.map((noteItem) => parseInt(noteItem.dataset.noteId, 10)); } /** diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 80b146c9209..12bb4b830f8 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -14,7 +14,7 @@ export default () => { issuePath, registerPath, signInPath, - savedRepliesNewPath, + newCommentTemplatePath, } = el.dataset; const router = createRouter(issuePath); @@ -39,7 +39,7 @@ export default () => { issueIid, registerPath, signInPath, - newSavedRepliesPath: savedRepliesNewPath, + newCommentTemplatePath, }, mounted() { performanceMarkAndMeasure({ diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 0251ffe28f9..2f2b2ed1a90 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -332,7 +332,7 @@ export default { <template> <div - class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <div class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index ab003fb2879..e270613e4eb 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -104,7 +104,7 @@ export default { return this.permissions.createDesign; }, showToolbar() { - return this.canCreateDesign && this.allVersions.length > 0; + return this.allVersions.length > 0; }, hasDesigns() { return this.designs.length > 0; @@ -375,6 +375,7 @@ export default { <design-version-dropdown /> </div> <div + v-if="canCreateDesign" v-show="hasDesigns" class="gl-display-flex gl-align-items-center" data-testid="design-selector-toolbar" @@ -489,7 +490,11 @@ export default { /> </li> <template #header> - <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> + <li + v-if="canCreateDesign" + :class="designDropzoneWrapperClass" + data-testid="design-dropzone-wrapper" + > <design-dropzone :enable-drag-behavior="isDraggingDesign" :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 9ef0f336d43..1ae7b6a2110 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -1,8 +1,7 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - import produce from 'immer'; import { differenceBy } from 'lodash'; import { createAlert } from '~/alert'; +import { TYPENAME_DISCUSSION, TYPENAME_TODO, TYPENAME_USER } from '~/graphql_shared/constants'; import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, @@ -60,7 +59,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = }); const newDiscussion = { - __typename: 'Discussion', + __typename: TYPENAME_DISCUSSION, id: createImageDiffNote.note.discussion.id, replyId: createImageDiffNote.note.discussion.replyId, resolvable: true, @@ -86,7 +85,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = design.issue.participants.nodes = [ ...design.issue.participants.nodes, { - __typename: 'User', + __typename: TYPENAME_USER, ...createImageDiffNote.note.author, }, ]; @@ -199,7 +198,7 @@ export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables) const data = produce(sourceData, (draftData) => { const design = extractDesign(draftData); const existingTodos = design.currentUserTodos?.nodes || []; - const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }]; + const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: TYPENAME_TODO }]; if (!design.currentUserTodos) { design.currentUserTodos = { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 9ccba88f7e6..9b3db78724d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -45,7 +45,9 @@ import { import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; +import { updateChangesTabCount } from '../utils/merge_request'; import { queueRedisHllEvents } from '../utils/queue_events'; +import FindingsDrawer from './shared/findings_drawer.vue'; import CollapsedFilesWarning from './collapsed_files_warning.vue'; import CommitWidget from './commit_widget.vue'; import CompareVersions from './compare_versions.vue'; @@ -59,6 +61,7 @@ import PreRenderer from './pre_renderer.vue'; export default { name: 'DiffsApp', components: { + FindingsDrawer, DynamicScroller, DynamicScrollerItem, PreRenderer, @@ -199,6 +202,7 @@ export default { numTotalFiles: 'realSize', numVisibleFiles: 'size', }), + ...mapState('findingsDrawer', ['activeDrawer']), ...mapState('diffs', [ 'showTreeList', 'isLoading', @@ -233,6 +237,7 @@ export default { 'flatBlobsList', ]), ...mapGetters(['isNotesFetched', 'getNoteableData']), + ...mapGetters('findingsDrawer', ['activeDrawer']), diffs() { if (!this.viewDiffsFileByFile) { return this.diffFiles; @@ -248,6 +253,9 @@ export default { renderDiffFiles() { return this.flatBlobsList.length > 0; }, + diffsIncomplete() { + return this.flatBlobsList.length !== this.diffFiles.length; + }, renderFileTree() { return this.renderDiffFiles && this.showTreeList; }, @@ -308,6 +316,11 @@ export default { diffViewType() { this.adjustView(); }, + viewDiffsFileByFile(newViewFileByFile) { + if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) { + this.refetchDiffData({ refetchMeta: false }); + } + }, shouldShow() { // When the shouldShow property changed to true, the route is rendered for the first time // and if we have the isLoading as true this means we didn't fetch the data @@ -337,8 +350,6 @@ export default { mrReviews: this.rehydratedMrReviews, }); - this.interfaceWithDOM(); - if (this.endpointCodequality) { this.setCodequalityEndpoint(this.endpointCodequality); } @@ -426,41 +437,48 @@ export default { 'setCodequalityEndpoint', 'fetchDiffFilesMeta', 'fetchDiffFilesBatch', + 'fetchFileByFile', 'fetchCoverageFiles', 'fetchCodequality', + 'rereadNoteHash', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', 'cacheTreeListWidth', - 'scrollToFile', + 'goToFile', 'setShowTreeList', 'navigateToDiffFileIndex', 'setFileByFile', 'disableVirtualScroller', ]), + ...mapActions('findingsDrawer', ['setDrawer']), + closeDrawer() { + this.setDrawer({}); + }, subscribeToEvents() { notesEventHub.$once('fetchDiffData', this.fetchData); notesEventHub.$on('refetchDiffData', this.refetchDiffData); + if (this.glFeatures.singleFileFileByFile) { + diffsEventHub.$on('diffFilesModified', this.setDiscussions); + notesEventHub.$on('fetchedNotesData', this.rereadNoteHash); + } }, unsubscribeFromEvents() { + if (this.glFeatures.singleFileFileByFile) { + notesEventHub.$off('fetchedNotesData', this.rereadNoteHash); + diffsEventHub.$off('diffFilesModified', this.setDiscussions); + } notesEventHub.$off('refetchDiffData', this.refetchDiffData); notesEventHub.$off('fetchDiffData', this.fetchData); }, - interfaceWithDOM() { - this.diffsTab = document.querySelector('.js-diffs-tab'); - }, - updateChangesTabCount() { - const badge = this.diffsTab.querySelector('.gl-badge'); - - if (this.diffsTab && badge) { - badge.textContent = this.diffFilesLength; - } - }, navigateToDiffFileNumber(number) { - this.navigateToDiffFileIndex(number - 1); + this.navigateToDiffFileIndex({ + index: number - 1, + singleFile: this.glFeatures.singleFileFileByFile, + }); }, - refetchDiffData() { - this.fetchData(false); + refetchDiffData({ refetchMeta = true } = {}) { + this.fetchData({ toggleTree: false, fetchMeta: refetchMeta }); }, needsReload() { return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]); @@ -468,42 +486,52 @@ export default { needsFirstLoad() { return !this.diffFiles.length; }, - fetchData(toggleTree = true) { - this.fetchDiffFilesMeta() - .then((data) => { - let realSize = 0; - - if (data) { - realSize = data.real_size; - } - - this.diffFilesLength = parseInt(realSize, 10) || 0; - if (toggleTree) { - this.setTreeDisplay(); - } - - this.updateChangesTabCount(); - }) - .catch(() => { - createAlert({ - message: __('Something went wrong on our end. Please try again!'), + fetchData({ toggleTree = true, fetchMeta = true } = {}) { + if (fetchMeta) { + this.fetchDiffFilesMeta() + .then((data) => { + let realSize = 0; + + if (data) { + realSize = data.real_size; + + if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) { + this.fetchFileByFile(); + } + } + + this.diffFilesLength = parseInt(realSize, 10) || 0; + if (toggleTree) { + this.setTreeDisplay(); + } + + updateChangesTabCount({ + count: this.diffFilesLength, + }); + }) + .catch(() => { + createAlert({ + message: __('Something went wrong on our end. Please try again!'), + }); }); - }); + } - this.fetchDiffFilesBatch() - .then(() => { - if (toggleTree) this.setTreeDisplay(); - // Guarantee the discussions are assigned after the batch finishes. - // Just watching the length of the discussions or the diff files - // isn't enough, because with split diff loading, neither will - // change when loading the other half of the diff files. - this.setDiscussions(); - }) - .catch(() => { - createAlert({ - message: __('Something went wrong on our end. Please try again!'), + if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) { + this.fetchDiffFilesBatch() + .then(() => { + if (toggleTree) this.setTreeDisplay(); + // Guarantee the discussions are assigned after the batch finishes. + // Just watching the length of the discussions or the diff files + // isn't enough, because with split diff loading, neither will + // change when loading the other half of the diff files. + this.setDiscussions(); + }) + .catch(() => { + createAlert({ + message: __('Something went wrong on our end. Please try again!'), + }); }); - }); + } if (this.endpointCoverage) { this.fetchCoverageFiles(); @@ -579,7 +607,10 @@ export default { jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) { - this.scrollToFile({ path: this.flatBlobsList[targetIndex].path }); + this.goToFile({ + path: this.flatBlobsList[targetIndex].path, + singleFile: this.glFeatures.singleFileFileByFile, + }); } }, setTreeDisplay() { @@ -640,6 +671,11 @@ export default { <template> <div v-show="shouldShow"> + <findings-drawer + v-if="glFeatures.codeQualityInlineDrawer" + :drawer="activeDrawer" + @close="closeDrawer" + /> <div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> <compare-versions :diff-files-count-text="numTotalFiles" /> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 1857ff557e6..d050f2fb9ae 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -30,6 +30,7 @@ export default { CommitPipelineStatus, GlButtonGroup, GlButton, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -117,12 +118,11 @@ export default { </div> <div> <div class="d-flex float-left align-items-center align-self-start"> - <input + <gl-form-checkbox v-if="isSelectable" - class="gl-mr-3" - type="checkbox" :checked="checked" - @change="$emit('handleCheckboxChange', $event.target.checked)" + class="gl-mt-3" + @change="$emit('handleCheckboxChange', !checked)" /> <user-avatar-link :link-href="authorUrl" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue index 5392c631c14..f3f05e3d9d9 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -1,27 +1,19 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { GlButton } from '@gitlab/ui'; import { NEW_CODE_QUALITY_FINDINGS } from '../i18n'; +import DiffCodeQualityItem from './diff_code_quality_item.vue'; export default { i18n: { newFindings: NEW_CODE_QUALITY_FINDINGS, }, - components: { GlButton, GlIcon }, + components: { GlButton, DiffCodeQualityItem }, props: { codeQuality: { type: Array, required: true, }, }, - methods: { - severityClass(severity) { - return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; - }, - severityIcon(severity) { - return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; - }, - }, }; </script> @@ -37,23 +29,11 @@ export default { {{ $options.i18n.newFindings }} </h4> <ul class="gl-list-style-none gl-mb-0 gl-p-0"> - <li + <diff-code-quality-item v-for="finding in codeQuality" :key="finding.description" - class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex" - > - <span class="gl-mr-3"> - <gl-icon - :size="12" - :name="severityIcon(finding.severity)" - :class="severityClass(finding.severity)" - class="codequality-severity-icon" - /> - </span> - <span> - <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }} - </span> - </li> + :finding="finding" + /> </ul> <gl-button data-testid="diff-codequality-close" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue new file mode 100644 index 00000000000..eede110f46c --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue @@ -0,0 +1,54 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + components: { GlLink, GlIcon }, + mixins: [glFeatureFlagsMixin()], + props: { + finding: { + type: Object, + required: true, + }, + }, + methods: { + severityClass(severity) { + return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; + }, + severityIcon(severity) { + return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + }, + toggleDrawer() { + this.setDrawer(this.finding); + }, + ...mapActions('findingsDrawer', ['setDrawer']), + }, +}; +</script> + +<template> + <li class="gl-py-1 gl-font-regular gl-display-flex"> + <span class="gl-mr-3"> + <gl-icon + :size="12" + :name="severityIcon(finding.severity)" + :class="severityClass(finding.severity)" + class="codequality-severity-icon" + /> + </span> + <span + v-if="glFeatures.codeQualityInlineDrawer" + data-testid="description-button-section" + class="gl-display-flex" + > + <gl-link category="primary" variant="link" @click="toggleDrawer"> + {{ finding.severity }} - {{ finding.description }}</gl-link + > + </span> + <span v-else data-testid="description-plain-text" class="gl-display-flex"> + {{ finding.severity }} - {{ finding.description }} + </span> + </li> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index c19174dda8a..a58178eaef7 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -209,7 +209,11 @@ export default { if (this.hasDiff) { this.postRender(); - } else if (this.viewDiffsFileByFile && !this.isCollapsed) { + } else if ( + this.viewDiffsFileByFile && + !this.isCollapsed && + !this.glFeatures.singleFileFileByFile + ) { this.requestDiff(); } diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 16f45c3ad6a..c3a4897ce78 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -50,6 +50,7 @@ export default { i18n: { ...DIFF_FILE_HEADER, compareButtonLabel: __('Compare submodule commit revisions'), + fileModeTooltip: __('File permissions'), }, props: { discussionPath: { @@ -201,6 +202,9 @@ export default { externalUrlLabel() { return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url }); }, + labelToggleFile() { + return this.expanded ? __('Hide file contents') : __('Show file contents'); + }, }, watch: { 'idState.moreActionsShown': { @@ -287,12 +291,14 @@ export default { @click.self="handleToggleFile" > <div class="file-header-content"> - <gl-icon + <gl-button v-if="collapsible" - ref="collapseIcon" - :name="collapseIcon" - :size="16" - class="diff-toggle-caret gl-mr-2" + ref="collapseButton" + class="gl-mr-2" + category="tertiary" + size="small" + :icon="collapseIcon" + :aria-label="labelToggleFile" @click.stop="handleToggleFile" /> <a @@ -342,7 +348,13 @@ export default { data-track-property="diff_copy_file" /> - <small v-if="isModeChanged" ref="fileMode" class="mr-1"> + <small + v-if="isModeChanged" + ref="fileMode" + v-gl-tooltip.hover + class="mr-1" + :title="$options.i18n.fileModeTooltip" + > {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -364,7 +376,7 @@ export default { v-if="isReviewable && showLocalFileReviews" v-gl-tooltip.hover data-testid="fileReviewCheckbox" - class="gl-mr-5 gl-display-flex gl-align-items-center" + class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center" :title="$options.i18n.fileReviewTooltip" :checked="reviewed" @change="toggleReview" 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 f63ab1bb067..43ba527dad8 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -8,7 +8,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils'; import NoteForm from '~/notes/components/note_form.vue'; -import autosave from '~/notes/mixins/autosave'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, @@ -21,7 +21,7 @@ export default { NoteForm, MultilineCommentForm, }, - mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()], + mixins: [diffLineNoteFormMixin, glFeatureFlagsMixin()], props: { diffFileHash: { type: String, @@ -146,6 +146,27 @@ export default { return lines; }, + autosaveKey() { + if (!this.isLoggedIn) return ''; + + const { + id, + noteable_type: noteableTypeUnderscored, + noteableType, + diff_head_sha: diffHeadSha, + source_project_id: sourceProjectId, + } = this.noteableData; + + return [ + s__('Autosave|Note'), + capitalizeFirstCharacter(noteableTypeUnderscored || noteableType), + id, + diffHeadSha, + DIFF_NOTE_TYPE, + sourceProjectId, + this.line.line_code, + ].join('/'); + }, }, created() { if (this.range) { @@ -155,17 +176,6 @@ export default { } }, mounted() { - if (this.isLoggedIn) { - const keys = [ - this.noteableData.diff_head_sha, - DIFF_NOTE_TYPE, - this.noteableData.source_project_id, - this.line.line_code, - ]; - - this.initAutoSave(this.noteableData, keys); - } - if (this.selectedCommentPosition) { this.commentLineStart = this.selectedCommentPosition.start; } @@ -196,9 +206,6 @@ export default { lineCode: this.line.line_code, fileHash: this.diffFileHash, }); - this.$nextTick(() => { - this.resetAutoSave(); - }); }), handleSaveNote(note) { return this.saveDiffDiscussion({ note, formData: this.formData }).then(() => @@ -232,6 +239,7 @@ export default { :diff-file="diffFile" :show-suggest-popover="showSuggestPopover" :save-button-title="__('Comment')" + :autosave-key="autosaveKey" class="diff-comment-form gl-mt-3" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index e8b4ff16aec..7de8eff7863 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -76,7 +76,7 @@ export default { class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center" :class="{ bold: isCompareVersionsHeader }" > - <span>-</span> + <span>−</span> <span data-testid="js-file-deletion-line">{{ removedLines }}</span> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index a2e052e0f93..348d6d1d78d 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,7 +1,6 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; @@ -21,11 +20,7 @@ export default { DiffCommentCell, DraftNote, }, - mixins: [ - draftCommentsMixin, - IdState({ idProp: (vm) => vm.diffFile.file_hash }), - glFeatureFlagsMixin(), - ], + mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })], props: { diffFile: { type: Object, @@ -265,10 +260,7 @@ export default { @stopdragging="onStopDragging" /> <diff-line - v-if=" - glFeatures.refactorCodeQualityInlineFindings && - codeQualityExpandedLines.includes(getCodeQualityLine(line)) - " + v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))" :key="line.line_code" :line="line" @hideCodeQualityFindings="hideCodeQualityFindings" diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue index f6a8c679f3b..26d37484541 100644 --- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -1,17 +1,18 @@ <script> -import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; export const i18n = { - title: __('Too many changes to show.'), + title: __('Some changes are not shown.'), plainDiff: __('Plain diff'), - emailPatch: __('Email patch'), + emailPatch: __('Patches'), }; export default { i18n, components: { GlAlert, + GlButton, GlSprintf, }, props: { @@ -38,18 +39,15 @@ export default { <template> <gl-alert variant="warning" + class="gl-mx-5 gl-mb-4 gl-mt-3" :title="$options.i18n.title" - :primary-button-text="$options.i18n.plainDiff" - :primary-button-link="plainDiffPath" - :secondary-button-text="$options.i18n.emailPatch" - :secondary-button-link="emailPatchPath" :dismissible="false" > <gl-sprintf :message=" sprintf( __( - 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.', + 'For a faster browsing experience, only %{strongStart}%{visible} of %{total}%{strongEnd} files are shown. Download one of the files below to see all changes.', ), { visible, total } /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */, ) @@ -59,5 +57,13 @@ export default { <strong>{{ content }}</strong> </template> </gl-sprintf> + <template #actions> + <gl-button :href="plainDiffPath" class="gl-mr-3 gl-alert-action"> + {{ $options.i18n.plainDiff }} + </gl-button> + <gl-button :href="emailPatchPath" class="gl-alert-action"> + {{ $options.i18n.emailPatch }} + </gl-button> + </template> </gl-alert> </template> diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue new file mode 100644 index 00000000000..da880c6f3ca --- /dev/null +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -0,0 +1,110 @@ +<script> +import { GlDrawer, GlIcon, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { s__ } from '~/locale'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; + +export const i18n = { + severity: s__('FindingsDrawer|Severity:'), + engine: s__('FindingsDrawer|Engine:'), + category: s__('FindingsDrawer|Category:'), + otherLocations: s__('FindingsDrawer|Other locations:'), +}; + +export default { + i18n, + components: { GlDrawer, GlIcon, GlLink }, + directives: { + SafeHtml, + }, + props: { + drawer: { + type: Object, + required: true, + }, + }, + safeHtmlConfig: { + ALLOWED_TAGS: ['a', 'h1', 'h2', 'p'], + ALLOWED_ATTR: ['href', 'rel'], + }, + computed: { + drawerOffsetTop() { + return getContentWrapperHeight('.content-wrapper'); + }, + }, + DRAWER_Z_INDEX, + methods: { + severityClass(severity) { + return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; + }, + severityIcon(severity) { + return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + }, + }, +}; +</script> +<template> + <gl-drawer + :header-height="drawerOffsetTop" + :z-index="$options.DRAWER_Z_INDEX" + class="findings-drawer" + :open="Object.keys(drawer).length !== 0" + @close="$emit('close')" + > + <template #title> + <h2 data-testid="findings-drawer-heading" class="gl-font-size-h2 gl-mt-0 gl-mb-0"> + {{ drawer.description }} + </h2> + </template> + + <template #default> + <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!"> + <li data-testid="findings-drawer-severity" class="gl-mb-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.severity }}</span> + <gl-icon + data-testid="findings-drawer-severity-icon" + :size="12" + :name="severityIcon(drawer.severity)" + :class="severityClass(drawer.severity)" + class="codequality-severity-icon" + /> + + {{ drawer.severity }} + </li> + <li data-testid="findings-drawer-engine" class="gl-mb-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.engine }}</span> + {{ drawer.engineName }} + </li> + <li data-testid="findings-drawer-category" class="gl-mb-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.category }}</span> + {{ drawer.categories ? drawer.categories[0] : '' }} + </li> + <li data-testid="findings-drawer-other-locations" class="gl-mb-4"> + <span class="gl-font-weight-bold gl-mb-3 gl-display-block">{{ + $options.i18n.otherLocations + }}</span> + <ul class="gl-pl-6"> + <li + v-for="otherLocation in drawer.otherLocations" + :key="otherLocation.path" + class="gl-mb-1" + > + <gl-link + data-testid="findings-drawer-other-locations-link" + :href="otherLocation.href" + >{{ otherLocation.path }}</gl-link + > + </li> + </ul> + </li> + </ul> + <span + v-safe-html:[$options.safeHtmlConfig]="drawer.content ? drawer.content.body : ''" + data-testid="findings-drawer-body" + class="drawer-body gl-display-block gl-px-3 gl-py-0!" + ></span> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index ab08c72b08f..4f1875e9175 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -5,6 +5,7 @@ import micromatch from 'micromatch'; import { debounce } from 'lodash'; import { getModifierKey } from '~/constants'; import { s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { RecycleScroller } from 'vendor/vue-virtual-scroller'; import DiffFileRow from './diff_file_row.vue'; @@ -19,6 +20,7 @@ export default { DiffFileRow, RecycleScroller, }, + mixins: [glFeatureFlagsMixin()], props: { hideFileStats: { type: Boolean, @@ -105,7 +107,7 @@ export default { this.resizeObserver.disconnect(); }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), + ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), clearSearch() { this.search = ''; }, @@ -128,7 +130,7 @@ export default { > <div class="gl-pb-3 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> - <gl-icon name="search" class="position-absolute tree-list-icon" /> + <gl-icon name="search" class="gl-absolute gl-top-5 tree-list-icon" /> <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label> <input id="diff-tree-search" @@ -154,7 +156,7 @@ export default { <div ref="scrollRoot" :class="{ 'tree-list-blobs': !renderTreeList || search }" - class="gl-flex-grow-1" + class="gl-flex-grow-1 mr-tree-list" > <recycle-scroller v-if="flatFilteredTreeList.length" @@ -172,10 +174,10 @@ export default { :hide-file-stats="hideFileStats" :current-diff-file-id="currentDiffFileId" :style="{ '--level': item.level }" - :class="{ 'tree-list-parent': item.tree.length }" + :class="{ 'tree-list-parent': item.level > 0 }" class="gl-relative" @toggleTreeOpen="toggleTreeOpen" - @clickFile="(path) => scrollToFile({ path })" + @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })" /> </template> <template #after> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 873c4819669..a459def6b4b 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -112,3 +112,6 @@ export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace'; export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting'; export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file'; export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files'; + +// UI +export const ZERO_CHANGES_ALT_DISPLAY = '-'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index 0f44eb06cb3..e233a0cef0a 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -1,6 +1,12 @@ import { __, s__ } from '~/locale'; export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!'); +export const LOAD_SINGLE_DIFF_FAILED = s__( + "MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.", +); +export const DISCUSSION_SINGLE_DIFF_FAILED = s__( + "MergeRequest|Can't fetch the single file diff for the discussion. Please reload this page.", +); export const DIFF_FILE_HEADER = { optionsDropdownTitle: __('Options'), diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 00a08434dac..ad7182024da 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -27,7 +27,7 @@ export default function initDiffsApp(store = notesStore) { store, apolloProvider, provide: { - newSavedRepliesPath: dataset.savedRepliesNewPath, + newCommentTemplatePath: dataset.newCommentTemplatePath, }, data() { return { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 9236e14beb1..a70c907314b 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -16,6 +16,7 @@ import { __, s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; import { generateTreeList } from '~/diffs/utils/tree_worker_utils'; import { sortTree } from '~/ide/stores/utils'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, @@ -49,6 +50,7 @@ import { TRACKING_SINGLE_FILE_MODE, TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; +import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n'; import eventHub from '../event_hub'; import { isCollapsed } from '../utils/diff_file'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; @@ -62,6 +64,8 @@ import { idleCallback, allDiscussionWrappersExpanded, prepareLineForRenamedFile, + parseUrlHashAsFileHash, + isUrlHashNoteLink, } from './utils'; export const setBaseConfig = ({ commit }, options) => { @@ -101,6 +105,47 @@ export const setBaseConfig = ({ commit }, options) => { }); }; +export const fetchFileByFile = async ({ state, getters, commit }) => { + const isNoteLink = isUrlHashNoteLink(window?.location?.hash); + const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId); + const treeEntry = id + ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id) + : getters.flatBlobsList[0]; + + if (treeEntry && !treeEntry.diffLoaded && !getters.getDiffFileByHash(id)) { + // Overloading "batch" loading indicators so the UI stays mostly the same + commit(types.SET_BATCH_LOADING_STATE, 'loading'); + commit(types.SET_RETRIEVING_BATCHES, true); + + const urlParams = { + old_path: treeEntry.filePaths.old, + new_path: treeEntry.filePaths.new, + w: state.showWhitespace ? '0' : '1', + view: 'inline', + }; + + axios + .get(mergeUrlParams({ ...urlParams }, state.endpointDiffForPath)) + .then(({ data: diffData }) => { + commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files }); + + if (!isNoteLink && !state.currentDiffFileId) { + commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0]?.file_hash || ''); + } + + commit(types.SET_BATCH_LOADING_STATE, 'loaded'); + + eventHub.$emit('diffFilesModified'); + }) + .catch(() => { + commit(types.SET_BATCH_LOADING_STATE, 'error'); + }) + .finally(() => { + commit(types.SET_RETRIEVING_BATCHES, false); + }); + } +}; + export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { let perPage = state.viewDiffsFileByFile ? 1 : 5; let increaseAmount = 1.4; @@ -512,13 +557,20 @@ export const toggleFileDiscussionWrappers = ({ commit }, diff) => { } }; -export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { +export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ commit: state.commit, note, ...formData, }); + if (containsSensitiveToken(note)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + return null; + } + } + return dispatch('saveNote', postData, { root: true }) .then((result) => dispatch('updateDiscussion', result.discussion, { root: true })) .then((discussion) => dispatch('assignDiscussionsToDiff', [discussion])) @@ -539,6 +591,31 @@ export const setCurrentFileHash = ({ commit }, hash) => { commit(types.SET_CURRENT_DIFF_FILE, hash); }; +export const goToFile = ({ state, commit, dispatch, getters }, { path, singleFile }) => { + if (!state.viewDiffsFileByFile || !singleFile) { + dispatch('scrollToFile', { path }); + } else { + if (!state.treeEntries[path]) return; + + const { fileHash } = state.treeEntries[path]; + + commit(types.SET_CURRENT_DIFF_FILE, fileHash); + document.location.hash = fileHash; + + if (!getters.isTreePathLoaded(path)) { + dispatch('fetchFileByFile') + .then(() => { + dispatch('scrollToFile', { path }); + }) + .catch(() => { + createAlert({ + message: LOAD_SINGLE_DIFF_FAILED, + }); + }); + } + } +}; + export const scrollToFile = ({ state, commit, getters }, { path }) => { if (!state.treeEntries[path]) return; @@ -779,13 +856,11 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => }); export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) { - /* eslint-disable @gitlab/require-i18n-strings */ if (!commitId) { return Promise.reject(new Error('`commitId` is a required argument')); } else if (!state.commit) { - return Promise.reject(new Error('`state` must already contain a valid `commit`')); + return Promise.reject(new Error('`state` must already contain a valid `commit`')); // eslint-disable-line @gitlab/require-i18n-strings } - /* eslint-enable @gitlab/require-i18n-strings */ // this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421 const commitRE = new RegExp(state.commit.id, 'g'); @@ -821,6 +896,24 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) { } } +export const rereadNoteHash = ({ state, dispatch }) => { + const urlHash = window?.location?.hash; + + if (isUrlHashNoteLink(urlHash)) { + dispatch('setCurrentDiffFileIdFromNote', urlHash.split('_').pop()) + .then(() => { + if (state.viewDiffsFileByFile) { + dispatch('fetchFileByFile'); + } + }) + .catch(() => { + createAlert({ + message: DISCUSSION_SINGLE_DIFF_FAILED, + }); + }); + } +}; + export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, noteId) => { const note = rootGetters.notesById[noteId]; @@ -833,11 +926,18 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n } }; -export const navigateToDiffFileIndex = ({ commit, getters }, index) => { +export const navigateToDiffFileIndex = ( + { state, getters, commit, dispatch }, + { index, singleFile }, +) => { const { fileHash } = getters.flatBlobsList[index]; document.location.hash = fileHash; commit(types.SET_CURRENT_DIFF_FILE, fileHash); + + if (state.viewDiffsFileByFile && singleFile) { + dispatch('fetchFileByFile'); + } }; export const setFileByFile = ({ state, commit }, { fileByFile }) => { diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 3739ef0cd55..4ca353333b7 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -559,19 +559,19 @@ export const allDiscussionWrappersExpanded = (diff) => { return discussionsExpanded; }; -export function isUrlHashNoteLink(urlHash) { +export function isUrlHashNoteLink(urlHash = '') { const id = urlHash.replace(/^#/, ''); return id.startsWith('note'); } -export function isUrlHashFileHeader(urlHash) { +export function isUrlHashFileHeader(urlHash = '') { const id = urlHash.replace(/^#/, ''); return id.startsWith('diff-content'); } -export function parseUrlHashAsFileHash(urlHash, currentDiffFileId = '') { +export function parseUrlHashAsFileHash(urlHash = '', currentDiffFileId = '') { const isNoteLink = isUrlHashNoteLink(urlHash); let id = urlHash.replace(/^#/, ''); diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js index 43e04a814c5..6847b8900d2 100644 --- a/app/assets/javascripts/diffs/utils/merge_request.js +++ b/app/assets/javascripts/diffs/utils/merge_request.js @@ -1,3 +1,5 @@ +import { ZERO_CHANGES_ALT_DISPLAY } from '../constants'; + const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i; function getVersionInfo({ endpoint } = {}) { @@ -13,6 +15,17 @@ function getVersionInfo({ endpoint } = {}) { }; } +export function updateChangesTabCount({ + count, + badge = document.querySelector('.js-diffs-tab .gl-badge'), +} = {}) { + if (badge) { + // The purpose of this function is to assign to this parameter + /* eslint-disable-next-line no-param-reassign */ + badge.textContent = count || ZERO_CHANGES_ALT_DISPLAY; + } +} + export function getDerivedMergeRequestInformation({ endpoint } = {}) { let mrPath; let userOrGroup; diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue index 67b909d37c3..0afee7bebe0 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -52,7 +52,7 @@ export default { <section v-if="isVisible" id="se-toolbar" - class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-align-items-center" + class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center" > <gl-button-group v-if="hasGroupItems($options.groups.file)"> <source-editor-toolbar-button diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js index f8ff533f53f..9ec1a97ba1a 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -37,8 +37,6 @@ const setupDomElement = ({ injectToEl = null } = {}) => { return previewEl; }; -let dimResize = false; - export class EditorMarkdownPreviewExtension { static get extensionName() { return 'EditorMarkdownPreview'; @@ -53,7 +51,6 @@ export class EditorMarkdownPreviewExtension { }, shown: false, modelChangeListener: undefined, - layoutChangeListener: undefined, path: setupOptions.previewMarkdownPath, actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true), eventEmitter: new Emitter(), @@ -65,13 +62,17 @@ export class EditorMarkdownPreviewExtension { this.setupToolbar(instance); } - this.preview.layoutChangeListener = instance.onDidLayoutChange(() => { - if (instance.markdownPreview?.shown && !dimResize) { - const { width } = instance.getLayoutInfo(); - const newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; - EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth); + const debouncedResizeHandler = debounce((entries) => { + for (const entry of entries) { + const { width: newInstanceWidth } = entry.contentRect; + if (instance.markdownPreview?.shown) { + const newWidth = newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth); + } } - }); + }, 50); + + this.resizeObserver = new ResizeObserver(debouncedResizeHandler); this.preview.eventEmitter.event(this.togglePreview.bind(this, instance)); } @@ -85,9 +86,7 @@ export class EditorMarkdownPreviewExtension { } cleanup(instance) { - if (this.preview.layoutChangeListener) { - this.preview.layoutChangeListener.dispose(); - } + this.resizeObserver.disconnect(); if (this.preview.modelChangeListener) { this.preview.modelChangeListener.dispose(); } @@ -102,11 +101,7 @@ export class EditorMarkdownPreviewExtension { static resizePreviewLayout(instance, width) { const { height } = instance.getLayoutInfo(); - dimResize = true; instance.layout({ width, height }); - window.requestAnimationFrame(() => { - dimResize = false; - }); } setupToolbar(instance) { @@ -130,9 +125,16 @@ export class EditorMarkdownPreviewExtension { togglePreviewLayout(instance) { const { width } = instance.getLayoutInfo(); - const newWidth = this.preview.shown - ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH - : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + let newWidth; + if (this.preview.shown) { + // This means the preview is to be closed at the next step + newWidth = width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + this.resizeObserver.disconnect(); + } else { + // The preview is hidden, but is in the process to be opened + newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + this.resizeObserver.observe(instance.getContainerDomNode()); + } EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth); } diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index a5080332b78..44944a4a205 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -1894,6 +1894,10 @@ } }, "additionalProperties": false + }, + "publish": { + "description": "A path to a directory that contains the files to be published with Pages", + "type": "string" } }, "oneOf": [ diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index b9392fabcbd..4484bc03737 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -232,8 +232,8 @@ export function emojiImageTag(name, src) { title: `:${name}:`, alt: `:${name}:`, src, - width: '20', - height: '20', + width: '16', + height: '16', align: 'absmiddle', }); diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js index 308077f98b1..6e88a998096 100644 --- a/app/assets/javascripts/entrypoints/super_sidebar.js +++ b/app/assets/javascripts/entrypoints/super_sidebar.js @@ -1,5 +1,6 @@ import '~/webpack'; import '~/commons'; -import { initSuperSidebar } from '~/super_sidebar/super_sidebar_bundle'; +import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle'; initSuperSidebar(); +initSuperSidebarToggle(); diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 53a93bbce30..9db3011ba5d 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -75,7 +75,12 @@ export default { if (this.hasMultipleCommits) { if (this.graphql) { const { lastDeployment } = this.environment; - return this.commitData(lastDeployment, 'commitPath'); + return ( + // data shape comming from REST and GraphQL is unfortunately different + // once we fully migrate to GraphQL it could be streamlined + this.commitData(lastDeployment, 'commitPath') || + this.commitData(lastDeployment, 'webUrl') + ); } const { last_deployment } = this.environment; @@ -135,7 +140,6 @@ export default { csrf, cancelProps: { text: __('Cancel'), - attributes: { variant: 'danger' }, }, docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }), }; @@ -157,7 +161,7 @@ export default { }}</gl-link> </template> <template #docs="{ content }"> - <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.docsPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-modal> diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index 31bc462f0b9..b2843b79ba6 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -158,7 +158,7 @@ export default { >{{ instanceTitle }} ({{ instanceCount }})</span > <span ref="legend-icon" data-testid="legend-tooltip-target"> - <gl-icon class="gl-text-blue-500 gl-ml-2" name="question" /> + <gl-icon class="gl-text-blue-500 gl-ml-2" name="question-o" /> </span> <gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body"> <div class="deploy-board-legend gl-display-flex gl-flex-direction-column"> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 74eef50ebaf..d49598d2f21 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { formatTime } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; @@ -7,12 +7,9 @@ import eventHub from '../event_hub'; import actionMutation from '../graphql/mutations/action.mutation.graphql'; export default { - directives: { - GlTooltip: GlTooltipDirective, - }, components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdownItem, + GlDisclosureDropdown, GlIcon, }, props: { @@ -36,6 +33,16 @@ export default { title() { return __('Deploy to...'); }, + actionItems() { + return this.actions.map((actionItem) => ({ + text: actionItem.name, + action: () => this.onClickAction(actionItem), + extraAttrs: { + disabled: this.isActionDisabled(actionItem), + }, + ...actionItem, + })); + }, }, methods: { async onClickAction(action) { @@ -48,7 +55,6 @@ export default { ); const confirmed = await confirmAction(confirmationMessage); - if (!confirmed) { return; } @@ -80,30 +86,31 @@ export default { }; </script> <template> - <gl-dropdown - v-gl-tooltip + <gl-disclosure-dropdown :text="title" :title="title" :loading="isLoading" :aria-label="title" + :items="actionItems" icon="play" text-sr-only right data-container="body" data-testid="environment-actions-button" > - <gl-dropdown-item - v-for="(action, i) in actions" - :key="i" - :disabled="isActionDisabled(action)" + <gl-disclosure-dropdown-item + v-for="item in actionItems" + :key="item.name" + :item="item" data-testid="manual-action-link" - @click="onClickAction(action)" > - <span class="gl-flex-grow-1">{{ action.name }}</span> - <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right"> - <gl-icon name="clock" /> - {{ remainingTime(action) }} - </span> - </gl-dropdown-item> - </gl-dropdown> + <template #list-item> + <span class="gl-flex-grow-1">{{ item.text }}</span> + <span v-if="item.scheduledAt" class="gl-text-gray-500 float-right"> + <gl-icon name="clock" /> + {{ remainingTime(item) }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index cfb18cc4f82..736eaa7062d 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -1,14 +1,20 @@ <script> -import { GlCollapse, GlButton } from '@gitlab/ui'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import KubernetesAgentInfo from './kubernetes_agent_info.vue'; +import KubernetesPods from './kubernetes_pods.vue'; export default { components: { GlCollapse, GlButton, + GlAlert, KubernetesAgentInfo, + KubernetesPods, }, + inject: ['kasTunnelUrl'], props: { agentName: { required: true, @@ -22,10 +28,16 @@ export default { required: true, type: String, }, + namespace: { + required: false, + type: String, + default: '', + }, }, data() { return { isVisible: false, + error: '', }; }, computed: { @@ -35,11 +47,26 @@ export default { label() { return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand; }, + gitlabAgentId() { + const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId; + return id.toString(); + }, + k8sAccessConfiguration() { + return { + basePath: this.kasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers }, + }, + }; + }, }, methods: { toggleCollapse() { this.isVisible = !this.isVisible; }, + onClusterError(message) { + this.error = message; + }, }, i18n: { collapse: __('Collapse'), @@ -66,7 +93,17 @@ export default { :agent-name="agentName" :agent-id="agentId" :agent-project-path="agentProjectPath" + class="gl-mb-5" /> + + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ error }} + </gl-alert> + + <kubernetes-pods + :configuration="k8sAccessConfiguration" + :namespace="namespace" class="gl-mb-5" + @cluster-error="onClusterError" /></template> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue new file mode 100644 index 00000000000..a153331ee58 --- /dev/null +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -0,0 +1,111 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { s__ } from '~/locale'; +import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlSingleStat, + }, + apollo: { + k8sPods: { + query: k8sPodsQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + update(data) { + return data?.k8sPods || []; + }, + error(error) { + this.error = error; + this.$emit('cluster-error', this.error); + }, + }, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: true, + type: String, + }, + }, + data() { + return { + error: '', + }; + }, + + computed: { + podStats() { + if (!this.k8sPods) return null; + + return [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Running'), + title: this.$options.i18n.runningPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Pending'), + title: this.$options.i18n.pendingPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Succeeded'), + title: this.$options.i18n.succeededPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Failed'), + title: this.$options.i18n.failedPods, + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sPods.loading; + }, + }, + methods: { + getPodsByPhase(phase) { + const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase); + return filteredPods.length; + }, + }, + i18n: { + podsTitle: s__('Environment|Pods'), + runningPods: s__('Environment|Running'), + pendingPods: s__('Environment|Pending'), + succeededPods: s__('Environment|Succeeded'), + failedPods: s__('Environment|Failed'), + }, +}; +</script> +<template> + <div> + <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p> + + <gl-loading-icon v-if="loading" /> + + <div + v-else-if="podStats && !error" + class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3" + > + <gl-single-stat + v-for="(stat, index) in podStats" + :key="index" + class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3" + :value="stat.value" + :title="stat.title" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 2ec6e12b8b3..ee197bbcd45 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -148,6 +148,9 @@ export default { return now < autoStopDate; }, + upcomingDeploymentIid() { + return this.environment.upcomingDeployment?.iid.toString() || ''; + }, autoStopPath() { return this.environment?.cancelAutoStopPath ?? ''; }, @@ -173,7 +176,8 @@ export default { return this.glFeatures?.kasUserAccessProject; }, hasRequiredAgentData() { - return this.agent.project && this.agent.id && this.agent.name; + const { project, id, name } = this.agent || {}; + return project && id && name; }, showKubernetesOverview() { return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData; @@ -223,7 +227,7 @@ export default { :icon="icon" :aria-label="label" size="small" - category="tertiary" + category="secondary" @click="toggleCollapse" /> <gl-link @@ -270,7 +274,6 @@ export default { <stop-component v-if="canStop" :environment="environment" - class="gl-z-index-2" data-track-action="click_button" data-track-label="environment_stop" graphql @@ -351,7 +354,11 @@ export default { class="gl-pl-4" > <template #approval> - <environment-approval :environment="environment" @change="$emit('change')" /> + <environment-approval + :deployment-iid="upcomingDeploymentIid" + :environment="environment" + @change="$emit('change')" + /> </template> </deployment> </div> @@ -368,6 +375,7 @@ export default { :agent-project-path="agent.project" :agent-name="agent.name" :agent-id="agent.id" + :namespace="agent.kubernetesNamespace" /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> 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 77d9311743c..92a0b0e550e 100644 --- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue +++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue @@ -1,9 +1,22 @@ <script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { translations } from '~/environments/environment_details/constants'; import ActionsComponent from '~/environments/components/environment_actions.vue'; +import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql'; + +const EnvironmentApprovalComponent = import( + 'ee_component/environments/components/environment_approval.vue' +); export default { components: { + GlButton, ActionsComponent, + EnvironmentApproval: () => EnvironmentApprovalComponent, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, props: { actions: { @@ -18,14 +31,92 @@ export default { type: Array, required: true, }, + rollback: { + // rollback shape: + /* + { + id: string, + name: string, + lastDeployment: { + commit: Commit, + isLast: boolean, + }, + retryUrl: url, + }; + */ + type: Object, + required: false, + default: null, + }, + // approvalEnvironment shape: + /* { + isApprovalActionAvailable: boolean, + deploymentIid: string, + environment: { + name: string, + tier: string, + requiredApprovalCount: number, + }, + */ + approvalEnvironment: { + type: Object, + required: false, + default: () => ({ + isApprovalActionAvailable: false, + }), + }, }, computed: { + isRollbackAvailable() { + return Boolean(this.rollback?.lastDeployment); + }, + rollbackIcon() { + return this.rollback.lastDeployment.isLast ? 'repeat' : 'redo'; + }, isActionsShown() { return this.actions.length > 0; }, + deploymentIid() { + return this.approvalEnvironment.deploymentIid; + }, + environment() { + return this.approvalEnvironment.environment; + }, + rollbackButtonTitle() { + return this.rollback.lastDeployment?.isLast + ? translations.redeployButtonTitle + : translations.rollbackButtonTitle; + }, + }, + methods: { + onRollbackClick() { + this.$apollo.mutate({ + mutation: setEnvironmentToRollback, + variables: { + environment: this.rollback, + }, + }); + }, }, }; </script> <template> - <actions-component v-if="isActionsShown" :actions="actions" graphql /> + <div> + <actions-component v-if="isActionsShown" :actions="actions" graphql /> + <gl-button + v-if="isRollbackAvailable" + v-gl-modal.confirm-rollback-modal + v-gl-tooltip + data-testid="rollback-button" + :title="rollbackButtonTitle" + :icon="rollbackIcon" + @click="onRollbackClick" + /> + <environment-approval + v-if="approvalEnvironment.isApprovalActionAvailable" + :environment="environment" + :deployment-iid="deploymentIid" + :show-text="false" + /> + </div> </template> diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js index 3b33d6a676e..07579092e23 100644 --- a/app/assets/javascripts/environments/environment_details/constants.js +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -30,7 +30,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ { key: 'job', label: __('Job'), - columnClass: 'gl-w-20p', + columnClass: 'gl-w-15p', tdClass: 'gl-vertical-align-middle!', }, { @@ -48,7 +48,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ { key: 'actions', label: __('Actions'), - columnClass: 'gl-w-10p', + columnClass: 'gl-w-15p', tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', }, ]; @@ -61,6 +61,8 @@ export const translations = { ), nextPageButtonLabel: __('Next'), previousPageButtonLabel: __('Prev'), + redeployButtonTitle: s__('Environments|Re-deploy to environment'), + rollbackButtonTitle: s__('Environments|Rollback environment'), }; export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] }; diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue index 10f8c06e581..128b1aae4d8 100644 --- a/app/assets/javascripts/environments/environment_details/deployments_table.vue +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -54,7 +54,11 @@ export default { <time-ago-tooltip :time="item.deployed" /> </template> <template #cell(actions)="{ item }"> - <deployment-actions :actions="item.actions" /> + <deployment-actions + :actions="item.actions" + :rollback="item.rollback" + :approval-environment="item.deploymentApproval" + /> </template> </gl-table-lite> </template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index f4657c5100a..f91e68e793f 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -1,7 +1,9 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { logError } from '~/lib/logger'; +import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql'; +import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper'; import EmptyState from './empty_state.vue'; import DeploymentsTable from './deployments_table.vue'; @@ -10,6 +12,7 @@ import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants'; export default { components: { + ConfirmRollbackModal, Pagination, DeploymentsTable, EmptyState, @@ -49,10 +52,14 @@ export default { }; }, }, + environmentToRollback: { + query: environmentToRollbackQuery, + }, }, data() { return { project: {}, + environmentToRollback: {}, isInitialPageDataReceived: false, isPrefetchingPages: false, }; @@ -143,5 +150,6 @@ export default { <pagination :page-info="pageInfo" :disabled="isPaginationDisabled" /> </div> <empty-state v-if="!isDeploymentTableShown && !isLoading" /> + <confirm-rollback-modal :environment="environmentToRollback" graphql /> </div> </template> diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 26514b59995..0482741979b 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -5,6 +5,7 @@ import pageInfoQuery from './queries/page_info.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; +import k8sPodsQuery from './queries/k8s_pods.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -82,6 +83,14 @@ export const apolloProvider = (endpoint) => { }, }, }); + cache.writeQuery({ + query: k8sPodsQuery, + data: { + status: { + phase: '', + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql new file mode 100644 index 00000000000..e799623f9bb --- /dev/null +++ b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql @@ -0,0 +1,6 @@ +fragment DeploymentJob on CiJob { + name + id + webPath + playable +} diff --git a/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql new file mode 100644 index 00000000000..1ff68c56362 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql @@ -0,0 +1,3 @@ +fragment ProtectedEnvironment on Environment { + id +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql index 0182b3a7234..65d36242afe 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -1,3 +1,7 @@ +#import "ee_else_ce/environments/graphql/fragments/environment_protected_data.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/environments/graphql/fragments/deployment_job.fragment.graphql" + query getEnvironmentDetails( $projectFullPath: ID! $environmentName: String @@ -11,8 +15,9 @@ query getEnvironmentDetails( name fullPath environment(name: $environmentName) { - id + ...ProtectedEnvironment name + tier lastDeployment(status: SUCCESS) { id job { @@ -40,19 +45,13 @@ query getEnvironmentDetails( ref tag job { - name - id - webPath - playable + ...DeploymentJob deploymentPipeline: pipeline { id jobs(whenExecuted: ["manual"], retried: false) { nodes { - id - name - playable + ...DeploymentJob scheduledAt - webPath } } } @@ -66,17 +65,11 @@ query getEnvironmentDetails( authorName authorEmail author { - id - name - avatarUrl - webUrl + ...User } } triggerer { - id - webUrl - name - avatarUrl + ...User } createdAt finishedAt diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql new file mode 100644 index 00000000000..818bca24d51 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql @@ -0,0 +1,7 @@ +query getK8sPods($configuration: Object, $namespace: String) { + k8sPods(configuration: $configuration, namespace: $namespace) @client { + status { + phase + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index e21670870b8..39e05825cf0 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,3 +1,4 @@ +import { CoreV1Api, Configuration } from '@gitlab/cluster-client'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { @@ -71,6 +72,19 @@ export const resolvers = (endpoint) => ({ 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) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; + }); + }, }, Mutation: { stopEnvironment(_, { environment }, { client }) { diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index b4d1f7326f6..7c102fd04d8 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -62,6 +62,19 @@ type LocalPageInfo { previousPage: Int! } +type k8sPodStatus { + phase: String +} + +type LocalK8sPods { + status: k8sPodStatus +} + +input LocalConfiguration { + basePath: String + baseOptions: JSON +} + extend type Query { environmentApp(page: Int, scope: String): LocalEnvironmentApp folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder @@ -71,6 +84,7 @@ extend type Query { environmentToStop: LocalEnvironment isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean isLastDeployment(environment: LocalEnvironmentInput): Boolean + k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods] } extend type Mutation { diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js index 9802dcbcf78..92efd46df64 100644 --- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js +++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js @@ -62,6 +62,60 @@ export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName) ); }; +export const getRollbackActionFromDeploymentNode = (deploymentNode, environment) => { + const { job, id } = deploymentNode; + + if (!job) { + return null; + } + const isLastDeployment = id === environment.lastDeployment?.id; + const { webPath } = job; + return { + id, + name: environment.name, + lastDeployment: { + commit: deploymentNode.commit, + isLast: isLastDeployment, + }, + retryUrl: `${webPath}/retry`, + }; +}; + +const getDeploymentApprovalFromDeploymentNode = (deploymentNode, environment) => { + if (!environment.protectedEnvironments || environment.protectedEnvironments.nodes.length === 0) { + return { + isApprovalActionAvailable: false, + }; + } + + const protectedEnvironmentInfo = environment.protectedEnvironments.nodes[0]; + + const hasApprovalRules = protectedEnvironmentInfo.approvalRules.nodes?.length > 0; + const hasRequiredApprovals = protectedEnvironmentInfo.requiredApprovalCount > 0; + + const isApprovalActionAvailable = hasRequiredApprovals || hasApprovalRules; + const requiredMultipleApprovalRulesApprovals = protectedEnvironmentInfo.approvalRules.nodes.reduce( + (requiredApprovals, rule) => { + return requiredApprovals + rule.requiredApprovals; + }, + 0, + ); + + const requiredApprovalCount = hasRequiredApprovals + ? protectedEnvironmentInfo.requiredApprovalCount + : requiredMultipleApprovalRulesApprovals; + + return { + isApprovalActionAvailable, + deploymentIid: deploymentNode.iid, + environment: { + name: environment.name, + tier: environment.tier, + requiredApprovalCount, + }, + }; +}; + /** * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table * @param {Object} deploymentNode @@ -82,5 +136,7 @@ export const convertToDeploymentTableRow = (deploymentNode, environment) => { created: deploymentNode.createdAt || '', deployed: deploymentNode.finishedAt || '', actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name), + rollback: getRollbackActionFromDeploymentNode(deploymentNode, environment), + deploymentApproval: getDeploymentApprovalFromDeploymentNode(deploymentNode, environment), }; }; diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d9a523fd806..3f746bc5383 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +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'; @@ -16,6 +17,7 @@ export default (el) => { projectPath, defaultBranchName, projectId, + kasTunnelUrl, } = el.dataset; return new Vue({ @@ -28,6 +30,7 @@ export default (el) => { newEnvironmentPath, helpPagePath, projectId, + kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl), canCreateEnvironment: parseBoolean(canCreateEnvironment), }, render(h) { diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index afce2b7f237..5e812c85c96 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -92,7 +92,9 @@ export const initPage = async () => { el, apolloProvider: apolloProvider(), router, - provide: {}, + provide: { + projectPath: dataSet.projectFullPath, + }, render(createElement) { return createElement('router-view'); }, diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js index aeed5450022..afb91d3db51 100644 --- a/app/assets/javascripts/error_tracking/utils.js +++ b/app/assets/javascripts/error_tracking/utils.js @@ -1,13 +1,13 @@ -/* eslint-disable @gitlab/require-i18n-strings */ +const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings /** * Tracks snowplow event when User clicks on error link to Sentry * @param {String} externalUrl that will be send as a property for the event */ export const trackClickErrorLinkToSentryOptions = (url) => ({ - category: 'Error Tracking', + category, action: 'click_error_link_to_sentry', - label: 'Error Link', + label: 'Error Link', // eslint-disable-line @gitlab/require-i18n-strings property: url, }); @@ -15,7 +15,7 @@ export const trackClickErrorLinkToSentryOptions = (url) => ({ * Tracks snowplow event when user views error list */ export const trackErrorListViewsOptions = { - category: 'Error Tracking', + category, action: 'view_errors_list', }; @@ -23,7 +23,7 @@ export const trackErrorListViewsOptions = { * Tracks snowplow event when user views error details */ export const trackErrorDetailsViewsOptions = { - category: 'Error Tracking', + category, action: 'view_error_details', }; @@ -31,6 +31,6 @@ export const trackErrorDetailsViewsOptions = { * Tracks snowplow event when error status is updated */ export const trackErrorStatusUpdateOptions = (status) => ({ - category: 'Error Tracking', + category, action: `update_${status}_status`, }); diff --git a/app/assets/javascripts/featurable/constants.js b/app/assets/javascripts/featurable/constants.js new file mode 100644 index 00000000000..23f1c5e415d --- /dev/null +++ b/app/assets/javascripts/featurable/constants.js @@ -0,0 +1,6 @@ +// Matches `app/models/concerns/featurable.rb` + +export const FEATURABLE_DISABLED = 'disabled'; +export const FEATURABLE_PRIVATE = 'private'; +export const FEATURABLE_ENABLED = 'enabled'; +export const FEATURABLE_PUBLIC = 'public'; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 93510870915..34e0b94af3b 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -187,7 +187,7 @@ export default { data-testid="feature-flags-tab-title" class="page-title gl-font-size-h-display gl-my-0" > - {{ s__('FeatureFlags|Feature Flags') }} + {{ s__('FeatureFlags|Feature flags') }} </h2> <gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge> </div> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index 286b214b511..dee1d239c9f 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -108,7 +108,7 @@ export default { {{ s__('FeatureFlags|Status') }} </div> <div class="table-section section-20" role="columnheader"> - {{ s__('FeatureFlags|Feature Flag') }} + {{ s__('FeatureFlags|Feature flag') }} </div> <div class="table-section section-40" role="columnheader"> {{ s__('FeatureFlags|Environment Specs') }} @@ -148,7 +148,7 @@ export default { <div class="table-section section-20" role="gridcell"> <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Feature Flag') }} + {{ s__('FeatureFlags|Feature flag') }} </div> <div class="table-mobile-content d-flex flex-column js-feature-flag-title"> <div class="gl-display-flex gl-align-items-center"> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index 1d7a79f926a..6c8a2d90209 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -138,7 +138,7 @@ export default { <template #description> {{ $options.i18n.strategyTypeDescription }} <gl-link :href="strategyTypeDocsPagePath" target="_blank"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </template> <gl-form-select @@ -202,7 +202,7 @@ export default { {{ $options.i18n.environmentsSelectDescription }} </span> <gl-link :href="environmentsScopeDocsPath" target="_blank"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </div> </div> diff --git a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js index 148d9a35b81..c2c46e4265a 100644 --- a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - const InputSetter = { init(hook) { this.hook = hook; @@ -33,11 +31,15 @@ const InputSetter = { setInput(config, selectedItem) { const input = config.input || this.hook.trigger; const newValue = selectedItem.getAttribute(config.valueAttribute); - const inputAttribute = config.inputAttribute; - - if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue); - if (input.tagName === 'INPUT') return (input.value = newValue); - return (input.textContent = newValue); + const { inputAttribute } = config; + + if (input.hasAttribute(inputAttribute)) { + input.setAttribute(inputAttribute, newValue); + } else if (input.tagName === 'INPUT') { + input.value = newValue; + } else { + input.textContent = newValue; + } }, destroy() { diff --git a/app/assets/javascripts/filtered_search/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js index d7f49bf19d8..3d3470a16d0 100644 --- a/app/assets/javascripts/filtered_search/droplab/utils.js +++ b/app/assets/javascripts/filtered_search/droplab/utils.js @@ -1,5 +1,3 @@ -/* eslint-disable */ - import { template as _template } from 'lodash'; import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; @@ -26,7 +24,7 @@ const utils = { closest(thisTag, stopTag) { while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') { - thisTag = thisTag.parentNode; + thisTag = thisTag.parentNode; // eslint-disable-line no-param-reassign } return thisTag; }, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index d865354881a..684375177bb 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -2,7 +2,13 @@ import { last } from 'lodash'; import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { createAlert } from '~/alert'; -import { WORKSPACE_PROJECT } from '~/issues/constants'; +import { + STATUS_ALL, + STATUS_CLOSED, + STATUS_MERGED, + STATUS_OPEN, + WORKSPACE_PROJECT, +} from '~/issues/constants'; import { ENTER_KEY_CODE, BACKSPACE_KEY_CODE, @@ -43,7 +49,7 @@ export default class FilteredSearchManager { this.isGroupAncestor = isGroupAncestor; this.isGroupDecendent = isGroupDecendent; this.useDefaultState = useDefaultState; - this.states = ['opened', 'closed', 'merged', 'all']; + this.states = [STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED, STATUS_ALL]; this.page = page; this.container = FilteredSearchContainer.container; @@ -743,7 +749,7 @@ export default class FilteredSearchManager { const { tokens, searchToken } = this.getSearchTokens(); let currentState = state || getParameterByName('state'); if (!currentState && this.useDefaultState) { - currentState = 'opened'; + currentState = STATUS_OPEN; } if (this.states.includes(currentState)) { paths.push(`state=${currentState}`); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 81da8409873..b778e05c7b1 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -343,7 +343,9 @@ class GfmAutoComplete { icon, availabilityStatus: availability && isUserBusy(availability) - ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>` + ? `<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2"> ${s__( + 'UserProfile|Busy', + )}</span>` : '', }); } diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 60f1b7f5aa4..09ee7de3b6e 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -54,6 +54,7 @@ import { __ } from '~/locale'; const errorMessageClass = 'gl-field-error'; const inputErrorClass = 'gl-field-error-outline'; +const validInputHintClass = '.gl-field-hint-valid'; const errorAnchorSelector = '.gl-field-error-anchor'; const ignoreInputSelector = '.gl-field-error-ignore'; @@ -151,6 +152,7 @@ export default class GlFieldError { renderInvalid() { this.inputElement.addClass(inputErrorClass); this.scopedSiblings.addClass('hidden'); + this.inputElement.parents('.form-group').find(validInputHintClass).addClass('hidden'); return this.fieldErrorElement.removeClass('hidden'); } diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 77fca45c949..65aa38cfb99 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -22,6 +22,7 @@ export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package'; export const TYPENAME_PROJECT = 'Project'; export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPENAME_SITE_PROFILE = 'DastSiteProfile'; +export const TYPENAME_TODO = 'Todo'; export const TYPENAME_USER = 'User'; export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner'; export const TYPENAME_VULNERABILITY = 'Vulnerability'; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 316bc746051..740eb722629 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -8,6 +8,7 @@ import typeDefs from '~/work_items/graphql/typedefs.graphql'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; import { findHierarchyWidgetChildren } from '~/work_items/utils'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export const config = { typeDefs, @@ -81,6 +82,14 @@ export const config = { }); }, }, + userPermissions: { + read(permission = {}) { + return { + ...permission, + setWorkItemMetadata: false, + }; + }, + }, }, }, MemberInterfaceConnection: { @@ -126,6 +135,33 @@ export const config = { }; }, }, + Group: { + fields: { + projects: { + keyArgs: ['includeSubgroups', 'search'], + }, + descendantGroups: { + keyArgs: ['includeSubgroups', 'search'], + }, + }, + }, + ProjectConnection: { + fields: { + nodes: concatPagination(), + }, + }, + GroupConnection: { + fields: { + nodes: concatPagination(), + }, + }, + Board: { + fields: { + epics: { + keyArgs: ['boardId'], + }, + }, + }, BoardEpicConnection: { merge(existing = { nodes: [] }, incoming, { args }) { if (!args.after) { @@ -174,6 +210,13 @@ export const resolvers = { }); cache.writeQuery({ query: getIssueStateQuery, data }); }, + setActiveBoardItem(_, { boardItem }, { cache }) { + cache.writeQuery({ + query: activeBoardItemQuery, + data: { activeBoardItem: boardItem }, + }); + return boardItem; + }, }, }; diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 22629dfb7d8..f7d1efc4d1f 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -21,7 +21,8 @@ "Epic", "EpicIssue", "Issue", - "MergeRequest" + "MergeRequest", + "WorkItemWidgetCurrentUserTodos" ], "DependencyLinkMetadata": [ "NugetDependencyLinkMetadata" @@ -145,6 +146,8 @@ ], "WorkItemWidget": [ "WorkItemWidgetAssignees", + "WorkItemWidgetAwardEmoji", + "WorkItemWidgetCurrentUserTodos", "WorkItemWidgetDescription", "WorkItemWidgetHealthStatus", "WorkItemWidgetHierarchy", diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql index d8760f147e1..28405d9dc9b 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql +++ b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql @@ -10,5 +10,9 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) { } } } + ... on Issue { + id + dueDate + } } } diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue index 535758750f9..cba13c11c5d 100644 --- a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue @@ -8,14 +8,14 @@ export default { i18n: { title: s__('GroupsEmptyState|No archived projects.'), }, - inject: ['newProjectIllustration'], + inject: ['emptyProjectsIllustration'], }; </script> <template> <gl-empty-state :title="$options.i18n.title" - :svg-path="newProjectIllustration" + :svg-path="emptyProjectsIllustration" :svg-height="100" /> </template> diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue index 7223321bf3e..7c691b56a43 100644 --- a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue @@ -8,14 +8,14 @@ export default { i18n: { title: s__('GroupsEmptyState|No shared projects.'), }, - inject: ['newProjectIllustration'], + inject: ['emptyProjectsIllustration'], }; </script> <template> <gl-empty-state :title="$options.i18n.title" - :svg-path="newProjectIllustration" + :svg-path="emptyProjectsIllustration" :svg-height="100" /> </template> diff --git a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue index 955cb1ca63e..0bd95d59022 100644 --- a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue @@ -43,6 +43,7 @@ export default { 'newProjectPath', 'newSubgroupIllustration', 'newProjectIllustration', + 'emptyProjectsIllustration', 'emptySubgroupIllustration', 'canCreateSubgroups', 'canCreateProjects', diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index d9781ef9c84..8d202194de7 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -16,8 +16,12 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; -import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants'; -import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants'; +import { + VISIBILITY_LEVELS_STRING_TO_INTEGER, + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, +} from '~/visibility_level/constants'; +import { ITEM_TYPE } from '../constants'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index a4c163b0a81..5674e28f5da 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -2,12 +2,7 @@ import { GlBadge } from '@gitlab/ui'; import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { - ITEM_TYPE, - VISIBILITY_TYPE_ICON, - GROUP_VISIBILITY_TYPE, - PROJECT_VISIBILITY_TYPE, -} from '../constants'; +import { ITEM_TYPE } from '../constants'; import ItemStatsValue from './item_stats_value.vue'; export default { @@ -24,15 +19,6 @@ export default { }, }, computed: { - visibilityIcon() { - return VISIBILITY_TYPE_ICON[this.item.visibility]; - }, - visibilityTooltip() { - if (this.item.type === ITEM_TYPE.GROUP) { - return GROUP_VISIBILITY_TYPE[this.item.visibility]; - } - return PROJECT_VISIBILITY_TYPE[this.item.visibility]; - }, isProject() { return this.item.type === ITEM_TYPE.PROJECT; }, diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 6f5b03788a8..a5854632040 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -1,9 +1,4 @@ import { __, s__ } from '~/locale'; -import { - VISIBILITY_LEVEL_PRIVATE_STRING, - VISIBILITY_LEVEL_INTERNAL_STRING, - VISIBILITY_LEVEL_PUBLIC_STRING, -} from '~/visibility_level/constants'; export const MAX_CHILDREN_COUNT = 20; @@ -30,36 +25,6 @@ export const ITEM_TYPE = { GROUP: 'group', }; -export const GROUP_VISIBILITY_TYPE = { - [VISIBILITY_LEVEL_PUBLIC_STRING]: __( - 'Public - The group and any public projects can be viewed without any authentication.', - ), - [VISIBILITY_LEVEL_INTERNAL_STRING]: __( - 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', - ), - [VISIBILITY_LEVEL_PRIVATE_STRING]: __( - 'Private - The group and its projects can only be viewed by members.', - ), -}; - -export const PROJECT_VISIBILITY_TYPE = { - [VISIBILITY_LEVEL_PUBLIC_STRING]: __( - 'Public - The project can be accessed without any authentication.', - ), - [VISIBILITY_LEVEL_INTERNAL_STRING]: __( - 'Internal - The project can be accessed by any logged in user except external users.', - ), - [VISIBILITY_LEVEL_PRIVATE_STRING]: __( - 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', - ), -}; - -export const VISIBILITY_TYPE_ICON = { - [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth', - [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', - [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock', -}; - export const OVERVIEW_TABS_SORTING_ITEMS = [ { label: __('Name'), diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index c3bf3f28509..f6711bde7d0 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -51,6 +51,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups, canCreateProjects, @@ -63,6 +64,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), diff --git a/app/assets/javascripts/groups/init_group_readme.js b/app/assets/javascripts/groups/init_group_readme.js new file mode 100644 index 00000000000..7cde64fed4d --- /dev/null +++ b/app/assets/javascripts/groups/init_group_readme.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import apolloProvider from '~/repository/graphql'; +import FilePreview from '~/repository/components/preview/index.vue'; + +Vue.use(VueApollo); + +export const initGroupReadme = () => { + const el = document.getElementById('js-group-readme'); + + if (!el) return false; + + const { webPath, name } = el.dataset; + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(FilePreview, { + props: { + blob: { webPath, name }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index 664d07ca13d..4064520d1ca 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -44,6 +44,7 @@ export const initGroupOverviewTabs = () => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups, canCreateProjects, @@ -62,6 +63,7 @@ export const initGroupOverviewTabs = () => { newProjectPath, newSubgroupIllustration, newProjectIllustration, + emptyProjectsIllustration, emptySubgroupIllustration, canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), diff --git a/app/assets/javascripts/groups/settings/components/group_settings_readme.vue b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue new file mode 100644 index 00000000000..123c7fc58f5 --- /dev/null +++ b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue @@ -0,0 +1,147 @@ +<script> +import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { createProject } from '~/rest_api'; +import { createAlert } from '~/alert'; +import { openWebIDE } from '~/lib/utils/web_ide_navigator'; +import { README_MODAL_ID, GITLAB_README_PROJECT, README_FILE } from '../constants'; + +export default { + name: 'GroupSettingsReadme', + i18n: { + readme: __('README'), + addReadme: __('Add README'), + cancel: __('Cancel'), + createProjectAndReadme: s__('Groups|Create and add README'), + creatingReadme: s__('Groups|Creating README'), + existingProjectNewReadme: s__('Groups|This will create a README.md for project %{path}.'), + newProjectAndReadme: s__('Groups|This will create a project %{path} and add a README.md.'), + errorCreatingProject: s__('Groups|There was an error creating the Group README.'), + }, + components: { + GlButton, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + groupReadmePath: { + type: String, + required: false, + default: '', + }, + readmeProjectPath: { + type: String, + required: false, + default: '', + }, + groupPath: { + type: String, + required: true, + }, + groupId: { + type: String, + required: true, + }, + }, + data() { + return { + creatingReadme: false, + }; + }, + computed: { + hasReadme() { + return this.groupReadmePath.length > 0; + }, + hasReadmeProject() { + return this.readmeProjectPath.length > 0; + }, + pathToReadmeProject() { + return this.hasReadmeProject + ? this.readmeProjectPath + : `${this.groupPath}/${GITLAB_README_PROJECT}`; + }, + modalBody() { + return this.hasReadmeProject + ? this.$options.i18n.existingProjectNewReadme + : this.$options.i18n.newProjectAndReadme; + }, + modalSubmitButtonText() { + return this.hasReadmeProject + ? this.$options.i18n.addReadme + : this.$options.i18n.createProjectAndReadme; + }, + }, + methods: { + hideModal() { + this.$refs.modal.hide(); + }, + createReadme() { + if (this.hasReadmeProject) { + openWebIDE(this.readmeProjectPath, README_FILE); + } else { + this.createProjectWithReadme(); + } + }, + createProjectWithReadme() { + this.creatingReadme = true; + + const projectData = { + name: GITLAB_README_PROJECT, + namespace_id: this.groupId, + }; + + createProject(projectData) + .then(({ path_with_namespace: pathWithNamespace }) => { + openWebIDE(pathWithNamespace, README_FILE); + }) + .catch(() => { + this.hideModal(); + this.creatingReadme = false; + createAlert({ message: this.$options.i18n.errorCreatingProject }); + }); + }, + }, + README_MODAL_ID, +}; +</script> + +<template> + <div> + <gl-button v-if="hasReadme" icon="doc-text" :href="groupReadmePath">{{ + $options.i18n.readme + }}</gl-button> + <gl-button + v-else + v-gl-modal="$options.README_MODAL_ID" + variant="dashed" + icon="file-addition" + data-testid="group-settings-add-readme-button" + >{{ $options.i18n.addReadme }}</gl-button + > + <gl-modal ref="modal" :modal-id="$options.README_MODAL_ID" :title="$options.i18n.addReadme"> + <div data-testid="group-settings-modal-readme-body"> + <gl-sprintf :message="modalBody"> + <template #path> + <code>{{ pathToReadmeProject }}</code> + </template> + </gl-sprintf> + </div> + <template #modal-footer> + <gl-button variant="default" @click="hideModal">{{ $options.i18n.cancel }}</gl-button> + <gl-button v-if="creatingReadme" variant="default" loading disabled>{{ + $options.i18n.creatingReadme + }}</gl-button> + <gl-button + v-else + variant="confirm" + data-testid="group-settings-modal-create-readme-button" + @click="createReadme" + >{{ modalSubmitButtonText }}</gl-button + > + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js index c91c2a20529..023ddf29b36 100644 --- a/app/assets/javascripts/groups/settings/constants.js +++ b/app/assets/javascripts/groups/settings/constants.js @@ -1,3 +1,7 @@ export const LEVEL_TYPES = { GROUP: 'group', }; + +export const README_MODAL_ID = 'add_group_readme_modal'; +export const GITLAB_README_PROJECT = 'gitlab-profile'; +export const README_FILE = 'README.md'; diff --git a/app/assets/javascripts/groups/settings/init_group_settings_readme.js b/app/assets/javascripts/groups/settings/init_group_settings_readme.js new file mode 100644 index 00000000000..d126228d854 --- /dev/null +++ b/app/assets/javascripts/groups/settings/init_group_settings_readme.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import GroupSettingsReadme from './components/group_settings_readme.vue'; + +export const initGroupSettingsReadme = () => { + const el = document.getElementById('js-group-settings-readme'); + + if (!el) return false; + + const { groupReadmePath, readmeProjectPath, groupPath, groupId } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(GroupSettingsReadme, { + props: { + groupReadmePath, + readmeProjectPath, + groupPath, + groupId, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 9cb96283689..25a84d17379 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -37,7 +37,7 @@ export function initStatusTriggers() { const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl); Tracking.event(undefined, 'click_button', { label: 'user_edit_status', - property: buttonWithinTopNav ? 'navigation_top' : undefined, + property: buttonWithinTopNav ? 'navigation_top' : 'nav_user_menu', }); import( @@ -135,6 +135,8 @@ function initNewNavToggle() { }); } -requestIdleCallback(initStatusTriggers); +if (!gon?.use_new_navigation) { + requestIdleCallback(initStatusTriggers); +} requestIdleCallback(initNavUserDropdownTracking); requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index c0a06706fc6..aa349186014 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,7 +1,6 @@ <script> import { GlSearchBoxByType, - GlOutsideDirective as Outside, GlIcon, GlToken, GlTooltipDirective, @@ -36,6 +35,7 @@ import { IS_SEARCHING, IS_FOCUSED, IS_NOT_FOCUSED, + DROPDOWN_CLOSE_TIMEOUT, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -53,7 +53,7 @@ export default { SEARCH_RESULTS_SCOPE, KBD_HELP, }, - directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, + directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, HeaderSearchDefaultItems, @@ -65,7 +65,6 @@ export default { }, data() { return { - showDropdown: false, isFocused: false, currentFocusIndex: SEARCH_BOX_INDEX, }; @@ -91,7 +90,7 @@ export default { return Boolean(gon?.current_username); }, showSearchDropdown() { - if (!this.showDropdown || !this.isLoggedIn) { + if (!this.isFocused || !this.isLoggedIn) { return false; } return this.searchOptions?.length > 0; @@ -108,7 +107,6 @@ export default { } return FIRST_DROPDOWN_INDEX; }, - searchInputDescribeBy() { if (this.isLoggedIn) { return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; @@ -160,29 +158,18 @@ export default { methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { - this.showDropdown = true; - - // check isFocused state to avoid firing duplicate events - if (!this.isFocused) { - this.isFocused = true; - this.$emit('expandSearchBar', true); + this.isFocused = true; + this.$emit('expandSearchBar'); - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - } - }, - closeDropdown() { - this.showDropdown = false; + Tracking.event(undefined, 'focus_input', { + label: 'global_search', + property: 'navigation_top', + }); }, collapseAndCloseSearchBar() { - // we need a delay on this method - // for the search bar not to remove - // the clear button from dom - // and register clicks on dropdown items + // without timeout dropdown closes + // before click event is dispatched setTimeout(() => { - this.showDropdown = false; this.isFocused = false; this.$emit('collapseSearchBar'); @@ -190,7 +177,7 @@ export default { label: 'global_search', property: 'navigation_top', }); - }, 200); + }, DROPDOWN_CLOSE_TIMEOUT); }, submitSearch() { if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { @@ -226,7 +213,6 @@ export default { <template> <form - v-outside="closeDropdown" role="search" :aria-label="$options.i18n.SEARCH_GITLAB" class="header-search gl-relative gl-rounded-base gl-w-full" @@ -244,12 +230,11 @@ export default { :placeholder="$options.i18n.SEARCH_GITLAB" :aria-activedescendant="currentFocusedId" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focus="openDropdown" - @click="openDropdown" - @blur="collapseAndCloseSearchBar" + @focusin="openDropdown" + @focusout="collapseAndCloseSearchBar" @input="getAutocompleteOptions" @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="closeDropdown" + @keydown.esc.stop.prevent="collapseAndCloseSearchBar" /> <gl-token v-if="showScopeHelp" @@ -301,7 +286,7 @@ export default { :max="searchOptions.length - 1" :min="$options.FIRST_DROPDOWN_INDEX" :default-index="defaultIndex" - @tab="closeDropdown" + :enable-cycle="true" /> <header-search-default-items v-if="showDefaultItems" diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index b9bb4e573fd..47aeb2f9caa 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -31,3 +31,5 @@ export const IS_NOT_FOCUSED = 'is-not-focused'; export const FETCH_TYPES = ['generic', 'search']; export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; + +export const DROPDOWN_CLOSE_TIMEOUT = 200; diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js index 4e9404007ec..64502d13ee2 100644 --- a/app/assets/javascripts/header_search/init.js +++ b/app/assets/javascripts/header_search/init.js @@ -2,29 +2,18 @@ import * as Sentry from '@sentry/browser'; import { HEADER_INIT_EVENTS } from './constants'; async function eventHandler(callback = () => {}) { - if (this.newHeaderSearchFeatureFlag) { - const { initHeaderSearchApp } = await import( - /* webpackChunkName: 'globalSearch' */ '~/header_search' - ).catch((error) => Sentry.captureException(error)); - - // In case the user started searching before we bootstrapped, - // let's pass the search along. - const initialSearchValue = this.searchInputBox.value; - initHeaderSearchApp(initialSearchValue); - - // this is new #search input element. We need to re-find it. - // And re-focus in it. - document.querySelector('#search').focus(); - callback(); - return; - } - - const { default: initSearchAutocomplete } = await import( - /* webpackChunkName: 'globalSearch' */ '../search_autocomplete' + const { initHeaderSearchApp } = await import( + /* webpackChunkName: 'globalSearch' */ '~/header_search' ).catch((error) => Sentry.captureException(error)); - const searchDropdown = initSearchAutocomplete(); - searchDropdown.onSearchInputFocus(); + // In case the user started searching before we bootstrapped, + // let's pass the search along. + const initialSearchValue = this.searchInputBox.value; + initHeaderSearchApp(initialSearchValue); + + // this is new #search input element. We need to re-find it. + // And re-focus in it. + document.querySelector('#search').focus(); callback(); } @@ -40,10 +29,7 @@ function initHeaderSearch() { HEADER_INIT_EVENTS.forEach((eventType) => { searchInputBox?.addEventListener( eventType, - eventHandler.bind( - { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch }, - cleanEventListeners, - ), + eventHandler.bind({ searchInputBox }, cleanEventListeners), { once: true }, ); }); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 92dacf8c94a..d788104edc8 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -43,6 +43,7 @@ export default { data-container="body" data-placement="right" data-qa-selector="edit_mode_tab" + data-testid="edit-mode-button" type="button" class="ide-sidebar-link js-ide-edit-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)" @@ -60,6 +61,7 @@ export default { :aria-label="s__('IDE|Review')" data-container="body" data-placement="right" + data-testid="review-mode-button" type="button" class="ide-sidebar-link js-ide-review-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)" @@ -78,6 +80,7 @@ export default { data-container="body" data-placement="right" data-qa-selector="commit_mode_tab" + data-testid="commit-mode-button" type="button" class="ide-sidebar-link js-ide-commit-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 2799ea1378e..d05aa960f01 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -82,7 +82,7 @@ export default { {{ __('Commit Message') }} <div id="ide-commit-message-popover-container"> <span id="ide-commit-message-question" class="form-text text-muted gl-ml-3"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </span> <gl-popover target="ide-commit-message-question" diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index ea1dbee4669..9f83de840b9 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -69,7 +69,7 @@ export default { > <gl-icon name="ellipsis_v" /> </button> - <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right"> + <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right" data-testid="dropdown-menu"> <template v-if="type === 'tree'"> <li> <item-button diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue index 7fca7429ad7..428cf7f55ac 100644 --- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue +++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue @@ -82,7 +82,7 @@ export default { <div>{{ __('Commit Message') }}</div> <div id="commit-message-popover-container"> <span id="commit-message-question" class="gl-gray-700 gl-ml-3"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </span> <gl-popover target="commit-message-question" diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 967c83b320f..29c44d2f596 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -72,7 +72,6 @@ export const initLegacyWebIDE = (el, options = {}) => { environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, userPreferencesPath: el.dataset.userPreferencesPath, - learnGitlabSource: parseBoolean(el.dataset.learnGitlabSource), }); }, beforeDestroy() { diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 4d3cefcb107..51af73decad 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -67,6 +67,7 @@ export const initGitlabWebIDE = async (el) => { links: { feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, userPreferences: el.dataset.userPreferencesPath, + signIn: el.dataset.signInPath, }, editorFont: { srcUrl: editorFontSrcUrl, diff --git a/app/assets/javascripts/ide/lib/languages/codeowners.js b/app/assets/javascripts/ide/lib/languages/codeowners.js new file mode 100644 index 00000000000..e2eed713801 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/codeowners.js @@ -0,0 +1,39 @@ +const conf = { + comments: { + lineComment: '#', + }, + autoClosingPairs: [{ open: '[', close: ']' }], + surroundingPairs: [{ open: '[', close: ']' }], +}; + +const language = { + tokenizer: { + root: [ + // comment + [/^#.*$/, 'comment'], + + // optional approval + [/^\^/, 'constant.numeric'], + + // number of approvers + [/\[\d+\]$/, 'constant.numeric'], + + // section + [/\[(?!\d+\])[^\]]+\]/, 'namespace'], + + // pattern + [/^\s*(\S+)/, 'regexp'], + + // owner + [/\S*@.*$/, 'variable.value'], + ], + }, +}; + +export default { + id: 'codeowners', + extensions: ['codeowners'], + aliases: ['CODEOWNERS'], + conf, + language, +}; diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js index f758cb7dd86..c2ab954eb73 100644 --- a/app/assets/javascripts/ide/lib/languages/index.js +++ b/app/assets/javascripts/ide/lib/languages/index.js @@ -1,6 +1,7 @@ import hcl from './hcl'; import vue from './vue'; +import codeowners from './codeowners'; -const languages = [vue, hcl]; +const languages = [vue, hcl, codeowners]; export default languages; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 9f1eae03685..06751b926b5 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,4 +1,5 @@ import { createAlert } from '~/alert'; +import { STATUS_OPEN } from '~/issues/constants'; import { __ } from '~/locale'; import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants'; import service from '../../services'; @@ -16,7 +17,7 @@ export const getMergeRequestsForBranch = ( .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, source_project_id: state.projects[projectId].id, - state: 'opened', + state: STATUS_OPEN, order_by: 'created_at', per_page: 1, }) diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 572465f7587..79a8ccf2285 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,7 +1,6 @@ import { createAlert } from '~/alert'; import { addNumericSuffix } from '~/ide/utils'; import { sprintf, __ } from '~/locale'; -import Tracking from '~/tracking'; import { leftSidebarViews } from '../../../constants'; import eventHub from '../../../eventhub'; import { parseCommitError } from '../../../lib/errors'; @@ -163,10 +162,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ); } - if (rootState.learnGitlabSource) { - Tracking.event(undefined, 'commit', { label: 'web_ide_learn_gitlab_source' }); - } - dispatch('setLastCommitMessage', data); dispatch('updateCommitMessage', ''); return dispatch('updateFilesAfterCommit', { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 013a0c3ce8f..356bbf28a48 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -32,5 +32,4 @@ export default () => ({ environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', userPreferencesPath: '', - learnGitlabSource: false, }); diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js new file mode 100644 index 00000000000..b9814b5ca60 --- /dev/null +++ b/app/assets/javascripts/import/constants.js @@ -0,0 +1,28 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { __, s__ } from '~/locale'; + +const STATISTIC_ITEMS = { + diff_note: __('Diff notes'), + issue: __('Issues'), + issue_attachment: s__('GithubImporter|Issue links'), + issue_event: __('Issue events'), + label: __('Labels'), + lfs_object: __('LFS objects'), + merge_request_attachment: s__('GithubImporter|Merge request links'), + milestone: __('Milestones'), + note: __('Notes'), + note_attachment: s__('GithubImporter|Note links'), + protected_branch: __('Protected branches'), + collaborator: s__('GithubImporter|Collaborators'), + pull_request: s__('GithubImporter|Pull requests'), + pull_request_merged_by: s__('GithubImporter|PR mergers'), + pull_request_review: s__('GithubImporter|PR reviews'), + pull_request_review_request: s__('GithubImporter|PR reviews'), + release: __('Releases'), + release_attachment: s__('GithubImporter|Release links'), +}; + +// support both camel case and snake case versions +Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS)); + +export { STATISTIC_ITEMS }; diff --git a/app/assets/javascripts/import/details/components/import_details_app.vue b/app/assets/javascripts/import/details/components/import_details_app.vue new file mode 100644 index 00000000000..86820663025 --- /dev/null +++ b/app/assets/javascripts/import/details/components/import_details_app.vue @@ -0,0 +1,25 @@ +<script> +import { s__ } from '~/locale'; +import ImportDetailsTable from './import_details_table.vue'; + +export default { + components: { ImportDetailsTable }, + props: { + project: { + type: Object, + required: false, + default: () => ({}), + }, + }, + i18n: { + pageTitle: s__('Import|GitHub import details'), + }, +}; +</script> + +<template> + <div> + <h1>{{ $options.i18n.pageTitle }}</h1> + <import-details-table /> + </div> +</template> diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue new file mode 100644 index 00000000000..9ce58e8a9bc --- /dev/null +++ b/app/assets/javascripts/import/details/components/import_details_table.vue @@ -0,0 +1,106 @@ +<script> +import { GlEmptyState, GlIcon, GlLink, GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import { STATISTIC_ITEMS } from '../../constants'; + +const DEFAULT_PAGE_SIZE = 20; + +export default { + components: { + GlEmptyState, + GlIcon, + GlLink, + GlTable, + PaginationBar, + }, + STATISTIC_ITEMS, + LOCAL_STORAGE_KEY: 'gl-import-details-page-size', + fields: [ + { + key: 'type', + label: __('Type'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'title', + label: __('Title'), + tdClass: 'gl-md-w-30 gl-word-break-word', + }, + { + key: 'url', + label: __('URL'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'details', + label: __('Details'), + }, + ], + data() { + return { + page: 1, + perPage: DEFAULT_PAGE_SIZE, + }; + }, + computed: { + items() { + return []; + }, + + hasItems() { + return this.items.length > 0; + }, + + pageInfo() { + const mockPageInfo = { + page: this.page, + perPage: this.perPage, + totalPages: this.page, + total: this.items.length, + }; + return mockPageInfo; + }, + }, + + methods: { + setPage(page) { + this.page = page; + }, + + setPageSize(size) { + this.perPage = size; + this.page = 1; + }, + }, +}; +</script> + +<template> + <div> + <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" show-empty> + <template #empty> + <gl-empty-state :title="s__('Import|No import details')" /> + </template> + + <template #cell(type)="{ item: { type } }"> + {{ $options.STATISTIC_ITEMS[type] }} + </template> + <template #cell(url)="{ item: { url } }"> + <gl-link v-if="url" :href="url" target="_blank"> + {{ url }} + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-table> + <pagination-bar + v-if="hasItems" + :page-info="pageInfo" + class="gl-mt-5" + :storage-key="$options.LOCAL_STORAGE_KEY" + @set-page="setPage" + @set-page-size="setPageSize" + /> + </div> +</template> diff --git a/app/assets/javascripts/import/details/index.js b/app/assets/javascripts/import/details/index.js new file mode 100644 index 00000000000..70850d947e2 --- /dev/null +++ b/app/assets/javascripts/import/details/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import ImportDetailsApp from './components/import_details_app.vue'; + +export default () => { + const el = document.querySelector('.js-import-details'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'ImportDetailsRoot', + render(createElement) { + return createElement(ImportDetailsApp); + }, + }); +}; diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index ec2ab9d0c3d..96d07803545 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -1,32 +1,10 @@ <script> -import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { STATUSES } from '../constants'; - -const STATISTIC_ITEMS = { - diff_note: __('Diff notes'), - issue: __('Issues'), - issue_attachment: s__('GithubImporter|Issue links'), - issue_event: __('Issue events'), - label: __('Labels'), - lfs_object: __('LFS objects'), - merge_request_attachment: s__('GithubImporter|Merge request links'), - milestone: __('Milestones'), - note: __('Notes'), - note_attachment: s__('GithubImporter|Note links'), - protected_branch: __('Protected branches'), - collaborator: s__('GithubImporter|Collaborators'), - pull_request: s__('GithubImporter|Pull requests'), - pull_request_merged_by: s__('GithubImporter|PR mergers'), - pull_request_review: s__('GithubImporter|PR reviews'), - pull_request_review_request: s__('GithubImporter|PR reviews'), - release: __('Releases'), - release_attachment: s__('GithubImporter|Release links'), -}; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -// support both camel case and snake case versions -Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS)); +import { STATISTIC_ITEMS } from '~/import/constants'; +import { STATUSES } from '../constants'; const SCHEDULED_STATUS = { icon: 'status-scheduled', @@ -78,6 +56,13 @@ export default { GlAccordionItem, GlBadge, GlIcon, + GlLink, + }, + mixins: [glFeatureFlagMixin()], + inject: { + detailsPath: { + default: undefined, + }, }, props: { status: { @@ -103,13 +88,16 @@ export default { return this.stats && this.knownStats.length > 0; }, + isIncomplete() { + return this.status === STATUSES.FINISHED && this.stats && isIncompleteImport(this.stats); + }, + mappedStatus() { if (this.status === STATUSES.FINISHED) { - const isIncomplete = this.stats && isIncompleteImport(this.stats); - return isIncomplete + return this.isIncomplete ? { icon: 'status-alert', - text: __('Partial import'), + text: s__('Import|Partially completed'), variant: 'warning', } : { @@ -121,6 +109,10 @@ export default { return STATUS_MAP[this.status]; }, + + showDetails() { + return Boolean(this.detailsPath) && this.glFeatures.importDetailsPage && this.isIncomplete; + }, }, methods: { @@ -141,25 +133,22 @@ export default { }, STATISTIC_ITEMS, + i18n: { + detailsLink: s__('Import|See failures'), + }, }; </script> <template> <div> - <div class="gl-display-inline-block gl-w-13"> - <gl-badge - :icon="mappedStatus.icon" - :variant="mappedStatus.variant" - size="md" - icon-size="sm" - class="gl-mr-2" - > + <div class="gl-display-inline-block"> + <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" icon-size="sm"> {{ mappedStatus.text }} </gl-badge> </div> <gl-accordion v-if="hasStats" :header-level="3"> <gl-accordion-item :title="__('Details')"> - <ul class="gl-p-0 gl-list-style-none gl-font-sm"> + <ul class="gl-p-0 gl-mb-3 gl-list-style-none gl-font-sm"> <li v-for="key in knownStats" :key="key"> <div class="gl-display-flex gl-w-20 gl-align-items-center"> <gl-icon @@ -174,6 +163,7 @@ export default { </div> </li> </ul> + <gl-link v-if="showDetails" :href="detailsPath">{{ $options.i18n.detailsLink }}</gl-link> </gl-accordion-item> </gl-accordion> </div> 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 aaa37f145aa..55a8bad27b9 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 @@ -180,18 +180,20 @@ export default { class="gl-mb-5" /> <div v-if="repositories.length" class="gl-w-full"> - <table> - <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100"> - <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> - {{ fromHeaderText }} - </th> - <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> - {{ __('To GitLab') }} - </th> - <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"> - {{ __('Status') }} - </th> - <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th> + <table class="table gl-table"> + <thead> + <tr> + <th class="gl-w-half"> + {{ fromHeaderText }} + </th> + <th class="gl-w-half"> + {{ __('To GitLab') }} + </th> + <th> + {{ __('Status') }} + </th> + <th></th> + </tr> </thead> <tbody> <template v-for="repo in repositories"> 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 265cca9070e..b20309baac7 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 @@ -155,16 +155,16 @@ export default { <template> <tr - class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top" + class="gl-h-11" data-qa-selector="project_import_row" :data-qa-source-project="repo.importSource.fullName" > - <td class="gl-p-4 gl-vertical-align-top"> + <td> <gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink" >{{ repo.importSource.fullName }} <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </gl-link> - <div v-if="isFinished" class="gl-font-sm"> + <div v-if="isFinished" class="gl-font-sm gl-mt-2"> <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')"> <template #link> <gl-link @@ -179,52 +179,50 @@ export default { </gl-sprintf> </div> </td> - <td - class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top" - data-testid="fullPath" - data-qa-selector="project_path_content" - > - <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> - <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" - > - / + <td data-testid="fullPath" data-qa-selector="project_path_content"> + <div class="gl-display-flex gl-sm-flex-wrap"> + <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> + <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" + > + / + </div> + <gl-form-input + ref="newNameInput" + v-model="newNameInput" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + data-qa-selector="project_path_field" + /> </div> - <gl-form-input - ref="newNameInput" - v-model="newNameInput" - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - data-qa-selector="project_path_field" - /> - </div> - </template> - <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> + </template> + <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> + </div> </td> - <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator"> + <td data-qa-selector="import_status_indicator"> <import-status :status="importStatus" :stats="stats" /> </td> - <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap"> + <td data-testid="actions" class="gl-white-space-nowrap"> <gl-tooltip :target="() => $refs.cancelButton.$el"> <div class="gl-text-left"> <p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p> diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 66ffd378426..f898e23b47a 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -66,12 +66,16 @@ export default function mountImportProjectsTable({ const store = initStoreFromElement(mountElement); const props = initPropsFromElement(mountElement); + const { detailsPath } = mountElement.dataset; return new Vue({ el: mountElement, name: 'ImportProjectsRoot', store, apolloProvider, + provide: { + detailsPath, + }, render(createElement) { // We are using attrs instead of props so root-level component with inheritAttrs // will be able to pass them down diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index f8e70fea7aa..e15cb2224f4 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -12,6 +12,7 @@ import { GlEmptyState, } from '@gitlab/ui'; import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; +import { STATUS_CLOSED } from '~/issues/constants'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { s__, n__ } from '~/locale'; import { INCIDENT_SEVERITY } from '~/sidebar/constants'; @@ -301,6 +302,9 @@ export default { getEscalationStatus(escalationStatus) { return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus; }, + isClosed(item) { + return item.state === STATUS_CLOSED; + }, showIncidentLink({ iid }) { return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid); }, @@ -397,7 +401,7 @@ export default { <template #cell(title)="{ item }"> <div :class="{ - 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed', + 'gl-display-flex gl-align-items-center gl-max-w-full': isClosed(item), }" > <gl-link @@ -411,7 +415,7 @@ export default { </tooltip-on-truncate> </gl-link> <gl-icon - v-if="item.state === 'closed'" + v-if="isClosed(item)" name="issue-close" class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0" :size="16" diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index dde40ec2983..6f8d5cf5f89 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { s__ } from '~/locale'; export const I18N = { @@ -51,11 +50,13 @@ export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; export const INCIDENT_DETAILS_PATH = 'incident'; +const category = 'Incident Management'; // eslint-disable-line @gitlab/require-i18n-strings + /** * Tracks snowplow event when user clicks create new incident */ export const trackIncidentCreateNewOptions = { - category: 'Incident Management', + category, action: 'create_incident_button_clicks', }; @@ -63,7 +64,7 @@ export const trackIncidentCreateNewOptions = { * Tracks snowplow event when user views incidents list */ export const trackIncidentListViewsOptions = { - category: 'Incident Management', + category, action: 'view_incidents_list', }; @@ -71,6 +72,6 @@ export const trackIncidentListViewsOptions = { * Tracks snowplow event when user views incident details */ export const trackIncidentDetailsViewsOptions = { - category: 'Incident Management', + category, action: 'view_incident_details', }; diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js index 8413fe92f89..82350a3987e 100644 --- a/app/assets/javascripts/init_diff_stats_dropdown.js +++ b/app/assets/javascripts/init_diff_stats_dropdown.js @@ -4,7 +4,15 @@ import { stickyMonitor } from './lib/utils/sticky'; export const initDiffStatsDropdown = (stickyTop) => { if (stickyTop) { - stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false); + // We spend quite a bit of effort in our CSS to set the correct padding-top on the + // layout page, so we re-use the padding set there to determine at what height our + // element should be sticky + const pageLayout = document.querySelector('.layout-page'); + const pageLayoutTopOffset = pageLayout + ? parseFloat(window.getComputedStyle(pageLayout).getPropertyValue('padding-top') || 0) + : 0; + + stickyMonitor(document.querySelector('.js-diff-files-changed'), pageLayoutTopOffset, false); } const el = document.querySelector('.js-diff-stats-dropdown'); diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue index 767675cc64c..aaa04dc4b43 100644 --- a/app/assets/javascripts/invite_members/components/invite_group_notification.vue +++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue @@ -1,12 +1,7 @@ <script> import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { GROUP_MODAL_ALERT_BODY } from '../constants'; - -const SHARE_GROUP_LINK = - 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group'; export default { - SHARE_GROUP_LINK, name: 'InviteGroupNotification', components: { GlAlert, GlSprintf, GlLink }, inject: ['freeUsersLimit'], @@ -15,18 +10,23 @@ export default { type: String, required: true, }, - }, - i18n: { - body: GROUP_MODAL_ALERT_BODY, + notificationText: { + type: String, + required: true, + }, + notificationLink: { + type: String, + required: true, + }, }, }; </script> <template> <gl-alert variant="warning" :dismissible="false"> - <gl-sprintf :message="$options.i18n.body"> + <gl-sprintf :message="notificationText"> <template #link="{ content }"> - <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{ + <gl-link :href="notificationLink" target="_blank" class="gl-label-link">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 3be3b9df747..51355baef99 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -190,7 +190,13 @@ export default { @submit="sendInvite" > <template #alert> - <invite-group-notification v-if="freeUserCapEnabled" :name="name" /> + <invite-group-notification + v-if="freeUserCapEnabled" + :name="name" + :notification-text="$options.labels[inviteTo].notificationText" + :notification-link="$options.labels[inviteTo].notificationLink" + class="gl-mb-5" + /> </template> <template #select> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index 812e39e6392..e99a61caf3f 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -13,20 +13,21 @@ import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import Tracking from '~/tracking'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { getParameterValues } from '~/lib/utils/url_utility'; import { n__, sprintf } from '~/locale'; import { + memberName, + triggerExternalAlert, + qualifiesForTasksToBeDone, +} from 'ee_else_ce/invite_members/utils/member_utils'; +import { USERS_FILTER_ALL, INVITE_MEMBERS_FOR_TASK, MEMBER_MODAL_LABELS, - LEARN_GITLAB, INVITE_MEMBER_MODAL_TRACKING_CATEGORY, } from '../constants'; import eventHub from '../event_hub'; import { responseFromSuccess } from '../utils/response_message_parser'; -import { memberName } from '../utils/member_utils'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import { displaySuccessfulInvitationAlert, @@ -170,11 +171,7 @@ export default { ); }, tasksToBeDoneEnabled() { - return ( - (getParameterValues('open_modal')[0] === 'invite_members_for_task' || - this.isOnLearnGitlab) && - this.tasksToBeDoneOptions.length - ); + return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length; }, showTasksToBeDone() { return ( @@ -193,9 +190,6 @@ export default { ? this.selectedTaskProject.id : ''; }, - isOnLearnGitlab() { - return this.source === LEARN_GITLAB; - }, showUserLimitNotification() { return !isEmpty(this.usersLimitDataset.alertVariant); }, @@ -248,14 +242,10 @@ export default { eventHub.$on('openModal', (options) => { this.openModal(options); - if (this.isOnLearnGitlab) { - this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source); - } }); if (this.tasksToBeDoneEnabled) { this.openModal({ source: 'in_product_marketing_email' }); - this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view); } }, methods: { @@ -283,16 +273,29 @@ export default { closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, - trackEvent(experimentName, eventName) { - const tracking = new ExperimentTracking(experimentName); - tracking.event(eventName); - }, showEmptyInvitesAlert() { this.invalidFeedbackMessage = this.$options.labels.placeHolder; this.shouldShowEmptyInvitesAlert = true; this.$refs.alerts.focus(); }, - sendInvite({ accessLevel, expiresAt }) { + getInvitePayload({ accessLevel, expiresAt }) { + const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); + + const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {}; + const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {}; + + return { + format: 'json', + expires_at: expiresAt, + access_level: accessLevel, + invite_source: this.source, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, + ...email, + ...userId, + }; + }, + async sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; this.clearValidation(); @@ -301,40 +304,28 @@ export default { return; } - const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); + this.trackInviteMembersForTask(); const apiAddByInvite = this.isProject ? Api.inviteProjectMembers.bind(Api) : Api.inviteGroupMembers.bind(Api); - const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {}; - const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {}; + try { + const payload = this.getInvitePayload({ accessLevel, expiresAt }); + const response = await apiAddByInvite(this.id, payload); - this.trackinviteMembersForTask(); - - apiAddByInvite(this.id, { - format: 'json', - expires_at: expiresAt, - access_level: accessLevel, - invite_source: this.source, - tasks_to_be_done: this.tasksToBeDoneForPost, - tasks_project_id: this.tasksProjectForPost, - ...email, - ...userId, - }) - .then((response) => { - const { error, message } = responseFromSuccess(response); + const { error, message } = responseFromSuccess(response); - if (error) { - this.showMemberErrors(message); - } else { - this.onInviteSuccess(); - } - }) - .catch((e) => this.showInvalidFeedbackMessage(e)) - .finally(() => { - this.isLoading = false; - }); + if (error) { + this.showMemberErrors(message); + } else { + this.onInviteSuccess(); + } + } catch (e) { + this.showInvalidFeedbackMessage(e); + } finally { + this.isLoading = false; + } }, showMemberErrors(message) { this.invalidMembers = message; @@ -344,11 +335,10 @@ export default { // initial token creation hits this and nothing is found... so safe navigation return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; }, - trackinviteMembersForTask() { + trackInviteMembersForTask() { const label = 'selected_tasks_to_be_done'; const property = this.selectedTasksToBeDone.join(','); - const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); - tracking.event(INVITE_MEMBERS_FOR_TASK.submit); + this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property }); }, onCancel() { this.track('click_cancel', { label: this.source }); @@ -377,9 +367,7 @@ export default { } }, showSuccessMessage() { - if (this.isOnLearnGitlab) { - eventHub.$emit('showSuccessfulInvitationsAlert'); - } else { + if (!triggerExternalAlert(this.source)) { this.$toast.show(this.$options.labels.toastMessageSuccessful); } @@ -431,7 +419,9 @@ export default { @access-level="onAccessLevelUpdate" > <template #intro-text-before> - <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> + <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"> + <gl-emoji data-name="tada" /> + </div> </template> <template #intro-text-after> <br /> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 86badd16d6c..d5e9e498c6b 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,12 +1,11 @@ import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const SEARCH_DELAY = 200; export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100'; export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100'; export const INVITE_MEMBERS_FOR_TASK = { minimum_access_level: 30, - name: 'invite_members_for_task', - view: 'modal_opened_from_email', submit: 'submit', }; export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully'; @@ -61,9 +60,18 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", ); -export const GROUP_MODAL_ALERT_BODY = s__( - 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', +export const GROUP_MODAL_TO_GROUP_ALERT_BODY = s__( + 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', ); +export const GROUP_MODAL_TO_GROUP_ALERT_LINK = helpPagePath('user/group/manage', { + anchor: 'share-a-group-with-another-group', +}); +export const GROUP_MODAL_TO_PROJECT_ALERT_BODY = s__( + 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your project%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', +); +export const GROUP_MODAL_TO_PROJECT_ALERT_LINK = helpPagePath('user/project/members/index', { + anchor: 'add-groups-to-a-project', +}); export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite'); export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite'); @@ -129,16 +137,19 @@ export const GROUP_MODAL_LABELS = { title: GROUP_MODAL_DEFAULT_TITLE, toGroup: { introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, + notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY, + notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK, }, toProject: { introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, + notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY, + notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK, }, searchField: GROUP_SEARCH_FIELD, placeHolder: GROUP_PLACEHOLDER, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, }; -export const LEARN_GITLAB = 'learn_gitlab'; export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed'; export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal'; diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js index d85162626f1..240a3a89686 100644 --- a/app/assets/javascripts/invite_members/utils/member_utils.js +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -1,4 +1,14 @@ +import { getParameterValues } from '~/lib/utils/url_utility'; + export function memberName(member) { // user defined tokens(invites by email) will have email in `name` and will not contain `username` return member.username || member.name; } + +export function triggerExternalAlert() { + return false; +} + +export function qualifiesForTasksToBeDone() { + return getParameterValues('open_modal')[0] === 'invite_members_for_task'; +} diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index c4b9bdb150b..d32336395dc 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -111,13 +111,14 @@ export default { <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> <gl-icon v-if="hasState" + :id="`iconElementXL-${itemId}`" ref="iconElementXL" :class="iconClasses" :name="iconName" :title="stateTitle" :aria-label="state" /> - <gl-tooltip :target="() => $refs.iconElementXL"> + <gl-tooltip :target="`iconElementXL-${itemId}`"> <span v-safe-html="stateTitle"></span> </gl-tooltip> <gl-icon @@ -141,7 +142,7 @@ export default { <!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). --> <!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 --> <div - class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-wrap-reverse" + class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-reverse" > <!-- Path area: status icon (<XL), path, issue # --> <div @@ -221,7 +222,7 @@ export default { category="tertiary" size="small" :disabled="removeDisabled" - class="js-issue-item-remove-button" + class="js-issue-item-remove-button gl-mr-2" data-qa-selector="remove_related_issue_button" :title="__('Remove')" :aria-label="__('Remove')" diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index 9ffcf14c943..799c0a18444 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -125,8 +125,13 @@ export default { </script> <template> - <gl-badge class="issuable-status-badge gl-mr-3" :class="badgeClass" :variant="badgeVariant"> - <gl-icon :name="badgeIcon" /> + <gl-badge + class="issuable-status-badge gl-mr-3" + :class="badgeClass" + :variant="badgeVariant" + :aria-label="badgeText" + > + <gl-icon :name="badgeIcon" class="gl-badge-icon" /> <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span> </gl-badge> </template> diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 8a094d5d688..a1525ad2bec 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -6,12 +6,13 @@ import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility' import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import ZenMode from '~/zen_mode'; +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; const DATA_ISSUES_NEW_PATH = 'data-new-issue-path'; -function organizeQuery(obj, isFallbackKey = false) { +export function organizeQuery(obj, isFallbackKey = false) { if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { return obj; } @@ -83,11 +84,10 @@ export default class IssuableForm { this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH)); this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); - this.descriptionField = this.form.find('textarea[name*="[description]"]'); + this.descriptionField = () => this.form.find('textarea[name*="[description]"]'); + this.submitButton = this.form.find('.js-issuable-submit-button'); this.draftCheck = document.querySelector('input.js-toggle-draft'); - if (!(this.titleField.length && this.descriptionField.length)) { - return; - } + if (!this.titleField.length) return; this.autosaves = this.initAutosave(); this.form.on('submit', this.handleSubmit); @@ -99,7 +99,7 @@ export default class IssuableForm { if ($issuableDueDate.length) { const calendar = new Pikaday({ field: $issuableDueDate.get(0), - theme: 'gitlab-theme animate-picker', + theme: 'gl-datepicker-theme animate-picker', format: 'yyyy-mm-dd', container: $issuableDueDate.parent().get(0), parse: (dateString) => parsePikadayDate(dateString), @@ -125,13 +125,6 @@ export default class IssuableForm { ); IssuableForm.addAutosave( autosaveMap, - 'description', - this.form.find('textarea[name*="[description]"]').get(0), - this.searchTerm, - this.fallbackKey, - ); - IssuableForm.addAutosave( - autosaveMap, 'confidential', this.form.find('input:checkbox[name*="[confidential]"]').get(0), this.searchTerm, @@ -148,7 +141,21 @@ export default class IssuableForm { return autosaveMap; } - handleSubmit() { + async handleSubmit(event) { + event.preventDefault(); + + const form = event.target; + const descriptionText = this.descriptionField().val(); + + if (containsSensitiveToken(descriptionText)) { + const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt); + if (!confirmed) { + this.submitButton.removeAttr('disabled'); + this.submitButton.removeClass('disabled'); + return false; + } + } + form.submit(); return this.resetAutosave(); } diff --git a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js index 4a6edae0c06..cbad6a2537d 100644 --- a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js @@ -1,4 +1,5 @@ import { isEmpty } from 'lodash'; +import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; import { sprintf, __ } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -107,13 +108,13 @@ const mixins = { return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; }, isOpen() { - return this.state === 'opened' || this.state === 'reopened'; + return this.state === STATUS_OPEN || this.state === STATUS_REOPENED; }, isClosed() { - return this.state === 'closed'; + return this.state === STATUS_CLOSED; }, isMerged() { - return this.state === 'merged'; + return this.state === STATUS_MERGED; }, hasTitle() { return this.title.length > 0; diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index b7d885ed8a7..d35355a8f26 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -5,6 +5,7 @@ export const STATUS_CLOSED = 'closed'; export const STATUS_MERGED = 'merged'; export const STATUS_OPEN = 'opened'; export const STATUS_REOPENED = 'reopened'; +export const STATUS_LOCKED = 'locked'; export const TITLE_LENGTH_MAX = 255; @@ -22,4 +23,6 @@ export const IssuableStatusText = { [STATUS_CLOSED]: __('Closed'), [STATUS_OPEN]: __('Open'), [STATUS_REOPENED]: __('Open'), + [STATUS_MERGED]: __('Merged'), + [STATUS_LOCKED]: __('Open'), }; diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index c821c18bcb9..de0334b4ffe 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -432,7 +432,7 @@ export default class CreateMergeRequestDropdown { let xhr = null; event.preventDefault(); - if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) { + if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) { this.droplab.hooks.forEach((hook) => hook.list.toggle()); return; @@ -442,9 +442,9 @@ export default class CreateMergeRequestDropdown { return; } - if (event.target.dataset.action === CREATE_MERGE_REQUEST) { + if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) { xhr = this.createMergeRequest(); - } else if (event.target.dataset.action === CREATE_BRANCH) { + } else if (event.currentTarget.dataset.action === CREATE_BRANCH) { xhr = this.createBranch(); } diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 83387d3ac29..61531880842 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -3,12 +3,10 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GLForm from '~/gl_form'; import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { TYPE_INCIDENT } from '~/issues/constants'; import Issue from '~/issues/issue'; -import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new'; import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; import { initRelatedIssues } from '~/related_issues'; import { @@ -38,15 +36,14 @@ export function initFilteredSearchServiceDesk() { } export function initForm() { - new GLForm($('.issue-form')); // eslint-disable-line no-new new IssuableForm($('.issue-form')); // eslint-disable-line no-new IssuableLabelSelector(); - new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initTitleSuggestions(); initTypePopover(); + initTypeSelect(); mountMilestoneDropdown(); } diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index d11540ad3dd..5199c36db5a 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -77,7 +77,7 @@ export default { class="issuable-milestone gl-mr-3" data-testid="issuable-milestone" > - <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate"> + <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate" class="gl-font-sm"> <gl-icon name="clock" /> {{ issue.milestone.title }} </gl-link> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 99064a50e3f..2c6f11b682c 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -76,8 +76,8 @@ export const ALTERNATIVE_FILTER = 'alternativeFilter'; export const i18n = { calendarLabel: __('Subscribe to calendar'), - closed: __('CLOSED'), - closedMoved: __('CLOSED (MOVED)'), + closed: __('Closed'), + closedMoved: __('Closed (moved)'), confidentialNo: __('No'), confidentialYes: __('Yes'), downvotes: __('Downvotes'), diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js index b590006929a..e64870152bd 100644 --- a/app/assets/javascripts/issues/list/graphql.js +++ b/app/assets/javascripts/issues/list/graphql.js @@ -1,7 +1,9 @@ import produce from 'immer'; -import createDefaultClient from '~/lib/graphql'; +import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +let client; + const resolvers = { Mutation: { reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => { @@ -22,6 +24,10 @@ const resolvers = { }, }; -export const gqlClient = gon.features?.frontendCaching - ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' }) - : createDefaultClient(resolvers); +export async function gqlClient() { + if (client) return client; + client = gon.features?.frontendCaching + ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' }) + : createDefaultClient(resolvers); + return client; +} diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index aca894549e4..a97b59c1e4f 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -6,7 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue'; import { gqlClient } from './graphql'; -export function mountJiraIssuesListApp() { +export async function mountJiraIssuesListApp() { const el = document.querySelector('.js-jira-issues-import-status-root'); if (!el) { @@ -27,7 +27,7 @@ export function mountJiraIssuesListApp() { el, name: 'JiraIssuesImportStatusRoot', apolloProvider: new VueApollo({ - defaultClient: gqlClient, + defaultClient: await gqlClient(), }), render(createComponent) { return createComponent(JiraIssuesImportStatusApp, { @@ -42,7 +42,7 @@ export function mountJiraIssuesListApp() { }); } -export function mountIssuesListApp() { +export async function mountIssuesListApp() { const el = document.querySelector('.js-issues-list-root'); if (!el) { @@ -100,7 +100,7 @@ export function mountIssuesListApp() { el, name: 'IssuesListRoot', apolloProvider: new VueApollo({ - defaultClient: gqlClient, + defaultClient: await gqlClient(), }), router: new VueRouter({ base: window.location.pathname, diff --git a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue index a01f4f747b9..be2237ae2a2 100644 --- a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue +++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import { STATUS_CLOSED } from '~/issues/constants'; import { __ } from '~/locale'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; @@ -42,7 +43,7 @@ export default { ].filter(({ count }) => count); }, isClosed() { - return this.suggestion.state === 'closed'; + return this.suggestion.state === STATUS_CLOSED; }, stateIconClass() { return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500'; diff --git a/app/assets/javascripts/issues/new/components/type_select.vue b/app/assets/javascripts/issues/new/components/type_select.vue new file mode 100644 index 00000000000..81c3a769d26 --- /dev/null +++ b/app/assets/javascripts/issues/new/components/type_select.vue @@ -0,0 +1,113 @@ +<script> +import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; +import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants'; +import { visitUrl } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; +import { __ } from '~/locale'; + +export default { + i18n: { + selectType: __('Select type'), + issuableType: { + [TYPE_ISSUE]: __('Issue'), + [TYPE_INCIDENT]: __('Incident'), + }, + }, + components: { + GlCollapsibleListbox, + GlIcon, + }, + mixins: [Tracking.mixin()], + props: { + selectedType: { + required: false, + default: '', + type: String, + }, + isIssueAllowed: { + required: false, + default: false, + type: Boolean, + }, + isIncidentAllowed: { + required: false, + default: false, + type: Boolean, + }, + issuePath: { + required: false, + default: '', + type: String, + }, + incidentPath: { + required: false, + default: '', + type: String, + }, + }, + data() { + return { + selected: this.selectedType, + }; + }, + computed: { + toggleText() { + return this.selectedType + ? this.$options.i18n.issuableType[this.selectedType] + : this.$options.i18n.selectType; + }, + dropdownItems() { + const issueItem = this.isIssueAllowed + ? { + value: TYPE_ISSUE, + text: __('Issue'), + icon: 'issue-type-issue', + href: this.issuePath, + } + : null; + const incidentItem = this.isIncidentAllowed + ? { + value: TYPE_INCIDENT, + text: __('Incident'), + icon: 'issue-type-incident', + href: this.incidentPath, + tracking: { + action: 'select_issue_type_incident', + label: 'select_issue_type_incident_dropdown_option', + }, + } + : null; + + return [issueItem, incidentItem].filter(Boolean); + }, + }, + methods: { + selectType(type) { + const selectedItem = this.dropdownItems.find((item) => item.value === type); + if (selectedItem.tracking) { + const { action, label } = selectedItem.tracking; + this.track(action, { label }); + } + + visitUrl(selectedItem.href); + }, + }, +}; +</script> + +<template> + <gl-collapsible-listbox + v-model="selected" + :header-text="$options.i18n.selectType" + :toggle-text="toggleText" + :items="dropdownItems" + block + class="js-issuable-type-filter-dropdown-wrap" + @select="selectType" + > + <template #list-item="{ item }"> + <gl-icon :name="item.icon" :size="16" /> + {{ item.text }} + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js index 91599502996..84a170e6564 100644 --- a/app/assets/javascripts/issues/new/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -1,8 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import TitleSuggestions from './components/title_suggestions.vue'; import TypePopover from './components/type_popover.vue'; +import TypeSelect from './components/type_select.vue'; export function initTitleSuggestions() { const el = document.getElementById('js-suggestions'); @@ -56,3 +58,28 @@ export function initTypePopover() { render: (createElement) => createElement(TypePopover), }); } + +export function initTypeSelect() { + const el = document.getElementById('js-type-select'); + + if (!el) { + return undefined; + } + + const { selectedType, isIssueAllowed, isIncidentAllowed, issuePath, incidentPath } = el.dataset; + + return new Vue({ + el, + name: 'TypeSelectRoot', + render: (createElement) => + createElement(TypeSelect, { + props: { + selectedType, + isIssueAllowed: parseBoolean(isIssueAllowed), + isIncidentAllowed: parseBoolean(isIncidentAllowed), + issuePath, + incidentPath, + }, + }), + }); +} diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 15f97222971..bc32a15a420 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -14,6 +14,7 @@ import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; @@ -95,10 +96,10 @@ export default { required: false, default: '', }, - initialTaskStatus: { - type: String, + initialTaskCompletionStatus: { + type: Object, required: false, - default: '', + default: () => ({}), }, updatedAt: { type: String, @@ -197,7 +198,7 @@ export default { updatedAt: this.updatedAt, updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, - taskStatus: this.initialTaskStatus, + taskCompletionStatus: this.initialTaskCompletionStatus, lock_version: this.lockVersion, }); @@ -222,9 +223,6 @@ export default { formState() { return this.store.formState; }, - hasUpdated() { - return Boolean(this.state.updatedAt); - }, issueChanged() { const { store: { @@ -379,7 +377,7 @@ export default { this.showForm = false; }, - updateIssuable() { + async updateIssuable() { this.setFormState({ updateLoading: true }); const { @@ -392,6 +390,14 @@ export default { this.alert?.dismiss(); + if (containsSensitiveToken(issuablePayload.description)) { + const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt); + if (!confirmed) { + this.setFormState({ updateLoading: false }); + return false; + } + } + return this.service .updateIssuable(issuablePayload) .then((res) => res.data) @@ -557,7 +563,6 @@ export default { :description-html="state.descriptionHtml" :description-text="state.descriptionText" :updated-at="state.updatedAt" - :task-status="state.taskStatus" :issuable-type="issuableType" :update-url="updateEndpoint" :lock-version="state.lock_version" @@ -570,7 +575,7 @@ export default { /> <edited-component - v-if="hasUpdated" + :task-completion-status="state.taskCompletionStatus" :updated-at="state.updatedAt" :updated-by-name="state.updatedByName" :updated-by-path="state.updatedByPath" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index bdee6c5fe9a..3721f224d5e 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,6 +1,5 @@ <script> import { GlToast } from '@gitlab/ui'; -import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; @@ -59,11 +58,6 @@ export default { required: false, default: '', }, - taskStatus: { - type: String, - required: false, - default: '', - }, issuableType: { type: String, required: false, @@ -138,7 +132,10 @@ export default { }, watch: { descriptionHtml(newDescription, oldDescription) { - if (!this.initialUpdate && newDescription !== oldDescription) { + if ( + !this.initialUpdate && + this.stripClientState(newDescription) !== this.stripClientState(oldDescription) + ) { this.animateChange(); } else { this.initialUpdate = false; @@ -148,16 +145,12 @@ export default { this.renderGFM(); }); }, - taskStatus() { - this.updateTaskStatusText(); - }, }, mounted() { eventHub.$on('convert-task-list-item', this.convertTaskListItem); eventHub.$on('delete-task-list-item', this.deleteTaskListItem); this.renderGFM(); - this.updateTaskStatusText(); }, beforeDestroy() { eventHub.$off('convert-task-list-item', this.convertTaskListItem); @@ -282,24 +275,6 @@ export default { this.$emit('taskListUpdateFailed'); }, - updateTaskStatusText() { - const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); - const $issuableHeader = $('.issuable-meta'); - const $tasks = $('#task_status', $issuableHeader); - const $tasksShort = $('#task_status_short', $issuableHeader); - - if (taskRegexMatches) { - $tasks.text(this.taskStatus); - $tasksShort.text( - `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${ - taskRegexMatches[2] > 1 ? 's' : '' - }`, - ); - } else { - $tasks.text(''); - $tasksShort.text(''); - } - }, createTaskListItemActions(provide) { const app = new Vue({ el: document.createElement('div'), @@ -349,6 +324,9 @@ export default { listItem.append(element); } }, + stripClientState(description) { + return description.replaceAll('<details open="true">', '<details>'); + }, async createTask({ taskTitle, taskDescription, oldDescription }) { try { const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription); diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 5138a4530e9..6a0edb59b65 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,13 +1,20 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { n__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { TimeAgoTooltip, + GlLink, GlSprintf, }, props: { + taskCompletionStatus: { + type: Object, + required: false, + default: () => ({}), + }, updatedAt: { type: String, required: false, @@ -25,36 +32,61 @@ export default { }, }, computed: { + completedCount() { + return this.taskCompletionStatus.completed_count; + }, + count() { + return this.taskCompletionStatus.count; + }, hasUpdatedBy() { return this.updatedByName && this.updatedByPath; }, + showCheck() { + return this.completedCount === this.count; + }, + taskStatus() { + const { completedCount, count } = this; + if (!count) { + return undefined; + } + + return sprintf( + n__( + '%{completedCount} of %{count} checklist item completed', + '%{completedCount} of %{count} checklist items completed', + count, + ), + { completedCount, count }, + ); + }, }, }; </script> <template> <small class="edited-text js-issue-widgets"> - <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')"> - <template #timeago> - <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> - </template> - </gl-sprintf> - <gl-sprintf v-else-if="!updatedAt" :message="__('Edited by %{author}')"> - <template #author> - <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline"> - <span>{{ updatedByName }}</span> - </a> - </template> - </gl-sprintf> - <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')"> - <template #timeago> - <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> - </template> - <template #author> - <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline"> - <span>{{ updatedByName }}</span> - </a> - </template> - </gl-sprintf> + <template v-if="taskStatus"> + <template v-if="showCheck">✓</template> + {{ taskStatus }} + <template v-if="updatedAt">·</template> + </template> + + <template v-if="updatedAt"> + <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')"> + <template #timeago> + <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')"> + <template #timeago> + <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" /> + </template> + <template #author> + <gl-link :href="updatedByPath" class="gl-font-sm gl-hover-text-gray-900 gl-text-gray-700"> + {{ updatedByName }} + </gl-link> + </template> + </gl-sprintf> + </template> </small> </template> 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 243666b2323..8267c0130a3 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 @@ -199,7 +199,7 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> - <gl-form-group v-if="glFeatures.incidentEventTags"> + <gl-form-group> <label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags"> {{ $options.i18n.tagsLabel }} <timeline-events-tags-popover /> 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 7c6ff002014..373c5970e64 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 @@ -42,8 +42,8 @@ export default { }, mounted() { this.gitlabBasePath = retrieveBaseUrl(); - setApiBaseURL(this.gitlabBasePath); if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) { + setApiBaseURL(this.gitlabBasePath); this.showSetupInstructions = true; } }, 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 2018942a7e8..1c7ba1d331b 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue @@ -45,7 +45,9 @@ export default { data-testid="artifacts-remove-timeline" > <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span> - <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span> + <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{ + s__('Job|The artifacts will be removed') + }}</span> <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> <gl-link :href="helpUrl" @@ -53,11 +55,11 @@ export default { rel="noopener noreferrer nofollow" data-testid="artifact-expired-help-link" > - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </p> <p v-else-if="isLocked" class="build-detail-row"> - <span data-testid="job-locked-message">{{ + <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.', ) diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 3d87cea6445..ff7982319e7 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -19,7 +19,7 @@ export default { loadingAriaLabel: __('Loading'), }, filterSearchBoxStyles: - 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b', + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100', components: { GlAlert, GlSkeletonLoader, @@ -140,6 +140,19 @@ export default { this.infiniteScrollingTriggered = false; this.filterSearchTriggered = true; + // all filters have been cleared reset query param + // and refetch jobs/count with defaults + if (!filters.length) { + updateHistory({ + url: setUrlParams({ statuses: null }, window.location.href, true), + }); + + this.$apollo.queries.jobs.refetch({ statuses: null }); + this.$apollo.queries.jobsCount.refetch({ statuses: null }); + + return; + } + // Eventually there will be more tokens available // this code is written to scale for those tokens filters.forEach((filter) => { @@ -223,7 +236,7 @@ export default { <jobs-table-empty-state v-else-if="showEmptyState" /> - <jobs-table v-else :jobs="jobs.list" /> + <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" /> <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> <gl-loading-icon diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue index 2be404de1e1..904e1ba9a47 100644 --- a/app/assets/javascripts/labels/components/delete_label_modal.vue +++ b/app/assets/javascripts/labels/components/delete_label_modal.vue @@ -81,14 +81,9 @@ export default { </gl-sprintf> <template #modal-footer> <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button> - <gl-button - category="primary" - variant="danger" - :href="destroyPath" - data-method="delete" - data-testid="delete-button" - >{{ __('Delete label') }}</gl-button - > + <gl-button category="primary" variant="danger" :href="destroyPath" data-method="delete">{{ + __('Delete label') + }}</gl-button> </template> </gl-modal> </template> diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js index 60ab0c92256..fa0104fcf12 100644 --- a/app/assets/javascripts/labels/create_label_dropdown.js +++ b/app/assets/javascripts/labels/create_label_dropdown.js @@ -1,5 +1,3 @@ -/* eslint-disable func-names */ - import $ from 'jquery'; import Api from '~/api'; import { humanize } from '~/lib/utils/text_utility'; @@ -49,6 +47,7 @@ export default class CreateLabelDropdown { addBinding() { const self = this; + // eslint-disable-next-line func-names this.$colorSuggestions.on('click', function (e) { const $this = $(this); self.addColorValue(e, $this); diff --git a/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js new file mode 100644 index 00000000000..5d2a002bf85 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js @@ -0,0 +1,97 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable class-methods-use-this */ +import { db } from './local_db'; + +/** + * IndexedDB implementation of apollo-cache-persist [PersistentStorage][1] + * + * [1]: https://github.com/apollographql/apollo-cache-persist/blob/d536c741d1f2828a0ef9abda343a9186dd8dbff2/src/types/index.ts#L15 + */ +export class IndexedDBPersistentStorage { + static async create() { + await db.open(); + + return new IndexedDBPersistentStorage(); + } + + async getItem(queryId) { + const resultObj = {}; + const selectedQuery = await db.table('queries').get(queryId); + const tableNames = new Set(db.tables.map((table) => table.name)); + + if (selectedQuery) { + resultObj.ROOT_QUERY = selectedQuery; + + const lookupTable = []; + + const parseObjectsForRef = async (selObject) => { + const ops = Object.values(selObject).map(async (child) => { + if (!child) { + return; + } + + if (child.__ref) { + const pathId = child.__ref; + const [refType, ...refKeyParts] = pathId.split(':'); + const refKey = refKeyParts.join(':'); + + if ( + !resultObj[pathId] && + !lookupTable.includes(pathId) && + tableNames.has(refType.toLowerCase()) + ) { + lookupTable.push(pathId); + const selectedEntity = await db.table(refType.toLowerCase()).get(refKey); + if (selectedEntity) { + await parseObjectsForRef(selectedEntity); + resultObj[pathId] = selectedEntity; + } + } + } else if (typeof child === 'object') { + await parseObjectsForRef(child); + } + }); + + return Promise.all(ops); + }; + + await parseObjectsForRef(resultObj.ROOT_QUERY); + } + + return resultObj; + } + + async setItem(key, value) { + await this.#setQueryResults(key, JSON.parse(value)); + } + + async removeItem() { + // apollo-cache-persist only ever calls this when we're removing everything, so let's blow it all away + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113745#note_1329175993 + + await Promise.all( + db.tables.map((table) => { + return table.clear(); + }), + ); + } + + async #setQueryResults(queryId, results) { + await Promise.all( + Object.keys(results).map((id) => { + const objectType = id.split(':')[0]; + if (objectType === 'ROOT_QUERY') { + return db.table('queries').put(results[id], queryId); + } + const key = objectType.toLowerCase(); + const tableExists = db.tables.some((table) => table.name === key); + if (tableExists) { + return db.table(key).put(results[id], id); + } + return new Promise((resolve) => { + resolve(); + }); + }), + ); + } +} diff --git a/app/assets/javascripts/lib/apollo/local_db.js b/app/assets/javascripts/lib/apollo/local_db.js new file mode 100644 index 00000000000..cda30ff9d42 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/local_db.js @@ -0,0 +1,14 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import Dexie from 'dexie'; + +export const db = new Dexie('GLLocalCache'); +db.version(1).stores({ + pages: 'url, timestamp', + queries: '', + project: 'id', + group: 'id', + usercore: 'id', + issue: 'id, state, title', + label: 'id, title', + milestone: 'id', +}); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c0e923b2670..2e6fcbea80d 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,7 +1,7 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; -import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist'; +import { persistCache } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import possibleTypes from '~/graphql_shared/possible_types.json'; @@ -53,6 +53,15 @@ export const typePolicies = { TreeEntry: { keyFields: ['webPath'], }, + Subscription: { + fields: { + aiCompletionResponse: { + read(value) { + return value ?? null; + }, + }, + }, + }, }; export const stripWhitespaceFromQuery = (url, path) => { @@ -104,7 +113,7 @@ Object.defineProperty(window, 'pendingApolloRequests', { }, }); -export default (resolvers = {}, config = {}) => { +function createApolloClient(resolvers = {}, config = {}) { const { baseUrl, batchMax = 10, @@ -113,7 +122,6 @@ export default (resolvers = {}, config = {}) => { typeDefs, path = '/api/graphql', useGet = false, - localCacheKey = null, } = config; let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -171,6 +179,7 @@ export default (resolvers = {}, config = {}) => { config: { url: httpResponse.url, operationName: operation.operationName, + method: operation.getContext()?.fetchOptions?.method || 'POST', // If method is not explicitly set, we default to POST request }, headers: { 'x-request-id': httpResponse.headers.get('x-request-id'), @@ -237,16 +246,6 @@ export default (resolvers = {}, config = {}) => { }, }); - if (localCacheKey) { - persistCacheSync({ - cache: newCache, - // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode - debug: process.env.NODE_ENV === 'development', - storage: new LocalStorageWrapper(window.localStorage), - persistenceMapper, - }); - } - ac = new ApolloClient({ typeDefs, link: appLink, @@ -262,5 +261,42 @@ export default (resolvers = {}, config = {}) => { acs.push(ac); - return ac; + return { client: ac, cache: newCache }; +} + +export async function createApolloClientWithCaching(resolvers = {}, config = {}) { + const { localCacheKey = null } = config; + const { client, cache } = createApolloClient(resolvers, config); + + if (localCacheKey) { + let storage; + + // Test that we can use IndexedDB. If not, no persisting for you! + try { + const { IndexedDBPersistentStorage } = await import( + /* webpackChunkName: 'indexed_db_persistent_storage' */ './apollo/indexed_db_persistent_storage' + ); + + storage = await IndexedDBPersistentStorage.create(); + } catch (error) { + return client; + } + + await persistCache({ + cache, + // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode + debug: process.env.NODE_ENV === 'development', + storage, + key: localCacheKey, + persistenceMapper, + }); + } + + return client; +} + +export default (resolvers = {}, config = {}) => { + const { client } = createApolloClient(resolvers, config); + + return client; }; diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js index c72561ce69d..60b46989375 100644 --- a/app/assets/javascripts/lib/mermaid.js +++ b/app/assets/javascripts/lib/mermaid.js @@ -12,8 +12,9 @@ const drawDiagram = (source) => { // eslint-disable-next-line no-unsanitized/property element.innerHTML = svgCode; - const height = parseInt(element.firstElementChild.getAttribute('height'), 10); - const width = parseInt(element.firstElementChild.style.maxWidth, 10); + element.firstElementChild.removeAttribute('height'); + const { height, width } = element.firstElementChild.getBoundingClientRect(); + setIframeRenderedSize(height, width); }; mermaid.mermaidAPI.render('mermaid', source, insertSvg); diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 7da3bab0a4b..520d7f627f6 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -1,3 +1,6 @@ +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { __ } from '~/locale'; + const commonTooltips = () => ({ mode: 'x', intersect: false, @@ -98,3 +101,38 @@ export const firstAndLastY = (data) => { return [firstY, lastY]; }; + +const toolboxIconSvgPath = async (name) => { + return `path://${await getSvgIconPathContent(name)}`; +}; + +export const getToolboxOptions = async () => { + const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath); + + try { + const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises); + + return { + toolbox: { + feature: { + dataZoom: { + icon: { zoom: marqueeSelectionPath, back: redoPath }, + }, + restore: { + icon: repeatPath, + }, + saveAsImage: { + icon: downloadPath, + }, + }, + }, + }; + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(__('SVG could not be rendered correctly: '), e); + } + + return {}; + } +}; diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js new file mode 100644 index 00000000000..64c77bf1080 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js @@ -0,0 +1,13 @@ +import { stringifyTime, parseSeconds } from './date_format_utility'; + +/** + * Formats seconds into a human readable value of elapsed time, + * optionally limiting it to hours. + * @param {Number} seconds Seconds to format + * @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours + * @return {String} Provided seconds in human readable elapsed time format + */ +export const formatTimeSpent = (seconds, limitToHours) => { + const negative = seconds < 0; + return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours })); +}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index c1081239544..f9a70371680 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -2,3 +2,4 @@ export * from './datetime/timeago_utility'; export * from './datetime/date_format_utility'; export * from './datetime/date_calculation_utility'; export * from './datetime/pikaday_utility'; +export * from './datetime/time_spent_utility'; diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js index 4cea4257e7b..febf83a4d38 100644 --- a/app/assets/javascripts/lib/utils/error_message.js +++ b/app/assets/javascripts/lib/utils/error_message.js @@ -1,20 +1,15 @@ -export const USER_FACING_ERROR_MESSAGE_PREFIX = 'UF:'; - -const getMessageFromError = (error = '') => { - return error.message || error; -}; - -export const parseErrorMessage = (error = '') => { - const messageString = getMessageFromError(error); - - if (messageString.startsWith(USER_FACING_ERROR_MESSAGE_PREFIX)) { - return { - message: messageString.replace(USER_FACING_ERROR_MESSAGE_PREFIX, '').trim(), - userFacing: true, - }; - } - return { - message: messageString, - userFacing: false, - }; +/** + * Utility to parse an error object returned from API. + * + * + * @param { Object } error - An error object directly from API response + * @param { string } error.message - The error message, returned from API. + * @param { string } defaultMessage - Default user-facing error message + * @returns { string } - A transformed user-facing error message, or defaultMessage + */ +export const parseErrorMessage = (error = {}, defaultMessage = '') => { + const messageString = error.message || ''; + return messageString.startsWith(window.gon.uf_error_prefix) + ? messageString.replace(window.gon.uf_error_prefix, '').trim() + : defaultMessage; }; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index bd47f10b3ac..7cfcd11ece9 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,3 +1,7 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; export const BACKSPACE_KEY = 'Backspace'; +export const ARROW_DOWN_KEY = 'ArrowDown'; +export const ARROW_UP_KEY = 'ArrowUp'; +export const END_KEY = 'End'; +export const HOME_KEY = 'Home'; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js new file mode 100644 index 00000000000..2807911c9bb --- /dev/null +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -0,0 +1,45 @@ +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { s__, __ } from '~/locale'; + +export const i18n = { + defaultPrompt: s__( + 'SecretDetection|This comment appears to have a token in it. Are you sure you want to add it?', + ), + descriptionPrompt: s__( + 'SecretDetection|This description appears to have a token in it. Are you sure you want to add it?', + ), + primaryBtnText: __('Proceed'), +}; + +const sensitiveDataPatterns = [ + { + name: 'GitLab Personal Access Token', + regex: 'glpat-[0-9a-zA-Z_-]{20}', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Feed Token', + regex: 'feed_token=[0-9a-zA-Z_-]{20}', + }, +]; + +export const containsSensitiveToken = (message) => { + for (const rule of sensitiveDataPatterns) { + const regex = new RegExp(rule.regex, 'gi'); + if (regex.test(message)) { + return true; + } + } + return false; +}; + +export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) { + const confirmed = await confirmAction(prompt, { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }); + if (!confirmed) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 2d5e9bc91f2..a2873622682 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -371,7 +371,7 @@ export function insertMarkdownText({ }); } -function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { +export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { const $textArea = $(textArea); textArea = $textArea.get(0); const text = $textArea.val(); @@ -627,10 +627,9 @@ export function addMarkdownListeners(form) { }); const $allToolbarBtns = $(form) - .off('click', '.js-md, .saved-replies-dropdown li') - .on('click', '.js-md, .saved-replies-dropdown li', function () { - const $savedReplyContent = $('.js-saved-reply-content', this); - const $toolbarBtn = $savedReplyContent.length ? $savedReplyContent : $(this); + .off('click', '.js-md') + .on('click', '.js-md', function () { + const $toolbarBtn = $(this); return updateTextForToolbarBtn($toolbarBtn); }); diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js new file mode 100644 index 00000000000..fd08d34a80e --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import { createApolloProvider } from '@vue/apollo-option'; +import { ApolloMutation } from '@vue/apollo-components'; + +export { ApolloMutation }; + +const installed = new WeakMap(); + +function callLifecycle(hookName, ...extraArgs) { + const { GITLAB_INTERNAL_ADDED_MIXINS: addedMixins } = this.$; + if (!addedMixins) { + return []; + } + + return addedMixins.map((m) => m[hookName]?.apply(this, extraArgs)); +} + +function createMixinForLateInit({ install, shouldInstall }) { + return { + created() { + callLifecycle.call(this, 'created'); + }, + // @vue/compat normalizez lifecycle hook names so there is no error here + destroyed() { + callLifecycle.call(this, 'unmounted'); + }, + + data(...args) { + const extraData = callLifecycle.call(this, 'data', ...args); + if (!extraData.length) { + return {}; + } + + return Object.assign({}, ...extraData); + }, + + beforeCreate() { + if (shouldInstall(this)) { + const { mixins } = this.$.appContext; + const globalMixinsBeforeInit = new Set(mixins); + install(this); + + this.$.GITLAB_INTERNAL_ADDED_MIXINS = mixins.filter((m) => !globalMixinsBeforeInit.has(m)); + + callLifecycle.call(this, 'beforeCreate'); + } + }, + }; +} + +export default class VueCompatApollo { + constructor(...args) { + // eslint-disable-next-line no-constructor-return + return createApolloProvider(...args); + } + + static install() { + Vue.mixin( + createMixinForLateInit({ + shouldInstall: (vm) => + vm.$options.apolloProvider && + !installed.get(vm.$.appContext.app)?.has(vm.$options.apolloProvider), + install: (vm) => { + const { app } = vm.$.appContext; + const { apolloProvider } = vm.$options; + + if (!installed.has(app)) { + installed.set(app, new WeakSet()); + } + + installed.get(app).add(apolloProvider); + + vm.$.appContext.app.use(vm.$options.apolloProvider); + }, + }), + ); + } +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js new file mode 100644 index 00000000000..006ed920ef0 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js @@ -0,0 +1,105 @@ +import Vue from 'vue'; +import { + createRouter, + createMemoryHistory, + createWebHistory, + createWebHashHistory, +} from 'vue-router-vue3'; + +const mode = (value, options) => { + if (!value) return null; + let history; + // eslint-disable-next-line default-case + switch (value) { + case 'history': + history = createWebHistory(options.base); + break; + case 'hash': + history = createWebHashHistory(); + break; + case 'abstract': + history = createMemoryHistory(); + break; + } + return { history }; +}; + +const base = () => null; + +const toNewCatchAllPath = (path) => { + if (path === '*') return '/:pathMatch(.*)*'; + return path; +}; + +const routes = (value) => { + if (!value) return null; + const newRoutes = value.reduce(function handleRoutes(acc, route) { + const newRoute = { + ...route, + path: toNewCatchAllPath(route.path), + }; + if (route.children) { + newRoute.children = route.children.reduce(handleRoutes, []); + } + acc.push(newRoute); + return acc; + }, []); + return { routes: newRoutes }; +}; + +const scrollBehavior = (value) => { + return { + scrollBehavior(...args) { + const { x, y, left, top } = value(...args); + return { left: x || left, top: y || top }; + }, + }; +}; + +const transformers = { + mode, + base, + routes, + scrollBehavior, +}; + +const transformOptions = (options) => { + const defaultConfig = { + routes: null, + history: createWebHashHistory(), + }; + return Object.keys(options).reduce((acc, key) => { + const value = options[key]; + if (key in transformers) { + Object.assign(acc, transformers[key](value, options)); + } else { + acc[key] = value; + } + return acc; + }, defaultConfig); +}; + +const installed = new WeakMap(); + +export default class VueRouterCompat { + constructor(options) { + // eslint-disable-next-line no-constructor-return + return createRouter(transformOptions(options)); + } + + static install() { + Vue.mixin({ + beforeCreate() { + const { app } = this.$.appContext; + const { router } = this.$options; + if (router && !installed.get(app)?.has(router)) { + if (!installed.has(app)) { + installed.set(app, new WeakSet()); + } + installed.get(app).add(router); + this.$.appContext.app.use(this.$options.router); + } + }, + }); + } +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vuex.js b/app/assets/javascripts/lib/utils/vue3compat/vuex.js new file mode 100644 index 00000000000..ff94ff3d04a --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vuex.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { + createStore, + mapState, + mapGetters, + mapActions, + mapMutations, + createNamespacedHelpers, +} from 'vuex-vue3'; + +export { mapState, mapGetters, mapActions, mapMutations, createNamespacedHelpers }; + +const installedStores = new WeakMap(); + +export default { + Store: class VuexCompatStore { + constructor(...args) { + // eslint-disable-next-line no-constructor-return + return createStore(...args); + } + }, + + install() { + Vue.mixin({ + beforeCreate() { + const { app } = this.$.appContext; + const { store } = this.$options; + if (store && !installedStores.get(app)?.has(store)) { + if (!installedStores.has(app)) { + installedStores.set(app, new WeakSet()); + } + installedStores.get(app).add(store); + this.$.appContext.app.use(this.$options.store); + } + }, + }); + }, +}; diff --git a/app/assets/javascripts/lib/utils/web_ide_navigator.js b/app/assets/javascripts/lib/utils/web_ide_navigator.js new file mode 100644 index 00000000000..f0579b5886d --- /dev/null +++ b/app/assets/javascripts/lib/utils/web_ide_navigator.js @@ -0,0 +1,24 @@ +import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility'; + +/** + * Takes a project path and optional file path and branch + * and then redirects the user to the web IDE. + * + * @param {string} projectPath - Full path to project including namespace (ex. flightjs/Flight) + * @param {string} filePath - optional path to file to be edited, otherwise will open at base directory (ex. README.md) + * @param {string} branch - optional branch to open the IDE, defaults to 'main' + */ + +export const openWebIDE = (projectPath, filePath, branch = 'main') => { + if (!projectPath) { + throw new TypeError('projectPath parameter is required'); + } + + const pathnameSegments = [projectPath, 'edit', branch, '-']; + + if (filePath) { + pathnameSegments.push(filePath); + } + + visitUrl(webIDEUrl(`/${pathnameSegments.join('/')}/`)); +}; diff --git a/app/assets/javascripts/locale/ensure_single_line.cjs b/app/assets/javascripts/locale/ensure_single_line.cjs index c2c63777001..f7790cadc48 100644 --- a/app/assets/javascripts/locale/ensure_single_line.cjs +++ b/app/assets/javascripts/locale/ensure_single_line.cjs @@ -1,5 +1,3 @@ -/* eslint-disable import/no-commonjs */ - const SPLIT_REGEX = /\s*[\r\n]+\s*/; /** diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a1539aba786..fd002e29afc 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -89,9 +89,11 @@ initRails(); function deferredInitialisation() { const $body = $('body'); - if (!gon.use_new_navigation) initTopNav(); + if (!gon.use_new_navigation) { + initTopNav(); + initTodoToggle(); + } initBreadcrumbs(); - initTodoToggle(); initPrefetchLinks('.js-prefetch-document'); initLogoAnimation(); initServicePingConsent(); 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 76b286f94ad..685482a76de 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,5 +1,6 @@ <script> import { mapState } from 'vuex'; +import { __ } from '~/locale'; import { getParameterByName, setUrlParams, @@ -46,6 +47,10 @@ export default { return false; } + if (token.type === 'user_type' && !gon.features?.serviceAccountsCrud) { + return false; + } + return this.filteredSearchBar.tokens?.includes(token.type); }); }, @@ -94,6 +99,14 @@ export default { }; } } else { + // Remove this block after this issue is closed: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159 + if (value.data === __('Service account')) { + return { + ...accumulator, + [type]: 'service_account', + }; + } + return { ...accumulator, [type]: value.data, diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index e066b023fbb..a85bb09e17b 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -81,19 +81,18 @@ export default { return; } - this.updateMemberRole({ - memberId: this.member.id, - accessLevel: { integerValue: newRoleValue, stringValue: newRoleName }, - }) - .then(() => { - this.$toast.show(s__('Members|Role updated successfully.')); - }) - .catch((error) => { - Sentry.captureException(error); - }) - .finally(() => { - this.busy = false; + try { + await this.updateMemberRole({ + memberId: this.member.id, + accessLevel: { integerValue: newRoleValue, stringValue: newRoleName }, }); + + this.$toast.show(s__('Members|Role updated successfully.')); + } catch (error) { + Sentry.captureException(error); + } finally { + this.busy = false; + } }, }, }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index d55e942dafa..124b14a9845 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -13,7 +13,7 @@ import axios from './lib/utils/axios_utils'; import { localTimeAgo } from './lib/utils/datetime_utility'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; -import { __ } from './locale'; +import { __, s__ } from './locale'; import syntaxHighlight from './syntax_highlight'; // MergeRequestTabs @@ -316,7 +316,7 @@ export default class MergeRequestTabs { }) .catch(() => { toggleLoader(false); - createAlert({ message: __('MergeRequest|Failed to load the page') }); + createAlert({ message: s__('MergeRequest|Failed to load the page') }); }); } diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js index f90fdb04923..9d210f7a6ec 100644 --- a/app/assets/javascripts/milestones/index.js +++ b/app/assets/javascripts/milestones/index.js @@ -64,8 +64,6 @@ export function initDeleteMilestoneModal() { if (!successful) { button.removeAttribute('disabled'); } - - button.querySelector('.js-loading-icon').classList.add('hidden'); }; const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); @@ -75,7 +73,6 @@ export function initDeleteMilestoneModal() { `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, ); button.setAttribute('disabled', ''); - button.querySelector('.js-loading-icon').classList.remove('hidden'); eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); }; diff --git a/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue new file mode 100644 index 00000000000..4c0f99cf62c --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue @@ -0,0 +1,98 @@ +<script> +import { + GlModal, + GlDropdown, + GlTooltipDirective, + GlDropdownItem, + GlModalDirective, +} from '@gitlab/ui'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlModal, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + props: { + deletePath: { + type: String, + required: true, + }, + deleteConfirmationText: { + type: String, + required: true, + }, + actionPrimaryText: { + type: String, + required: true, + }, + modalTitle: { + type: String, + required: true, + }, + }, + data() { + return { + isDeleteModalVisible: false, + modal: { + id: 'ml-experiments-delete-modal', + deleteConfirmation: this.deleteConfirmationText, + actionPrimary: { + text: this.actionPrimaryText, + attributes: { variant: 'danger' }, + }, + actionCancel: { + text: __('Cancel'), + }, + }, + }; + }, + methods: { + confirmDelete() { + this.$refs.deleteForm.submit(); + }, + }, + csrf, +}; +</script> + +<template> + <gl-dropdown + right + category="tertiary" + :aria-label="__('More actions')" + icon="ellipsis_v" + no-caret + > + <gl-dropdown-item + v-gl-modal-directive="modal.id" + :aria-label="actionPrimaryText" + variant="danger" + > + {{ actionPrimaryText }} + + <form ref="deleteForm" method="post" :action="deletePath"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + </form> + + <gl-modal + :modal-id="modal.id" + :title="modalTitle" + :action-primary="modal.actionPrimary" + :action-cancel="modal.actionCancel" + @primary="confirmDelete" + > + <p> + {{ deleteConfirmationText }} + </p> + </gl-modal> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue index 3c765de92a2..23b58543f11 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue @@ -2,6 +2,7 @@ import { GlLink } from '@gitlab/ui'; import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants'; import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import { TITLE_LABEL, INFO_LABEL, @@ -12,12 +13,16 @@ import { PARAMETERS_LABEL, METRICS_LABEL, METADATA_LABEL, + DELETE_CANDIDATE_CONFIRMATION_MESSAGE, + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, + DELETE_CANDIDATE_MODAL_TITLE, } from './translations'; export default { name: 'MlCandidatesShow', components: { IncubationAlert, + DeleteButton, GlLink, }, props: { @@ -36,6 +41,9 @@ export default { PARAMETERS_LABEL, METRICS_LABEL, METADATA_LABEL, + DELETE_CANDIDATE_CONFIRMATION_MESSAGE, + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, + DELETE_CANDIDATE_MODAL_TITLE, }, computed: { sections() { @@ -67,11 +75,22 @@ export default { :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" /> - <h3> - {{ $options.i18n.TITLE_LABEL }} - </h3> + <div class="detail-page-header gl-flex-wrap"> + <div class="detail-page-header-body"> + <h1 class="page-title gl-font-size-h-display flex-fill"> + {{ $options.i18n.TITLE_LABEL }} + </h1> - <table class="candidate-details"> + <delete-button + :delete-path="candidate.info.path" + :delete-confirmation-text="$options.i18n.DELETE_CANDIDATE_CONFIRMATION_MESSAGE" + :action-primary-text="$options.i18n.DELETE_CANDIDATE_PRIMARY_ACTION_LABEL" + :modal-title="$options.i18n.DELETE_CANDIDATE_MODAL_TITLE" + /> + </div> + </div> + + <table class="candidate-details gl-w-full"> <tbody> <tr class="divider"></tr> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js index caad145873e..5f7714aa0c0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js @@ -9,3 +9,8 @@ export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts'); export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters'); export const METRICS_LABEL = s__('MlExperimentTracking|Metrics'); export const METADATA_LABEL = s__('MlExperimentTracking|Metadata'); +export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( + 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', +); +export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); +export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue new file mode 100644 index 00000000000..92c662fedf1 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue @@ -0,0 +1,47 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import { __ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; + +export default { + name: 'ExperimentHeader', + components: { + DeleteButton, + GlButton, + }, + props: { + title: { + type: String, + required: true, + }, + deleteInfo: { + type: Object, + required: true, + }, + }, + methods: { + downloadCsv() { + const currentPath = window.location.pathname; + const currentSearch = window.location.search; + + visitUrl(`${currentPath}.csv${currentSearch}`); + }, + }, + i18n: { + downloadAsCsvLabel: __('Download as CSV'), + }, +}; +</script> + +<template> + <div class="detail-page-header gl-flex-wrap"> + <div class="detail-page-header-body"> + <h1 class="page-title gl-font-size-h-display flex-fill">{{ title }}</h1> + + <gl-button @click="downloadCsv">{{ $options.i18n.downloadAsCsvLabel }}</gl-button> + + <delete-button v-bind="deleteInfo" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue index ca0a42fda10..acb5fc7cad2 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue @@ -12,6 +12,7 @@ import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue'; import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import ExperimentHeader from './components/experiment_header.vue'; import { LIST_KEY_CREATED_AT, BASE_SORT_FIELDS, @@ -30,8 +31,13 @@ export default { IncubationAlert, RegistrySearch, KeysetPagination, + ExperimentHeader, }, props: { + experiment: { + type: Object, + required: true, + }, candidates: { type: Array, required: true, @@ -124,6 +130,14 @@ export default { hasItems() { return this.candidates.length > 0; }, + deleteButtonInfo() { + return { + deletePath: this.experiment.path, + deleteConfirmationText: translations.DELETE_EXPERIMENT_CONFIRMATION_MESSAGE, + actionPrimaryText: translations.DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL, + modalTitle: translations.DELETE_EXPERIMENT_MODAL_TITLE, + }; + }, }, methods: { submitFilters() { @@ -157,6 +171,8 @@ export default { :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE" /> + <experiment-header :title="experiment.name" :delete-info="deleteButtonInfo" /> + <registry-search :filters="filters" :sorting="sorting" diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js index 63b0d902b72..5c34a66921d 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js @@ -14,3 +14,8 @@ export const EMPTY_STATE_DESCRIPTION_LABEL = s__( 'MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client.', ); export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No candidates'); +export const DELETE_EXPERIMENT_CONFIRMATION_MESSAGE = s__( + 'MlExperimentTracking|Deleting this experiment will also delete its candidates and their associated metadata.', +); +export const DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete experiment'); +export const DELETE_EXPERIMENT_MODAL_TITLE = s__('MLExperimentTracking|Delete experiment?'); diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 4fdc08487f2..32e85262882 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -40,7 +40,7 @@ function prometheusMetricQueryParams(timeRange) { * Extract error messages from API or HTTP request errors. * * - API errors are in `error.response.data.message` - * - HTTP (axios) errors are in `error.messsage` + * - HTTP (axios) errors are in `error.message` * * @param {Object} error * @returns {String} User friendly error message diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 0d849e1a2d8..5f4d2703d21 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -97,7 +97,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => { */ const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show'); -/* eslint-disable @gitlab/require-i18n-strings */ /** * Tracks snowplow event when user generates link to metric chart * @param {String} chart link that will be sent as a property for the event @@ -107,13 +106,13 @@ export const generateLinkToChartOptions = (chartLink) => { const isCLusterHealthBoard = isClusterHealthBoard(); const category = isCLusterHealthBoard - ? 'Cluster Monitoring' + ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings : 'Incident Management::Embedded metrics'; const action = isCLusterHealthBoard ? 'generate_link_to_cluster_metric_chart' : 'generate_link_to_metrics_chart'; - return { category, action, label: 'Chart link', property: chartLink }; + return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings }; /** @@ -125,13 +124,13 @@ export const downloadCSVOptions = (title) => { const isCLusterHealthBoard = isClusterHealthBoard(); const category = isCLusterHealthBoard - ? 'Cluster Monitoring' + ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings : 'Incident Management::Embedded metrics'; const action = isCLusterHealthBoard ? 'download_csv_of_cluster_metric_chart' : 'download_csv_of_metrics_dashboard_chart'; - return { category, action, label: 'Chart title', property: title }; + return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings }; /* eslint-enable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 2488c8aee9c..d537117d962 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -41,7 +41,7 @@ export default () => { apolloProvider, provide: { reportAbusePath: notesDataset.reportAbusePath, - newSavedRepliesPath: notesDataset.savedRepliesNewPath, + newCommentTemplatePath: notesDataset.newCommentTemplatePath, }, data() { const noteableData = JSON.parse(notesDataset.noteableData); diff --git a/app/assets/javascripts/mr_notes/stores/drawer/actions.js b/app/assets/javascripts/mr_notes/stores/drawer/actions.js new file mode 100644 index 00000000000..4f8c3bb7f20 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/actions.js @@ -0,0 +1,5 @@ +import * as types from './mutation_types'; + +export const setDrawer = ({ commit }, data) => { + return commit(types.default.SET_DRAWER, data); +}; diff --git a/app/assets/javascripts/mr_notes/stores/drawer/getters.js b/app/assets/javascripts/mr_notes/stores/drawer/getters.js new file mode 100644 index 00000000000..dd61bc900fa --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/getters.js @@ -0,0 +1 @@ +export const activeDrawer = (state) => state.activeDrawer; diff --git a/app/assets/javascripts/mr_notes/stores/drawer/index.js b/app/assets/javascripts/mr_notes/stores/drawer/index.js new file mode 100644 index 00000000000..c4a7eacb78a --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/index.js @@ -0,0 +1,13 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +export default () => ({ + namespaced: true, + state: { + activeDrawer: {}, + }, + mutations, + actions, + getters, +}); diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js new file mode 100644 index 00000000000..5fe8a9ba20d --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js @@ -0,0 +1,3 @@ +export default { + SET_DRAWER: 'SET_DRAWER', +}; diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutations.js b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js new file mode 100644 index 00000000000..eee43f2b316 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js @@ -0,0 +1,7 @@ +import types from './mutation_types'; + +export default { + [types.SET_DRAWER](state, drawer) { + Object.assign(state, { activeDrawer: drawer }); + }, +}; diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js index 7527c685c71..1f8e61beff0 100644 --- a/app/assets/javascripts/mr_notes/stores/index.js +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -4,6 +4,7 @@ import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments' import diffsModule from '~/diffs/store/modules'; import notesModule from '~/notes/stores/modules'; import mrPageModule from './modules'; +import findingsDrawer from './drawer'; Vue.use(Vuex); @@ -12,6 +13,7 @@ export const createModules = () => ({ notes: notesModule(), diffs: diffsModule(), batchComments: batchCommentsModule(), + findingsDrawer: findingsDrawer(), }); export const createStore = () => diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index ca6232fa4c4..5eb5e5b9b90 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -7,7 +7,7 @@ import Tracking from '~/tracking'; export default { i18n: { - badgeLabel: s__('NorthstarNavigation|Alpha'), + badgeLabel: s__('NorthstarNavigation|Beta'), sectionTitle: s__('NorthstarNavigation|Navigation redesign'), toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'), toggleLabel: s__('NorthstarNavigation|Toggle new navigation'), @@ -51,7 +51,7 @@ export default { Tracking.event(undefined, 'click_toggle', { label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta', - property: this.enabled ? 'navigation' : 'navigation_top', + property: this.enabled ? 'nav_user_menu' : 'navigation_top', }); window.location.reload(); diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index a7c2e572037..9d07f435620 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -10,12 +10,12 @@ export default class NewBranchForm { } addBinding() { - this.name.addEventListener('blur', this.validate); + this.name.addEventListener('change', this.validate); } init() { if (this.name != null && this.name.value.length > 0) { - const event = new CustomEvent('blur'); + const event = new CustomEvent('change'); this.name.dispatchEvent(event); } } @@ -77,6 +77,7 @@ export default class NewBranchForm { const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { this.branchNameError.textContent = errors.join(', '); + this.name.focus(); } } } diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index 037be8467cb..c76ffce9168 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,4 +1,3 @@ -/* eslint-disable no-return-assign */ export default class NewCommitForm { constructor(form) { this.form = form; @@ -21,6 +20,6 @@ export default class NewCommitForm { this.createMergeRequestContainer.hide(); this.createMergeRequest.prop('checked', false); } - return (this.wasDifferent = different); + this.wasDifferent = different; } } diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 64e801a7516..211a12208c1 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -51,7 +51,7 @@ export default { language="python" :code="code" :max-height="maxHeight" - class="gl-border" + class="gl-border gl-p-4!" /> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/dataframe.vue b/app/assets/javascripts/notebook/cells/output/dataframe.vue new file mode 100644 index 00000000000..4fe02ee6edf --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/dataframe.vue @@ -0,0 +1,46 @@ +<script> +import JSONTable from '~/behaviors/components/json_table.vue'; +import Prompt from '../prompt.vue'; +import { convertHtmlTableToJson } from './dataframe_util'; + +export default { + name: 'DataframeOutput', + components: { + Prompt, + JSONTable, + }, + props: { + count: { + type: Number, + required: true, + }, + rawCode: { + type: String, + required: true, + }, + index: { + type: Number, + required: true, + }, + }, + computed: { + showOutput() { + return this.index === 0; + }, + dataframeAsJSONTable() { + return { + ...convertHtmlTableToJson(this.rawCode), + caption: '', + hasFilter: true, + }; + }, + }, +}; +</script> + +<template> + <div class="output"> + <prompt type="Out" :count="count" :show-output="showOutput" /> + <j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" /> + </div> +</template> diff --git a/app/assets/javascripts/notebook/cells/output/dataframe_util.js b/app/assets/javascripts/notebook/cells/output/dataframe_util.js new file mode 100644 index 00000000000..2fdaaced0b9 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js @@ -0,0 +1,44 @@ +import { sanitize } from '~/lib/dompurify'; + +/** + * Converts a dataframe in the output of a Jupyter Notebook cell to a json object + * + * @param {string} input - the dataframe + * @param {DOMParser} parser - the html parser + * @returns {Object} The converted JSON object with an `items` property containing the rows. + */ +export function convertHtmlTableToJson(input, domParser) { + const parser = domParser || new DOMParser(); + const htmlDoc = parser.parseFromString(sanitize(input), 'text/html'); + + if (!htmlDoc) return { fields: [], items: [] }; + + const columnNames = [...htmlDoc.querySelectorAll('table > thead th')].map( + (head) => head.innerText, + ); + + if (!columnNames) return { fields: [], items: [] }; + + const itemValues = [...htmlDoc.querySelectorAll('table > tbody > tr')].map((row) => + [...row.querySelectorAll('td')].map((item) => item.innerText), + ); + + return { + fields: columnNames.map((column) => ({ + key: column === '' ? 'index' : column, + label: column, + sortable: true, + })), + items: itemValues.map((values, itemIndex) => ({ + index: itemIndex, + ...Object.fromEntries(values.map((value, index) => [columnNames[index + 1], value])), + })), + }; +} + +export function isDataframe(output) { + const htmlData = output.data['text/html']; + if (!htmlData) return false; + + return htmlData.slice(0, 20).some((line) => line.includes('dataframe')); +} diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 22bcb5dd66a..0437b85913b 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -5,6 +5,8 @@ import ImageOutput from './image.vue'; import LatexOutput from './latex.vue'; import MarkdownOutput from './markdown.vue'; import ErrorOutput from './error.vue'; +import DataframeOutput from './dataframe.vue'; +import { isDataframe } from './dataframe_util'; const TEXT_MARKDOWN = 'text/markdown'; const ERROR_OUTPUT_TYPE = 'error'; @@ -66,6 +68,8 @@ export default { return ImageOutput; } else if (output.data['image/jpeg']) { return ImageOutput; + } else if (isDataframe(output)) { + return DataframeOutput; } else if (output.data['text/html']) { return HtmlOutput; } else if (output.data['text/latex']) { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4bcddb260e1..6794f838c84 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,6 +7,7 @@ import { createAlert } from '~/alert'; import { badgeState } from '~/issuable/components/status_box.vue'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { capitalizeFirstCharacter, convertToCamelCase, @@ -81,6 +82,9 @@ export default { 'hasDrafts', ]), ...mapState(['isToggleStateButtonLoading']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, noteableDisplayName() { const displayNameMap = { [constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue, @@ -224,7 +228,7 @@ export default { handleSaveDraft() { this.handleSave({ isDraft: true }); }, - handleSave({ withIssueAction = false, isDraft = false } = {}) { + async handleSave({ withIssueAction = false, isDraft = false } = {}) { this.errors = []; if (this.note.length) { @@ -246,6 +250,13 @@ export default { noteData.data.note.type = constants.DISCUSSION_NOTE; } + if (containsSensitiveToken(this.note)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + return; + } + } + this.note = ''; // Empty textarea while being requested. Repopulate in catch this.stopPolling(); @@ -363,6 +374,7 @@ export default { :form-field-props="formFieldProps" :autosave-key="autosaveKey" :disabled="isSubmitting" + :autocomplete-data-sources="autocompleteDataSources" supports-quick-actions @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleEnter()" @@ -399,10 +411,10 @@ export default { {{ $options.i18n.internal }} <gl-icon v-gl-tooltip:tooltipcontainer.bottom - name="question" + name="question-o" :size="16" :title="$options.i18n.internalVisibility" - class="gl-text-gray-500" + class="gl-text-blue-500" /> </gl-form-checkbox> <comment-type-dropdown diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 37935e9c3c6..ba5ffc60917 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -157,7 +157,7 @@ export default { v-if="resolveAllDiscussionsIssuePath && !allResolved" :href="resolveAllDiscussionsIssuePath" > - {{ __('Create issue to resolve all threads') }} + {{ __('Resolve all with new issue') }} </gl-dropdown-item> </gl-dropdown> </gl-button-group> diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index 39b3df899a5..d02327a37a7 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -28,7 +28,9 @@ export default { class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note" data-qa-selector="discussion_filter_container" > - <div class="timeline-icon d-none d-lg-flex"> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > <gl-icon name="comment" /> </div> <div class="timeline-content gl-pl-8"> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 89cd252b94b..bce17aebd64 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -21,7 +21,7 @@ export default { editCommentLabel: __('Edit comment'), deleteCommentLabel: __('Delete comment'), moreActionsLabel: __('More actions'), - reportAbuse: __('Report abuse to administrator'), + reportAbuse: __('Report abuse'), }, name: 'NoteActions', components: { @@ -367,7 +367,7 @@ export default { @click="closeTooltip" /> <!-- eslint-enable @gitlab/vue-no-data-toggle --> - <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> + <ul class="dropdown-menu more-actions-dropdown dropdown-menu-right"> <gl-dropdown-item v-if="canEdit" class="js-note-edit gl-sm-display-none!" diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 21841680cab..9c04a72375b 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -35,9 +35,6 @@ export default { isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, - addButtonClass() { - return this.isAuthoredByMe ? 'js-user-authored' : ''; - }, }, methods: { ...mapActions(['toggleAwardRequest']), @@ -64,7 +61,6 @@ export default { :awards="awards" :can-award-emoji="canAwardEmoji" :current-user-id="getUserData.id" - :add-button-class="addButtonClass" @award="handleAward($event)" /> </div> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index eef011db7d2..b4e5129ca0e 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -5,7 +5,6 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; -import autosave from '../mixins/autosave'; import NoteAttachment from './note_attachment.vue'; import NoteAwardsList from './note_awards_list.vue'; import NoteEditedText from './note_edited_text.vue'; @@ -22,7 +21,6 @@ export default { directives: { SafeHtml, }, - mixins: [autosave], props: { note: { type: Object, @@ -96,21 +94,9 @@ export default { }, mounted() { this.renderGFM(); - - if (this.isEditing) { - this.initAutoSave(this.note); - } }, updated() { this.renderGFM(); - - if (this.isEditing) { - if (!this.autosave) { - this.initAutoSave(this.note); - } else { - this.setAutoSave(); - } - } }, methods: { ...mapActions([ diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index b6ede10d02b..34ae0c7ffc1 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,10 +1,10 @@ <script> -import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; -import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -15,13 +15,14 @@ export default { i18n: COMMENT_FORM, name: 'NoteForm', components: { - MarkdownField, + MarkdownEditor, CommentFieldLayout, GlButton, GlSprintf, GlLink, + GlFormCheckbox, }, - mixins: [issuableStateMixin, resolvable], + mixins: [issuableStateMixin, resolvable, glFeaturesFlagMixin()], props: { noteBody: { type: String, @@ -95,20 +96,22 @@ export default { }, }, data() { - let updatedNoteBody = this.noteBody; - - if (!updatedNoteBody && this.autosaveKey) { - updatedNoteBody = getDraft(this.autosaveKey) || ''; - } - return { - updatedNoteBody, + updatedNoteBody: this.noteBody, conflictWhileEditing: false, isSubmitting: false, isResolving: this.resolveDiscussion, isUnresolving: !this.resolveDiscussion, resolveAsThread: true, isSubmittingWithKeydown: false, + formFieldProps: { + id: 'note_note', + name: 'note[note]', + 'aria-label': __('Reply to comment'), + placeholder: this.$options.i18n.bodyPlaceholder, + class: 'note-textarea js-gfm-input js-note-text markdown-area js-vue-issue-note-form', + 'data-qa-selector': 'reply_field', + }, }; }, computed: { @@ -123,6 +126,9 @@ export default { withBatchComments: (state) => state.batchComments?.withBatchComments, }), ...mapGetters('batchComments', ['hasDrafts']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, showBatchCommentsActions() { return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit; }, @@ -135,11 +141,6 @@ export default { .some((n) => n.current_user?.can_resolve_discussion) || this.isDraft ); }, - textareaPlaceholder() { - return this.discussionNote?.internal - ? this.$options.i18n.bodyPlaceholderInternal - : this.$options.i18n.bodyPlaceholder; - }, noteHash() { if (this.noteId) { return `#note_${this.noteId}`; @@ -214,6 +215,9 @@ export default { placeholder: { link: ['startTag', 'endTag'] }, }; }, + enableContentEditor() { + return Boolean(this.glFeatures.contentEditorOnIssues); + }, }, watch: { noteBody() { @@ -225,7 +229,7 @@ export default { }, }, mounted() { - this.$refs.textarea.focus(); + this.updatePlaceholder(); }, methods: { ...mapActions(['toggleResolveNote']), @@ -252,19 +256,21 @@ export default { }, cancelHandler(shouldConfirm = false) { // check if any dropdowns are active before sending the cancelation event - if (!this.$refs.textarea.classList.contains('at-who-active')) { + if ( + !this.$refs.markdownEditor.$el + .querySelector('textarea') + ?.classList.contains('at-who-active') + ) { this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); } }, - onInput() { - if (this.isSubmittingWithKeydown) { - return; - } - - if (this.autosaveKey) { - const { autosaveKey, updatedNoteBody: text } = this; - updateDraft(autosaveKey, text); - } + updatePlaceholder() { + this.formFieldProps.placeholder = this.discussionNote?.internal + ? this.$options.i18n.bodyPlaceholderInternal + : this.$options.i18n.bodyPlaceholder; + }, + onInput(value) { + this.updatedNoteBody = value; }, handleKeySubmit() { if (this.showBatchCommentsActions) { @@ -273,6 +279,7 @@ export default { this.isSubmittingWithKeydown = true; this.handleUpdate(); } + this.updatedNoteBody = ''; }, handleUpdate(shouldResolve) { const beforeSubmitDiscussionState = this.discussionResolved; @@ -333,53 +340,47 @@ export default { :noteable-data="getNoteableData" :is-internal-note="discussion.internal" > - <markdown-field - :markdown-preview-path="markdownPreviewPath" + <markdown-editor + ref="markdownEditor" + :enable-content-editor="enableContentEditor" + :value="updatedNoteBody" + :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" :line="line" :lines="lines" - :note="discussionNote" :can-suggest="canSuggest" :add-spacing-classes="false" :help-page-path="helpPagePath" + :note="discussionNote" + :form-field-props="formFieldProps" :show-suggest-popover="showSuggestPopover" - :textarea-value="updatedNoteBody" + :quick-actions-docs-path="quickActionsDocsPath" + :autosave-key="autosaveKey" + :autocomplete-data-sources="autocompleteDataSources" + :disabled="isSubmitting" + supports-quick-actions + autofocus + @keydown.meta.enter="handleKeySubmit()" + @keydown.ctrl.enter="handleKeySubmit()" + @keydown.exact.up="editMyLastNote()" + @keydown.exact.esc="cancelHandler(true)" + @input="onInput" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" - > - <template #textarea> - <textarea - id="note_note" - ref="textarea" - v-model="updatedNoteBody" - :disabled="isSubmitting" - data-supports-quick-actions="true" - name="note[note]" - class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" - data-qa-selector="reply_field" - dir="auto" - :aria-label="__('Reply to comment')" - :placeholder="textareaPlaceholder" - @keydown.meta.enter="handleKeySubmit()" - @keydown.ctrl.enter="handleKeySubmit()" - @keydown.exact.up="editMyLastNote()" - @keydown.exact.esc="cancelHandler(true)" - @input="onInput" - ></textarea> - </template> - </markdown-field> + /> </comment-field-layout> <div class="note-form-actions"> <template v-if="showBatchCommentsActions"> <p v-if="showResolveDiscussionToggle"> <label> <template v-if="discussionResolved"> - <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" /> - {{ __('Unresolve thread') }} + <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox"> + {{ __('Unresolve thread') }} + </gl-form-checkbox> </template> <template v-else> - <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" /> - {{ __('Resolve thread') }} + <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox"> + {{ __('Resolve thread') }} + </gl-form-checkbox> </template> </label> </p> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 60ae573bae7..3375e366ecf 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -11,6 +11,7 @@ import { s__, __, sprintf } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -207,12 +208,21 @@ export default { this.isReplying = false; clearDraft(this.autosaveKey); }), - saveReply(noteText, form, callback) { + async saveReply(noteText, form, callback) { if (!noteText) { this.cancelReplyForm(); callback(); return; } + + if (containsSensitiveToken(noteText)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + callback(); + return; + } + } + const postData = { in_reply_to_discussion_id: this.discussion.reply_id, target_type: this.getNoteableData.targetType, diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 80025d6f98a..ae2f94a5a80 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -13,6 +13,7 @@ import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '~/locale'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -294,10 +295,9 @@ export default { this.isRequesting = false; this.oldContent = null; renderGFM(this.$refs.noteBody.$el); - this.$refs.noteBody.resetAutoSave(); this.$emit('updateSuccess'); }, - formUpdateHandler({ noteText, callback, resolveDiscussion }) { + async formUpdateHandler({ noteText, callback, resolveDiscussion }) { const position = { ...this.note.position, }; @@ -320,6 +320,14 @@ export default { if (this.isDraft) return; + if (containsSensitiveToken(noteText)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + callback(); + return; + } + } + const data = { endpoint: this.note.path, note: { @@ -383,7 +391,6 @@ export default { }); if (!confirmed) return; } - this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { // eslint-disable-next-line vue/no-mutating-props this.note.note_html = this.oldContent; diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue index 9c3b2139a5d..95c02884ace 100644 --- a/app/assets/javascripts/notes/components/notes_activity_header.vue +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -27,7 +27,7 @@ export default { <template> <div - class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5" + class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5 gl-pb-3" > <h2 class="gl-font-size-h1 gl-m-0">{{ __('Activity') }}</h2> <div class="gl-display-flex gl-gap-3 gl-w-full gl-sm-w-auto gl-mt-3 gl-sm-mt-0"> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index b884c6b6d19..cf7207d260d 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { apolloProvider } from '~/graphql_shared/issuable_client'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; import NotesApp from './components/notes_app.vue'; @@ -58,7 +59,8 @@ export default () => { provide: { showTimelineViewToggle, reportAbusePath: notesDataset.reportAbusePath, - newSavedRepliesPath: notesDataset.savedRepliesNewPath, + newCommentTemplatePath: notesDataset.newCommentTemplatePath, + resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id), }, data() { return { diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js deleted file mode 100644 index 17272d5abef..00000000000 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ /dev/null @@ -1,30 +0,0 @@ -import { s__ } from '~/locale'; -import Autosave from '~/autosave'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -export default { - methods: { - initAutoSave(noteable, extraKeys = []) { - let keys = [ - s__('Autosave|Note'), - capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType), - noteable.id, - ]; - - if (extraKeys) { - keys = keys.concat(extraKeys); - } - - this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys); - }, - resetAutoSave() { - this.autosave.reset(); - }, - setAutoSave() { - this.autosave.save(); - }, - disposeAutoSave() { - this.autosave.dispose(); - }, - }, -}; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 3dbcf28d11c..90de7db8c1b 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -63,6 +63,7 @@ function getPreviousDiscussion() { function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { const discussion = getDiscussion(); + if (!isOverviewPage() && !discussion) { window.mrTabs?.eventHub.$once('NotesAppReady', () => { handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions); @@ -71,9 +72,12 @@ function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { window.mrTabs?.tabShown('show', undefined, false); return; } - const id = discussion.dataset.discussionId; - ctx.expandDiscussion({ discussionId: id }); - scrollToElement(discussion, scrollOptions); + + if (discussion) { + const id = discussion.dataset.discussionId; + ctx.expandDiscussion({ discussionId: id }); + scrollToElement(discussion, scrollOptions); + } } export default { diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index 14e97fcef46..9a1323cdaf2 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { marked } from 'marked'; import { sanitize } from '~/lib/dompurify'; import { markdownConfig } from '~/lib/utils/text_utility'; @@ -8,9 +7,9 @@ import { markdownConfig } from '~/lib/utils/text_utility'; * @param {Boolean} enabled that will be send as a property for the event */ export const trackToggleTimelineView = (enabled) => ({ - category: 'Incident Management', + category: 'Incident Management', // eslint-disable-line @gitlab/require-i18n-strings action: 'toggle_incident_comments_into_timeline_view', - label: 'Status', + label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings property: enabled, }); diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue new file mode 100644 index 00000000000..c4a928c5e07 --- /dev/null +++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue @@ -0,0 +1,106 @@ +<script> +import { GlButton, GlModal } from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; +import { + CONFIRM_MODAL, + CONFIRM_MODAL_TITLE, + COPY_SECRET, + DESCRIPTION_SECRET, + RENEW_SECRET, + RENEW_SECRET_FAILURE, + RENEW_SECRET_SUCCESS, + WARNING_NO_SECRET, +} from '../constants'; + +export default { + CONFIRM_MODAL, + CONFIRM_MODAL_TITLE, + COPY_SECRET, + DESCRIPTION_SECRET, + RENEW_SECRET, + name: 'OAuthSecret', + components: { + GlButton, + GlModal, + InputCopyToggleVisibility, + }, + inject: ['initialSecret', 'renewPath'], + data() { + return { + secret: this.initialSecret, + alert: null, + isModalVisible: false, + isLoading: false, + }; + }, + computed: { + actionPrimary() { + return { + text: this.$options.RENEW_SECRET, + attributes: { + variant: 'confirm', + loading: this.isLoading, + }, + }; + }, + }, + created() { + if (!this.secret) { + this.alert = createAlert({ message: WARNING_NO_SECRET, variant: VARIANT_WARNING }); + } + }, + methods: { + displayModal() { + this.isModalVisible = true; + }, + async renewSecret(event) { + event.preventDefault(); + this.isLoading = true; + this.alert?.dismiss(); + + try { + const { data } = await axios.put(this.renewPath); + this.alert = createAlert({ message: RENEW_SECRET_SUCCESS, variant: VARIANT_SUCCESS }); + this.secret = data.secret; + } catch { + this.alert = createAlert({ message: RENEW_SECRET_FAILURE }); + } finally { + this.isLoading = false; + this.isModalVisible = false; + } + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <input-copy-toggle-visibility + v-if="secret" + :copy-button-title="$options.COPY_SECRET" + :value="secret" + class="gl-mt-n3 gl-mb-0" + > + <template #description> + {{ $options.DESCRIPTION_SECRET }} + </template> + </input-copy-toggle-visibility> + + <gl-button category="secondary" class="gl-align-self-start" @click="displayModal">{{ + $options.RENEW_SECRET + }}</gl-button> + + <gl-modal + v-model="isModalVisible" + :title="$options.CONFIRM_MODAL_TITLE" + size="sm" + modal-id="modal-renew-secret" + :action-primary="actionPrimary" + @primary="renewSecret" + > + {{ $options.CONFIRM_MODAL }} + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/oauth_application/constants.js b/app/assets/javascripts/oauth_application/constants.js new file mode 100644 index 00000000000..5eaacadda78 --- /dev/null +++ b/app/assets/javascripts/oauth_application/constants.js @@ -0,0 +1,20 @@ +import { __, s__ } from '~/locale'; + +export const CONFIRM_MODAL = s__( + 'AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab.', +); +export const CONFIRM_MODAL_TITLE = s__('AuthorizedApplication|Renew secret?'); +export const COPY_SECRET = __('Copy secret'); +export const DESCRIPTION_SECRET = __( + 'This is the only time the secret is accessible. Copy the secret and store it securely.', +); +export const RENEW_SECRET = s__('AuthorizedApplication|Renew secret'); +export const RENEW_SECRET_FAILURE = s__( + 'AuthorizedApplication|There was an error trying to renew the application secret. Please try again.', +); +export const RENEW_SECRET_SUCCESS = s__( + 'AuthorizedApplication|Application secret was successfully renewed.', +); +export const WARNING_NO_SECRET = __( + 'The secret is only available when you create the application or renew the secret.', +); diff --git a/app/assets/javascripts/oauth_application/index.js b/app/assets/javascripts/oauth_application/index.js new file mode 100644 index 00000000000..f8f1f647a15 --- /dev/null +++ b/app/assets/javascripts/oauth_application/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import OAuthSecret from './components/oauth_secret.vue'; + +export const initOAuthApplicationSecret = () => { + const el = document.querySelector('#js-oauth-application-secret'); + + if (!el) { + return null; + } + + const { initialSecret, renewPath } = el.dataset; + + return new Vue({ + el, + name: 'OAuthSecretRoot', + provide: { initialSecret, renewPath }, + render(h) { + return h(OAuthSecret); + }, + }); +}; 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 5d77ff9dc0d..4e154870f55 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -4,9 +4,10 @@ import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { formatDate } from '~/lib/utils/datetime_utility'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { - UPDATED_AT, + CREATED_AT, CLEANUP_UNSCHEDULED_TEXT, CLEANUP_SCHEDULED_TEXT, CLEANUP_ONGOING_TEXT, @@ -65,11 +66,11 @@ export default { visibilityIcon() { return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; }, - timeAgo() { - return this.timeFormatted(this.imageDetails.updatedAt); + formattedCreatedAtDate() { + return formatDate(this.imageDetails.createdAt, 'mmm d, yyyy HH:MM', true); }, - updatedText() { - return sprintf(UPDATED_AT, { time: this.timeAgo }); + createdText() { + return sprintf(CREATED_AT, { time: this.formattedCreatedAtDate }); }, tagCountText() { if (this.$apollo.queries.containerRepository.loading) { @@ -145,9 +146,9 @@ export default { <template #metadata-updated> <metadata-item :icon="visibilityIcon" - :text="updatedText" + :text="createdText" size="xl" - data-testid="updated-and-visibility" + data-testid="created-and-visibility" /> </template> <template #right-actions> 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 863d1c2629b..a1c837a4add 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 @@ -3,12 +3,16 @@ import { GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { n__ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.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'; import { + ALERT_SUCCESS_TAG, + ALERT_DANGER_TAG, + ALERT_SUCCESS_TAGS, + ALERT_DANGER_TAGS, REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, GRAPHQL_PAGE_SIZE, @@ -20,19 +24,22 @@ import { NO_TAGS_MATCHING_FILTERS_DESCRIPTION, } from '../../constants/index'; import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; +import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import TagsListRow from './tags_list_row.vue'; +import DeleteModal from './delete_modal.vue'; export default { name: 'TagsList', components: { + DeleteModal, GlEmptyState, TagsListRow, TagsLoader, RegistryList, PersistedSearch, }, + mixins: [Tracking.mixin()], inject: ['config'], - props: { id: { type: [Number, String], @@ -77,6 +84,8 @@ export default { return { containerRepository: {}, filters: {}, + itemsToBeDeleted: [], + mutationLoading: false, sort: null, }; }, @@ -88,7 +97,7 @@ export default { return this.containerRepository?.tags?.nodes || []; }, hideBulkDelete() { - return !(this.containerRepository?.canDelete || false); + return !this.containerRepository?.canDelete; }, tagsPageInfo() { return this.containerRepository?.tags?.pageInfo; @@ -105,7 +114,12 @@ export default { return this.tags.length === 0; }, isLoading() { - return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort; + return ( + this.isImageLoading || + this.$apollo.queries.containerRepository.loading || + this.mutationLoading || + !this.sort + ); }, hasFilters() { return this.filters?.name; @@ -116,17 +130,61 @@ export default { emptyStateDescription() { return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE; }, + tracking() { + return { + label: + this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, }, methods: { + deleteTags(toBeDeleted) { + this.itemsToBeDeleted = toBeDeleted; + this.track('click_button'); + this.$refs.deleteModal.show(); + }, + confirmDelete() { + this.handleDeleteTag(); + }, + async handleDeleteTag() { + this.track('confirm_delete'); + const { itemsToBeDeleted } = this; + this.mutationLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: deleteContainerRepositoryTagsMutation, + variables: { + id: this.queryVariables.id, + tagNames: itemsToBeDeleted.map((item) => item.name), + }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: getContainerRepositoryTagsQuery, + variables: this.queryVariables, + }, + ], + }); + if (data?.destroyContainerRepositoryTags?.errors[0]) { + throw new Error(); + } + this.$emit( + 'delete', + itemsToBeDeleted.length === 1 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS, + ); + this.itemsToBeDeleted = []; + } catch (e) { + this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS); + } finally { + this.mutationLoading = false; + } + }, fetchNextPage() { this.$apollo.queries.containerRepository.fetchMore({ variables: { after: this.tagsPageInfo?.endCursor, first: GRAPHQL_PAGE_SIZE, }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, }); }, fetchPreviousPage() { @@ -136,9 +194,6 @@ export default { before: this.tagsPageInfo?.startCursor, last: GRAPHQL_PAGE_SIZE, }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, }); }, handleSearchUpdate({ sort, filters }) { @@ -193,7 +248,7 @@ export default { id-property="name" @prev-page="fetchPreviousPage" @next-page="fetchNextPage" - @delete="$emit('delete', $event)" + @delete="deleteTags" > <template #default="{ selectItem, isSelected, item, first }"> <tags-list-row @@ -203,10 +258,17 @@ export default { :is-mobile="isMobile" :disabled="disabled" @select="selectItem(item)" - @delete="$emit('delete', [item])" + @delete="deleteTags([item])" /> </template> </registry-list> + + <delete-modal + ref="deleteModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirmDelete="confirmDelete" + @cancel="track('cancel_delete')" + /> </template> </template> </div> 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 7bb69363743..7ac803a8ece 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 @@ -65,7 +65,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( 'ContainerRegistry|Invalid tag: missing manifest digest', ); -export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}'); +export const CREATED_AT = s__('ContainerRegistry|Created %{time}'); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js index 9d0ecfd2dcb..71538ea5a07 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js @@ -1,14 +1,10 @@ import { s__ } from '~/locale'; -export const EXPIRATION_POLICY_WILL_RUN_IN = s__( - 'ContainerRegistry|Expiration policy will run in %{time}', -); -export const EXPIRATION_POLICY_DISABLED_TEXT = s__( - 'ContainerRegistry|Expiration policy is disabled.', -); +export const EXPIRATION_POLICY_WILL_RUN_IN = s__('ContainerRegistry|Cleanup will run in %{time}'); +export const EXPIRATION_POLICY_DISABLED_TEXT = s__('ContainerRegistry|Cleanup is not scheduled.'); export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); export const DELETE_ALERT_LINK_TEXT = s__( - 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}', + 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}', ); export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__( 'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}', diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js index 850dca07a3f..f9820df4a12 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js @@ -6,7 +6,6 @@ Vue.use(VueApollo); export const mergeVariables = (existing, incoming) => { if (!incoming) return existing; - if (!existing) return incoming; return incoming; }; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql index e2036d9e63d..eae663acb48 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -7,7 +7,6 @@ query getContainerRepositoryDetails($id: ContainerRepositoryID!) { location canDelete createdAt - updatedAt expirationPolicyStartedAt expirationPolicyCleanupStatus project { 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 2b5fb1a70ed..c6ed4c06577 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 @@ -15,22 +15,14 @@ import StatusAlert from '../components/details_page/status_alert.vue'; import TagsList from '../components/details_page/tags_list.vue'; import { - ALERT_SUCCESS_TAG, - ALERT_DANGER_TAG, - ALERT_SUCCESS_TAGS, - ALERT_DANGER_TAGS, ALERT_DANGER_IMAGE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, - GRAPHQL_PAGE_SIZE, MISSING_OR_DELETED_IMAGE_TITLE, MISSING_OR_DELETED_IMAGE_MESSAGE, } from '../constants/index'; -import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; -import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; -import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql'; export default { name: 'RegistryDetailsPage', @@ -76,7 +68,6 @@ export default { mutationLoading: false, deleteAlertType: null, hidePartialCleanupWarning: false, - deleteImageAlert: false, }; }, computed: { @@ -97,8 +88,7 @@ export default { }, tracking() { return { - label: - this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + label: 'registry_image_delete', }; }, pageActionsAreDisabled() { @@ -112,57 +102,8 @@ export default { : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, - deleteTags(toBeDeleted) { - this.deleteImageAlert = false; - this.itemsToBeDeleted = toBeDeleted; - this.track('click_button'); - this.$refs.deleteModal.show(); - }, confirmDelete() { - if (this.deleteImageAlert) { - this.$refs.deleteImage.doDelete(); - } else { - this.handleDeleteTag(); - } - }, - async handleDeleteTag() { - this.track('confirm_delete'); - const { itemsToBeDeleted } = this; - this.itemsToBeDeleted = []; - this.mutationLoading = true; - try { - const { data } = await this.$apollo.mutate({ - mutation: deleteContainerRepositoryTagsMutation, - variables: { - id: this.queryVariables.id, - tagNames: itemsToBeDeleted.map((i) => i.name), - }, - awaitRefetchQueries: true, - refetchQueries: [ - { - query: getContainerRepositoryTagsQuery, - variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE }, - }, - { - query: getContainerRepositoriesDetails, - variables: { - fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, - isGroupPage: this.config.isGroupPage, - }, - }, - ], - }); - - if (data?.destroyContainerRepositoryTags?.errors[0]) { - throw new Error(); - } - this.deleteAlertType = - itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; - } catch (e) { - this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; - } - - this.mutationLoading = false; + this.$refs.deleteImage.doDelete(); }, handleResize() { this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs'; @@ -174,7 +115,6 @@ export default { }); }, deleteImage() { - this.deleteImageAlert = true; this.itemsToBeDeleted = [{ ...this.containerRepository }]; this.$refs.deleteModal.show(); }, @@ -185,6 +125,9 @@ export default { this.itemsToBeDeleted = []; this.mutationLoading = true; }, + showAlert(alertType) { + this.deleteAlertType = alertType; + }, }, }; </script> @@ -222,7 +165,7 @@ export default { :is-image-loading="isLoading" :is-mobile="isMobile" :disabled="pageActionsAreDisabled" - @delete="deleteTags" + @delete="showAlert" /> <delete-image @@ -237,7 +180,7 @@ export default { <delete-modal ref="deleteModal" :items-to-be-deleted="itemsToBeDeleted" - :delete-image="deleteImageAlert" + delete-image @confirmDelete="confirmDelete" @cancel="track('cancel_delete')" /> 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 b24ec65464f..d32e90f3adb 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -8,7 +8,6 @@ import { GlFormInputGroup, GlModal, GlModalDirective, - GlSkeletonLoader, GlSprintf, } from '@gitlab/ui'; import { __, s__, n__, sprintf } from '~/locale'; @@ -30,7 +29,6 @@ export default { GlFormGroup, GlFormInputGroup, GlModal, - GlSkeletonLoader, GlSprintf, ClipboardButton, TitleArea, @@ -208,23 +206,20 @@ export default { </template> </gl-form-group> - <gl-skeleton-loader v-if="$apollo.queries.group.loading" /> + <manifests-list + v-if="manifests && manifests.length" + :loading="$apollo.queries.group.loading" + :manifests="manifests" + :pagination="pageInfo" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + /> - <div v-else data-testid="main-area"> - <manifests-list - v-if="manifests && manifests.length" - :manifests="manifests" - :pagination="pageInfo" - @prev-page="fetchPreviousPage" - @next-page="fetchNextPage" - /> - - <gl-empty-state - v-else - :svg-path="noManifestsIllustration" - :title="$options.i18n.noManifestTitle" - /> - </div> + <gl-empty-state + v-else + :svg-path="noManifestsIllustration" + :title="$options.i18n.noManifestTitle" + /> <gl-modal :modal-id="$options.confirmClearCacheModal" 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 005c8feea3a..0d9b8330fe3 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 @@ -1,5 +1,5 @@ <script> -import { GlKeysetPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui'; import { s__ } from '~/locale'; import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; @@ -8,6 +8,7 @@ export default { components: { ManifestRow, GlKeysetPagination, + GlSkeletonLoader, }, props: { manifests: { @@ -19,6 +20,11 @@ export default { type: Object, required: true, }, + loading: { + type: Boolean, + required: false, + default: () => false, + }, }, i18n: { listTitle: s__('DependencyProxy|Image list'), @@ -34,19 +40,22 @@ export default { <template> <div class="gl-mt-6"> <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3> - <div - class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column" - > - <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" /> - </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')" - @next="$emit('next-page')" - /> + <gl-skeleton-loader v-if="loading" /> + <div v-else data-testid="main-area"> + <div + class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column" + > + <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" /> + </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')" + @next="$emit('next-page')" + /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue index 9bab08b8548..a9d076afb92 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue @@ -36,7 +36,7 @@ export default { }, }, i18n: { - LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'), + LIST_TITLE_TEXT: s__('InfrastructureRegistry|Terraform Module Registry'), LIST_INTRO_TEXT: s__( 'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}', ), diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue index b167fff26b0..f790c7b1430 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue @@ -1,39 +1,74 @@ <script> -import { GlModal } from '@gitlab/ui'; -import { __, n__ } from '~/locale'; +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import { + DELETE_MODAL_CONTENT, + DELETE_MODAL_TITLE, + DELETE_PACKAGES_MODAL_DESCRIPTION, DELETE_PACKAGES_MODAL_TITLE, DELETE_PACKAGE_MODAL_PRIMARY_ACTION, + DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT, + DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT, + DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + REQUEST_FORWARDING_HELP_PAGE_PATH, } from '~/packages_and_registries/package_registry/constants'; export default { name: 'DeleteModal', i18n: { - DELETE_PACKAGES_MODAL_TITLE, + DELETE_MODAL_CONTENT, + DELETE_PACKAGES_MODAL_DESCRIPTION, }, components: { + GlLink, GlModal, + GlSprintf, }, props: { itemsToBeDeleted: { type: Array, required: true, }, + showRequestForwardingContent: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - description() { - return n__( - 'PackageRegistry|You are about to delete 1 package. This operation is irreversible.', - `PackageRegistry|You are about to delete %d packages. This operation is irreversible.`, - this.itemsToBeDeleted.length, - ); + itemToBeDeleted() { + if (this.itemsToBeDeleted.length === 1) { + const [itemToBeDeleted] = this.itemsToBeDeleted; + return itemToBeDeleted; + } + return null; + }, + title() { + return this.itemToBeDeleted ? DELETE_MODAL_TITLE : DELETE_PACKAGES_MODAL_TITLE; + }, + packagesDeletePrimaryActionProps() { + let text = DELETE_PACKAGE_MODAL_PRIMARY_ACTION; + + if (this.showRequestForwardingContent) { + if (this.itemToBeDeleted) { + text = DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION; + } else { + text = DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION; + } + } + return { + text, + attributes: { variant: 'danger', category: 'primary' }, + }; + }, + requestForwardingContentMessage() { + return this.itemToBeDeleted + ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT + : DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT; }, }, modal: { - packagesDeletePrimaryAction: { - text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION, - attributes: { variant: 'danger', category: 'primary' }, - }, cancelAction: { text: __('Cancel'), }, @@ -43,6 +78,9 @@ export default { this.$refs.deleteModal.show(); }, }, + links: { + REQUEST_FORWARDING_HELP_PAGE_PATH, + }, }; </script> @@ -51,12 +89,34 @@ export default { ref="deleteModal" size="sm" modal-id="delete-packages-modal" - :action-primary="$options.modal.packagesDeletePrimaryAction" + :action-primary="packagesDeletePrimaryActionProps" :action-cancel="$options.modal.cancelAction" - :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE" + :title="title" @primary="$emit('confirm')" @cancel="$emit('cancel')" > - <span>{{ description }}</span> + <p v-if="showRequestForwardingContent"> + <gl-sprintf :message="requestForwardingContentMessage"> + <template #docLink="{ content }"> + <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p v-else> + <gl-sprintf v-if="itemToBeDeleted" :message="$options.i18n.DELETE_MODAL_CONTENT"> + <template #version> + <strong>{{ itemToBeDeleted.version }}</strong> + </template> + + <template #name> + <strong>{{ itemToBeDeleted.name }}</strong> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="$options.i18n.DELETE_PACKAGES_MODAL_DESCRIPTION"> + <template #count> + {{ itemsToBeDeleted.length }} + </template> + </gl-sprintf> + </p> </gl-modal> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index 3d5ac528920..7ea19df7a6c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,4 +1,6 @@ <script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { n__ } from '~/locale'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; @@ -10,16 +12,20 @@ import { CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, DELETE_PACKAGE_VERSION_TRACKING_ACTION, DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE, + GRAPHQL_PAGE_SIZE, REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, } from '~/packages_and_registries/package_registry/constants'; import Tracking from '~/tracking'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; +import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql'; export default { components: { DeleteModal, DeletePackageModal, + GlAlert, VersionRow, PackagesListLoader, RegistryList, @@ -31,33 +37,65 @@ export default { required: false, default: false, }, - versions: { - type: Array, - required: true, - default: () => [], - }, - pageInfo: { - type: Object, - required: true, + count: { + type: Number, + required: false, + default: 0, }, - isLoading: { + isMutationLoading: { type: Boolean, required: false, default: false, }, + packageId: { + type: String, + required: true, + }, }, data() { return { itemToBeDeleted: null, itemsToBeDeleted: [], + packageVersions: {}, + fetchPackageVersionsError: false, }; }, + apollo: { + packageVersions: { + query: getPackageVersionsQuery, + variables() { + return this.queryVariables; + }, + skip() { + return this.isListEmpty; + }, + update(data) { + return data.package?.versions ?? {}; + }, + error(error) { + this.fetchPackageVersionsError = true; + Sentry.captureException(error); + }, + }, + }, computed: { + isListEmpty() { + return this.count === 0; + }, + isLoading() { + return this.$apollo.queries.packageVersions.loading || this.isMutationLoading; + }, + pageInfo() { + return this.packageVersions?.pageInfo ?? {}; + }, listTitle() { return n__('%d version', '%d versions', this.versions.length); }, - isListEmpty() { - return this.versions.length === 0; + queryVariables() { + return { + id: this.packageId, + first: GRAPHQL_PAGE_SIZE, + }; }, tracking() { const category = this.itemToBeDeleted @@ -67,6 +105,9 @@ export default { category, }; }, + versions() { + return this.packageVersions?.nodes ?? []; + }, }, methods: { deleteItemConfirmation() { @@ -101,6 +142,32 @@ export default { this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); this.$refs.deletePackagesModal.show(); }, + fetchPreviousVersionsPage() { + const variables = { + ...this.queryVariables, + first: null, + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo?.startCursor, + }; + this.$apollo.queries.packageVersions.fetchMore({ + variables, + }); + }, + fetchNextVersionsPage() { + const variables = { + ...this.queryVariables, + first: GRAPHQL_PAGE_SIZE, + last: null, + after: this.pageInfo?.endCursor, + }; + + this.$apollo.queries.packageVersions.fetchMore({ + variables, + }); + }, + }, + i18n: { + errorMessage: FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE, }, }; </script> @@ -109,6 +176,9 @@ export default { <div v-if="isLoading"> <packages-list-loader /> </div> + <gl-alert v-else-if="fetchPackageVersionsError" variant="danger" :dismissible="false">{{ + $options.i18n.errorMessage + }}</gl-alert> <slot v-else-if="isListEmpty" name="empty-state"></slot> <div v-else> <registry-list @@ -118,8 +188,8 @@ export default { :pagination="pageInfo" :title="listTitle" @delete="setItemsToBeDeleted" - @prev-page="$emit('prev-page')" - @next-page="$emit('next-page')" + @prev-page="fetchPreviousVersionsPage" + @next-page="fetchNextVersionsPage" > <template #default="{ first, item, isSelected, selectItem }"> <version-row diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index 193a222853f..37a6fe75f15 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -130,13 +130,14 @@ export default { <template v-if="packageEntity.canDestroy" #right-action> <gl-dropdown + data-testid="delete-dropdown" icon="ellipsis_v" :text="$options.i18n.moreActions" :text-sr-only="true" category="tertiary" no-caret > - <gl-dropdown-item variant="danger" @click="$emit('delete')">{{ + <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{ $options.i18n.deletePackage }}</gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue index 440e11a99f2..05359128af4 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue @@ -39,5 +39,8 @@ export default { <template #metadata-amount> <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> </template> + <template #right-actions> + <slot name="settings-link"></slot> + </template> </title-area> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 486ab4fdc99..effed4891d8 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -1,7 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; -import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; @@ -14,16 +13,24 @@ import { CANCEL_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGES_TRACKING_ACTION, PACKAGE_ERROR_STATUS, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, + PACKAGE_TYPE_PYPI, } from '~/packages_and_registries/package_registry/constants'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import Tracking from '~/tracking'; +const forwardingFieldToPackageTypeMapping = { + mavenPackageRequestsForwarding: PACKAGE_TYPE_MAVEN, + npmPackageRequestsForwarding: PACKAGE_TYPE_NPM, + pypiPackageRequestsForwarding: PACKAGE_TYPE_PYPI, +}; + export default { name: 'PackagesList', components: { GlAlert, DeleteModal, - DeletePackageModal, PackagesListLoader, PackagesListRow, RegistryList, @@ -44,16 +51,27 @@ export default { type: Object, required: true, }, + groupSettings: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { - itemToBeDeleted: null, itemsToBeDeleted: [], errorPackages: [], }; }, computed: { + itemToBeDeleted() { + if (this.itemsToBeDeleted.length === 1) { + const [itemToBeDeleted] = this.itemsToBeDeleted; + return itemToBeDeleted; + } + return null; + }, listTitle() { return n__('%d package', '%d packages', this.list.length); }, @@ -77,6 +95,15 @@ export default { showErrorPackageAlert() { return this.errorPackages.length > 0; }, + packageTypesWithForwardingEnabled() { + return Object.keys(this.groupSettings) + .filter((field) => this.groupSettings[field]) + .map((field) => forwardingFieldToPackageTypeMapping[field]); + }, + isRequestForwardingEnabled() { + const selectedPackageTypes = new Set(this.itemsToBeDeleted.map((item) => item.packageType)); + return this.packageTypesWithForwardingEnabled.some((type) => selectedPackageTypes.has(type)); + }, }, watch: { list(newVal) { @@ -88,40 +115,36 @@ export default { this.list.length > 0 ? this.list.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS) : []; }, methods: { - setItemToBeDeleted(item) { - this.itemToBeDeleted = { ...item }; - this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION); - }, setItemsToBeDeleted(items) { + this.itemsToBeDeleted = items; if (items.length === 1) { - const [item] = items; - this.setItemToBeDeleted(item); - return; + this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION); + } else { + this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION); } - this.itemsToBeDeleted = items; - this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION); this.$refs.deletePackagesModal.show(); }, deleteItemsConfirmation() { this.$emit('delete', this.itemsToBeDeleted); - this.track(DELETE_PACKAGES_TRACKING_ACTION); + + if (this.itemToBeDeleted) { + this.track(DELETE_PACKAGE_TRACKING_ACTION); + } else { + this.track(DELETE_PACKAGES_TRACKING_ACTION); + } + this.itemsToBeDeleted = []; }, deleteItemsCanceled() { - this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION); + if (this.itemToBeDeleted) { + this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION); + } else { + this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION); + } this.itemsToBeDeleted = []; }, - deleteItemConfirmation() { - this.$emit('delete', [this.itemToBeDeleted]); - this.track(DELETE_PACKAGE_TRACKING_ACTION); - this.itemToBeDeleted = null; - }, - deleteItemCanceled() { - this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION); - this.itemToBeDeleted = null; - }, showConfirmationModal() { - this.setItemToBeDeleted(this.errorPackages[0]); + this.setItemsToBeDeleted([this.errorPackages[0]]); }, }, i18n: { @@ -165,21 +188,16 @@ export default { :first="first" :package-entity="item" :selected="isSelected(item)" - @delete="setItemToBeDeleted(item)" + @delete="setItemsToBeDeleted([item])" @select="selectItem(item)" /> </template> </registry-list> - <delete-package-modal - :item-to-be-deleted="itemToBeDeleted" - @ok="deleteItemConfirmation" - @cancel="deleteItemCanceled" - /> - <delete-modal ref="deletePackagesModal" :items-to-be-deleted="itemsToBeDeleted" + :show-request-forwarding-content="isRequestForwardingEnabled" @confirm="deleteItemsConfirmation" @cancel="deleteItemsCanceled" /> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index eda8d9e0066..ad5edcd7602 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -115,6 +115,10 @@ export const DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'delete_package_version'; export const REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'request_delete_package_version'; export const CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'cancel_delete_package_version'; +export const FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE = s__( + 'PackageRegistry|Failed to load version data', +); + export const DELETE_PACKAGES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting packages.', ); @@ -122,6 +126,21 @@ export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages del export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages'); export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete'); +export const DELETE_PACKAGES_MODAL_DESCRIPTION = s__( + 'PackageRegistry|You are about to delete %{count} packages. This operation is irreversible.', +); +export const DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__( + 'PackageRegistry|Yes, delete package', +); +export const DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__( + 'PackageRegistry|Yes, delete selected packages', +); +export const DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT = s__( + 'PackageRegistry|Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete the package anyway? %{docLinkStart}What are the risks?%{docLinkEnd}', +); +export const DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT = s__( + 'PackageRegistry|Some of the selected package formats allow request forwarding. Deleting a package while request forwarding is enabled for the project can pose a security risk. Do you want to proceed with deleting the selected packages? %{docLinkStart}What are the risks?%{docLinkEnd}', +); export const DELETE_PACKAGE_TEXT = s__('PackageRegistry|Delete package'); export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully'); @@ -207,5 +226,9 @@ export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/inde export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index'); export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index'); export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens'); +export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath( + 'user/packages/package_registry/supported_functionality', + { anchor: 'deleting-packages' }, +); export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql new file mode 100644 index 00000000000..db05f497b7f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql @@ -0,0 +1,8 @@ +fragment GroupPackageSettings on Group { + id + packageSettings { + mavenPackageRequestsForwarding + npmPackageRequestsForwarding + pypiPackageRequestsForwarding + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js index 56f95fa2c1f..39e5da54509 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -4,6 +4,27 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); +export const mergeVariables = (existing, incoming) => { + if (!incoming) return existing; + return incoming; +}; + export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + cacheConfig: { + typePolicies: { + PackageDetailsType: { + fields: { + versions: { + keyArgs: false, + merge: mergeVariables, + }, + }, + }, + }, + }, + }, + ), }); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index b5313f929f8..99864f7ad0c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -1,10 +1,4 @@ -query getPackageDetails( - $id: PackagesPackageID! - $first: Int - $last: Int - $after: String - $before: String -) { +query getPackageDetails($id: PackagesPackageID!) { package(id: $id) { id name @@ -62,31 +56,8 @@ query getPackageDetails( downloadPath } } - versions(after: $after, before: $before, first: $first, last: $last) { + versions { count - nodes { - id - name - canDestroy - createdAt - version - status - _links { - webPath - } - tags(first: 1) { - nodes { - id - name - } - } - } - pageInfo { - hasNextPage - hasPreviousPage - endCursor - startCursor - } } dependencyLinks { nodes { diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql new file mode 100644 index 00000000000..a4119ac5821 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql @@ -0,0 +1,38 @@ +query getPackageVersions( + $id: PackagesPackageID! + $first: Int + $last: Int + $after: String + $before: String +) { + package(id: $id) { + id + versions(after: $after, before: $before, first: $first, last: $last) { + count + nodes { + id + name + canDestroy + createdAt + packageType + version + status + _links { + webPath + } + tags(first: 1) { + nodes { + id + name + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql index 5bde5f08e56..f25f24cbc5f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -1,4 +1,5 @@ #import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql" +#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getPackages( @@ -32,6 +33,9 @@ query getPackages( ...PageInfo } } + group { + ...GroupPackageSettings + } } group(fullPath: $fullPath) @include(if: $isGroupPage) { id @@ -52,5 +56,6 @@ query getPackages( ...PageInfo } } + ...GroupPackageSettings } } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js index 15ed98122a0..e2f8d239bae 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -19,6 +19,7 @@ export default () => { npmInstanceUrl, projectListUrl, groupListUrl, + settingsPath, } = el.dataset; const isGroupPage = pageType === 'groups'; @@ -48,6 +49,7 @@ export default () => { projectListUrl, groupListUrl, breadCrumbState, + settingsPath, }, render(createElement) { return createElement(PackageRegistry); 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 1ce2140894e..0f1c63a04ad 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 @@ -54,6 +54,7 @@ import { import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; +import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql'; import Tracking from '~/tracking'; export default { @@ -135,7 +136,6 @@ export default { queryVariables() { return { id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId), - first: GRAPHQL_PAGE_SIZE, }; }, packageFiles() { @@ -147,9 +147,6 @@ export default { isLoading() { return this.$apollo.queries.packageEntity.loading; }, - isVersionsLoading() { - return this.isLoading || this.versionsMutationLoading; - }, packageFilesLoading() { return this.isLoading || this.mutationLoading; }, @@ -161,12 +158,12 @@ export default { category: packageTypeToTrackCategory(this.packageType), }; }, - versionPageInfo() { - return this.packageEntity?.versions?.pageInfo ?? {}; - }, packageDependencies() { return this.packageEntity.dependencyLinks?.nodes || []; }, + packageVersionsCount() { + return this.packageEntity.versions?.count ?? 0; + }, showDependencies() { return this.packageType === PACKAGE_TYPE_NUGET; }, @@ -190,6 +187,17 @@ export default { }, ]; }, + refetchVersionsQueryData() { + return [ + { + query: getPackageVersionsQuery, + variables: { + id: this.queryVariables.id, + first: GRAPHQL_PAGE_SIZE, + }, + }, + ]; + }, }, methods: { formatSize(size) { @@ -274,34 +282,6 @@ export default { resetDeleteModalContent() { this.deletePackageModalContent = DELETE_MODAL_CONTENT; }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, - fetchPreviousVersionsPage() { - const variables = { - ...this.queryVariables, - first: null, - last: GRAPHQL_PAGE_SIZE, - before: this.versionPageInfo?.startCursor, - }; - this.$apollo.queries.packageEntity.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - }, - fetchNextVersionsPage() { - const variables = { - ...this.queryVariables, - first: GRAPHQL_PAGE_SIZE, - last: null, - after: this.versionPageInfo?.endCursor, - }; - - this.$apollo.queries.packageEntity.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); - }, }, i18n: { DELETE_MODAL_TITLE, @@ -403,12 +383,12 @@ export default { <template #title> <span>{{ $options.i18n.otherVersionsTabTitle }}</span> <gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{ - packageEntity.versions.count + packageVersionsCount }}</gl-badge> </template> <delete-packages - :refetch-queries="refetchQueriesData" + :refetch-queries="refetchVersionsQueryData" show-success-alert @start="versionsMutationLoading = true" @end="versionsMutationLoading = false" @@ -416,12 +396,10 @@ export default { <template #default="{ deletePackages }"> <package-versions-list :can-destroy="packageEntity.canDestroy" - :is-loading="isVersionsLoading" - :page-info="versionPageInfo" - :versions="packageEntity.versions.nodes" + :count="packageVersionsCount" + :is-mutation-loading="versionsMutationLoading" + :package-id="packageEntity.id" @delete="deletePackages" - @prev-page="fetchPreviousVersionsPage" - @next-page="fetchNextVersionsPage" > <template #empty-state> <p class="gl-mt-3" data-testid="no-versions-message"> 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 6e92a6420ac..044ce4e6413 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,5 +1,5 @@ <script> -import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_INFO } from '~/alert'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { historyReplaceState } from '~/lib/utils/common_utils'; @@ -19,6 +19,7 @@ import PackageList from '~/packages_and_registries/package_registry/components/l export default { components: { + GlButton, GlEmptyState, GlLink, GlSprintf, @@ -27,23 +28,26 @@ export default { PackageSearch, DeletePackages, }, - inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['emptyListIllustration', 'isGroupPage', 'fullPath', 'settingsPath'], data() { return { - packages: {}, + packagesResource: {}, sort: '', filters: {}, mutationLoading: false, }; }, apollo: { - packages: { + packagesResource: { query: getPackagesQuery, variables() { return this.queryVariables; }, update(data) { - return data[this.graphqlResource]?.packages ?? {}; + return data[this.graphqlResource] ?? {}; }, skip() { return !this.sort; @@ -51,6 +55,14 @@ export default { }, }, computed: { + packages() { + return this.packagesResource?.packages ?? {}; + }, + groupSettings() { + return this.isGroupPage + ? this.packagesResource?.packageSettings ?? {} + : this.packagesResource?.group?.packageSettings ?? {}; + }, queryVariables() { return { isGroupPage: this.isGroupPage, @@ -83,7 +95,7 @@ export default { : this.$options.i18n.noResultsTitle; }, isLoading() { - return this.$apollo.queries.packages.loading || this.mutationLoading; + return this.$apollo.queries.packagesResource.loading || this.mutationLoading; }, refetchQueriesData() { return [ @@ -123,7 +135,7 @@ export default { after: this.pageInfo?.endCursor, }; - this.$apollo.queries.packages.fetchMore({ + this.$apollo.queries.packagesResource.fetchMore({ variables, updateQuery: this.updateQuery, }); @@ -136,7 +148,7 @@ export default { before: this.pageInfo?.startCursor, }; - this.$apollo.queries.packages.fetchMore({ + this.$apollo.queries.packagesResource.fetchMore({ variables, updateQuery: this.updateQuery, }); @@ -149,6 +161,7 @@ export default { noResultsText: s__( 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', ), + settingsText: s__('PackageRegistry|Configure in settings'), }, links: { EMPTY_LIST_HELP_URL, @@ -159,7 +172,16 @@ export default { <template> <div> - <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> + <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount"> + <template v-if="settingsPath" #settings-link> + <gl-button + v-gl-tooltip="$options.i18n.settingsText" + icon="settings" + :href="settingsPath" + :aria-label="$options.i18n.settingsText" + /> + </template> + </package-title> <package-search class="gl-mb-5" @update="handleSearchUpdate" /> <delete-packages @@ -170,6 +192,7 @@ export default { > <template #default="{ deletePackages }"> <package-list + :group-settings="groupSettings" :list="packages.nodes" :is-loading="isLoading" :page-info="pageInfo" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue index b7d7f0aaca7..ab88d9e8936 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue @@ -1,12 +1,14 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { isEqual } from 'lodash'; import { + PACKAGE_FORWARDING_SECURITY_DESCRIPTION, PACKAGE_FORWARDING_SETTINGS_HEADER, PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, PACKAGE_FORWARDING_FORM_BUTTON, PACKAGE_FORWARDING_FIELDS, MAVEN_FORWARDING_FIELDS, + REQUEST_FORWARDING_HELP_PAGE_PATH, } from '~/packages_and_registries/settings/group/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; @@ -20,12 +22,15 @@ export default { name: 'PackageForwardingSettings', i18n: { PACKAGE_FORWARDING_FORM_BUTTON, + PACKAGE_FORWARDING_SECURITY_DESCRIPTION, PACKAGE_FORWARDING_SETTINGS_HEADER, PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, }, components: { ForwardingSettings, GlButton, + GlLink, + GlSprintf, SettingsBlock, }, mixins: [glFeatureFlagsMixin()], @@ -150,6 +155,9 @@ export default { this.$set(this.workingCopy, type, value); }, }, + links: { + REQUEST_FORWARDING_HELP_PAGE_PATH, + }, }; </script> @@ -157,9 +165,14 @@ export default { <settings-block> <template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template> <template #description> - <span data-testid="description"> + <span class="gl-display-block gl-mb-2" data-testid="description"> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }} </span> + <gl-sprintf :message="$options.i18n.PACKAGE_FORWARDING_SECURITY_DESCRIPTION"> + <template #docLink="{ content }"> + <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link> + </template> + </gl-sprintf> </template> <template #default> <form @submit.prevent="submit"> 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 b47759df35f..fa73c01c5c4 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -17,6 +17,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Publish packages if their name or version matches this regex.', ); +export const PACKAGE_FORWARDING_SECURITY_DESCRIPTION = s__( + 'PackageRegistry|There are security risks if packages are deleted while request forwarding is enabled. %{docLinkStart}What are the risks?%{docLinkEnd}', +); export const PACKAGE_FORWARDING_SETTINGS_HEADER = s__('PackageRegistry|Package forwarding'); export const PACKAGE_FORWARDING_SETTINGS_DESCRIPTION = s__( 'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.', @@ -79,3 +82,7 @@ export const MAVEN_FORWARDING_FIELDS = { // Parameters export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index'); +export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath( + 'user/packages/package_registry/supported_functionality', + { anchor: 'deleting-packages' }, +); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue index 7a9ea7c0bf7..35fc0910a16 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue @@ -8,7 +8,7 @@ import { export default { i18n: { - toggleLabel: s__('ContainerRegistry|Enable expiration policy'), + toggleLabel: s__('ContainerRegistry|Enable cleanup policy'), }, components: { GlFormGroup, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 731fb3e4c45..5f59372e5ba 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -1,6 +1,6 @@ import { s__, __ } from '~/locale'; -export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`); +export const CONTAINER_CLEANUP_POLICY_TITLE = s__('ContainerRegistry|Cleanup policies'); export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__( `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`, ); diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index 76623377d90..adffab277cc 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -55,15 +55,6 @@ export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) => RegistryBreadcrumb, }, render(createElement) { - // FIXME(@tnir): this is a workaround until the MR gets merged: - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115 - const parentEl = breadCrumbEl.parentElement.parentElement; - if (parentEl) { - parentEl.classList.remove('breadcrumbs-container'); - parentEl.classList.add('gl-display-flex'); - parentEl.classList.add('w-100'); - } - // End of FIXME(@tnir) return createElement('registry-breadcrumb', { class: breadCrumbEl.className, props: { diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js index feceeb0b10a..ea7c9042e6d 100644 --- a/app/assets/javascripts/pages/abuse_reports/index.js +++ b/app/assets/javascripts/pages/abuse_reports/index.js @@ -1,3 +1,5 @@ import { initLinkToSpam } from '~/abuse_reports'; +import initFilePickers from '~/file_pickers'; initLinkToSpam(); +initFilePickers(); diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js index 3397b02aeba..df9e38431b0 100644 --- a/app/assets/javascripts/pages/admin/applications/index.js +++ b/app/assets/javascripts/pages/admin/applications/index.js @@ -1,3 +1,5 @@ import initApplicationDeleteButtons from '~/admin/applications'; +import { initOAuthApplicationSecret } from '~/oauth_application'; initApplicationDeleteButtons(); +initOAuthApplicationSecret(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue index 72cfc005782..72cfc005782 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue +++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue index 3bc785ee1b6..3bc785ee1b6 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js index cfde1fc0a2b..84be895e194 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { DEFAULT_FIELDS } from '~/jobs/components/table/constants'; export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); @@ -10,3 +11,11 @@ export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed'); export const CANCEL_JOBS_WARNING = s__( "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?", ); + +/* Admin Table constants */ +export const DEFAULT_FIELDS_ADMIN = [ + ...DEFAULT_FIELDS.slice(0, 2), + { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, + { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, + ...DEFAULT_FIELDS.slice(2), +]; diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue new file mode 100644 index 00000000000..b89e311ff1d --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue @@ -0,0 +1,118 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { validateQueryString } from '~/jobs/components/filtered_search/utils'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; +import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue'; +import { DEFAULT_FIELDS_ADMIN } from '../constants'; +import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql'; + +export default { + i18n: { + jobsFetchErrorMsg: __('There was an error fetching the jobs.'), + }, + components: { + JobsTableEmptyState, + GlAlert, + JobsTable, + JobsTableTabs, + }, + inject: { + jobStatuses: { + default: null, + }, + url: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, + apollo: { + jobs: { + query: GetAllJobs, + variables() { + return this.variables; + }, + update(data) { + const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data || {}; + return { + list, + pageInfo, + count, + }; + }, + error() { + this.error = this.$options.i18n.jobsFetchErrorMsg; + }, + }, + }, + data() { + return { + jobs: { + list: [], + }, + error: '', + count: 0, + scope: null, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + DEFAULT_FIELDS_ADMIN, + }; + }, + computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search + showEmptyState() { + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); + }, + variables() { + return { ...this.validatedQueryString }; + }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, + jobsCount() { + return this.jobs.count; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount) { + if (this.scope) return; + + this.count = newCount; + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="error" class="gl-mt-2" variant="danger" dismissible @dismiss="error = ''"> + {{ error }} + </gl-alert> + + <jobs-table-tabs :all-jobs-count="count" :loading="loading" /> + + <jobs-table-empty-state v-if="showEmptyState" /> + + <jobs-table + v-else + :jobs="jobs.list" + :table-fields="DEFAULT_FIELDS_ADMIN" + class="gl-table-no-top-border" + /> + </div> +</template> diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js new file mode 100644 index 00000000000..fd7ee2a6f8c --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js @@ -0,0 +1,62 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Query: { + fields: { + jobs: { + keyArgs: ['statuses'], + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + if (incoming.nodes) { + let nodes; + + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + return { + nodes: existing.nodes, + pageInfo: existing.pageInfo, + statuses: args.statuses, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql new file mode 100644 index 00000000000..374009efa15 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql @@ -0,0 +1,81 @@ +query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) { + jobs(after: $after, first: $first, statuses: $statuses) { + count + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + artifacts { + nodes { + id + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + project { + id + fullPath + webUrl + } + path + user { + id + webPath + avatarUrl + } + } + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } +} diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue deleted file mode 100644 index c5a0509b625..00000000000 --- a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue +++ /dev/null @@ -1,19 +0,0 @@ -<script> -export default { - inject: { - jobStatuses: { - default: null, - }, - url: { - default: '', - }, - emptyStateSvgPath: { - default: '', - }, - }, -}; -</script> - -<template> - <div>{{ __('Jobs') }}</div> -</template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 9df52557212..9c2a255a1a3 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,11 +1,21 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; -import { CANCEL_JOBS_MODAL_ID } from './components/constants'; -import CancelJobsModal from './components/cancel_jobs_modal.vue'; -import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue'; +import createDefaultClient from '~/lib/graphql'; +import { CANCEL_JOBS_MODAL_ID } from '../components/constants'; +import CancelJobsModal from '../components/cancel_jobs_modal.vue'; +import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue'; +import cacheConfig from '../components/table/graphql/cache_config'; Vue.use(Translate); +Vue.use(VueApollo); + +const client = createDefaultClient({}, { cacheConfig }); + +const apolloProvider = new VueApollo({ + defaultClient: client, +}); function initJobs() { const buttonId = 'js-stop-jobs-button'; @@ -44,6 +54,7 @@ export function initAdminJobsApp() { return new Vue({ el: containerEl, + apolloProvider, provide: { url, emptyStateSvgPath, diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index dec06fe6f4d..721168f6140 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -9,6 +9,7 @@ import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import initConfirmDanger from '~/init_confirm_danger'; +import { initGroupSettingsReadme } from '~/groups/settings/init_group_settings_readme'; initFilePickers(); initConfirmDanger(); @@ -27,3 +28,5 @@ initProjectSelects(); initSearchSettings(); initCascadingSettingsLockPopovers(); + +initGroupSettingsReadme(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 1b3c7ba5a52..2e71eced66f 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -23,7 +23,7 @@ const APP_OPTIONS = { requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { show: true, - tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'], + tokens: ['two_factor', 'with_inherited_permissions', 'enterprise', 'user_type'], searchParam: 'search', placeholder: s__('Members|Filter members'), recentSearchesStorageKey: 'group_members', diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index 8b68cb5f3bf..513f4968dbd 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -11,6 +11,10 @@ export default { NewNamespacePage, }, props: { + rootPath: { + type: String, + required: true, + }, groupsUrl: { type: String, required: true, @@ -44,6 +48,7 @@ export default { { text: s__('GroupsNew|New subgroup'), href: '#' }, ] : [ + { text: s__('Navigation|Your work'), href: this.rootPath }, { text: s__('GroupsNew|Groups'), href: this.groupsUrl }, { text: s__('GroupsNew|New group'), href: '#' }, ]; diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index b16c5f3da9f..6227d5ff880 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -22,6 +22,7 @@ initFilePickers(); function initNewGroupCreation(el) { const { hasErrors, + rootPath, groupsUrl, parentGroupUrl, parentGroupName, @@ -33,6 +34,7 @@ function initNewGroupCreation(el) { const props = { groupsUrl, + rootPath, parentGroupUrl, parentGroupName, importExistingGroupPath, diff --git a/app/assets/javascripts/pages/groups/runners/new/index.js b/app/assets/javascripts/pages/groups/runners/new/index.js new file mode 100644 index 00000000000..318643d95a4 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/new/index.js @@ -0,0 +1,3 @@ +import { initGroupNewRunner } from '~/ci/runner/group_new_runner'; + +initGroupNewRunner(); diff --git a/app/assets/javascripts/pages/groups/runners/register/index.js b/app/assets/javascripts/pages/groups/runners/register/index.js new file mode 100644 index 00000000000..b02e33e21f2 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/register/index.js @@ -0,0 +1,3 @@ +import { initGroupRegisterRunner } from '~/ci/runner/group_register_runner'; + +initGroupRegisterRunner(); diff --git a/app/assets/javascripts/pages/groups/settings/applications/index.js b/app/assets/javascripts/pages/groups/settings/applications/index.js new file mode 100644 index 00000000000..4dee5433ec9 --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/applications/index.js @@ -0,0 +1,3 @@ +import { initOAuthApplicationSecret } from '~/oauth_application'; + +initOAuthApplicationSecret(); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 52124865bcc..dba65c7e791 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new */ - import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; @@ -7,11 +5,11 @@ import initNotificationsDropdown from '~/notifications'; import ProjectsList from '~/projects_list'; export default function initGroupDetails() { - new ShortcutsNavigation(); + new ShortcutsNavigation(); // eslint-disable-line no-new initNotificationsDropdown(); - new ProjectsList(); + new ProjectsList(); // eslint-disable-line no-new initInviteMembersBanner(); initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index 53bceb3a6f0..f6a4ca0f360 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,5 +1,6 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; +import { initGroupReadme } from '~/groups/init_group_readme'; import initReadMore from '~/read_more'; import initGroupDetails from '../shared/group_details'; @@ -7,3 +8,4 @@ leaveByUrl('group'); initGroupDetails(); initGroupOverviewTabs(); initReadMore(); +initGroupReadme(); diff --git a/app/assets/javascripts/pages/import/github/details/index.js b/app/assets/javascripts/pages/import/github/details/index.js new file mode 100644 index 00000000000..44a85589c9d --- /dev/null +++ b/app/assets/javascripts/pages/import/github/details/index.js @@ -0,0 +1,3 @@ +import initImportDetails from '~/import/details'; + +initImportDetails(); diff --git a/app/assets/javascripts/pages/oauth/applications/index.js b/app/assets/javascripts/pages/oauth/applications/index.js new file mode 100644 index 00000000000..4dee5433ec9 --- /dev/null +++ b/app/assets/javascripts/pages/oauth/applications/index.js @@ -0,0 +1,3 @@ +import { initOAuthApplicationSecret } from '~/oauth_application'; + +initOAuthApplicationSecret(); diff --git a/app/assets/javascripts/pages/profiles/comment_templates/index.js b/app/assets/javascripts/pages/profiles/comment_templates/index.js new file mode 100644 index 00000000000..413816c29cc --- /dev/null +++ b/app/assets/javascripts/pages/profiles/comment_templates/index.js @@ -0,0 +1,3 @@ +import { initCommentTemplates } from '~/comment_templates'; + +initCommentTemplates(); diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js deleted file mode 100644 index ef227b82172..00000000000 --- a/app/assets/javascripts/pages/profiles/saved_replies/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { initSavedReplies } from '~/saved_replies'; - -initSavedReplies(); diff --git a/app/assets/javascripts/pages/projects/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js index 4aa9b225790..df8f110a60d 100644 --- a/app/assets/javascripts/pages/projects/artifacts/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/index.js @@ -1,3 +1,3 @@ -import { initArtifactsTable } from '~/artifacts/index'; +import { initArtifactsTable } from '~/ci/artifacts/index'; initArtifactsTable(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 02fcc6ea940..ec894586803 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -8,6 +8,7 @@ import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; import createDefaultClient from '~/lib/graphql'; import initBlob from '~/pages/projects/init_blob'; +import ForkInfo from '~/repository/components/fork_info.vue'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; @@ -16,6 +17,7 @@ import createStore from '~/code_navigation/store'; import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils'; import RefSelector from '~/ref/components/ref_selector.vue'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(Vuex); Vue.use(VueApollo); @@ -44,6 +46,7 @@ const initRefSwitcher = () => { projectId, value: refType ? joinPaths('refs', refType, ref) : ref, useSymbolicRefNames: true, + queryParams: { sort: 'updated_desc' }, }, on: { input(selectedRef) { @@ -58,7 +61,15 @@ const initRefSwitcher = () => { initRefSwitcher(); if (viewBlobEl) { - const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset; + const { + blobPath, + projectPath, + targetBranch, + originalBranch, + resourceId, + userId, + explainCodeAvailable, + } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ @@ -69,6 +80,9 @@ if (viewBlobEl) { provide: { targetBranch, originalBranch, + resourceId, + userId, + explainCodeAvailable: parseBoolean(explainCodeAvailable), }, render(createElement) { return createElement(BlobContentViewer, { @@ -87,6 +101,47 @@ if (viewBlobEl) { initBlob(); } +const initForkInfo = () => { + const forkEl = document.getElementById('js-fork-info'); + if (!forkEl) { + return null; + } + const { + projectPath, + selectedBranch, + sourceName, + sourcePath, + sourceDefaultBranch, + canSyncBranch, + aheadComparePath, + behindComparePath, + canUserCreateMrInFork, + createMrPath, + } = forkEl.dataset; + return new Vue({ + el: forkEl, + apolloProvider, + render(h) { + return h(ForkInfo, { + props: { + canSyncBranch: parseBoolean(canSyncBranch), + projectPath, + selectedBranch, + sourceName, + sourcePath, + sourceDefaultBranch, + aheadComparePath, + behindComparePath, + canUserCreateMrInFork, + createMrPath, + }, + }); + }, + }); +}; + +initForkInfo(); + const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); const statusLink = document.querySelector('.commit-actions .ci-status-link'); if (statusLink) { diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index f871cd804e7..9a47a720709 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -18,11 +18,7 @@ import '~/sourcegraph/load'; import DiffStats from '~/diffs/components/diff_stats.vue'; import { initReportAbuse } from '~/projects/report_abuse'; -const hasPerfBar = document.querySelector('.with-performance-bar'); -const performanceHeight = hasPerfBar ? 35 : 0; -initDiffStatsDropdown( - (document.querySelector('.navbar-gitlab')?.offsetHeight ?? 0) + performanceHeight, -); +initDiffStatsDropdown(true); new ZenMode(); new ShortcutsNavigation(); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index 760bf3f7131..5bcdd34e258 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -7,8 +7,7 @@ import syntaxHighlight from '~/syntax_highlight'; initCompareSelector(); new Diff(); // eslint-disable-line no-new -const paddingTop = 16; -initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); +initDiffStatsDropdown(true); GpgBadges.fetch(); syntaxHighlight([document.querySelector('.files')]); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index 05a1bbc69ed..fe9f0c7e69f 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ -import initCycleAnalytics from '~/analytics/cycle_analytics'; +import cycleAnalyticsAppBundle from 'ee_else_ce/analytics/cycle_analytics/bundle'; -initCycleAnalytics(); +cycleAnalyticsAppBundle(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 06dcd2c2d94..c5b63b74c35 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,8 @@ import { initForm } from 'ee_else_ce/issues'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); + +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 06dcd2c2d94..c5b63b74c35 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,8 @@ import { initForm } from 'ee_else_ce/issues'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); + +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 2718765ee23..3d81e77f879 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -3,6 +3,8 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; import CompareApp from '~/merge_requests/components/compare_app.vue'; import { __ } from '~/locale'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { @@ -82,4 +84,6 @@ if (mrNewCompareNode) { action: mrNewSubmitNode.dataset.mrSubmitAction, }); initPipelines(); + // eslint-disable-next-line no-new + new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); } diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index f8cb8b30250..6127adc3584 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,10 +1,11 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; - +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import initCheckFormState from './check_form_state'; import initFormUpdate from './update_form'; @@ -72,3 +73,5 @@ initMergeRequest(); initFormUpdate(); initCheckFormState(); initTargetBranchSelector(); +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index d4734b8842d..599fd225de9 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -4,19 +4,13 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GLForm from '~/gl_form'; import LabelsSelect from '~/labels/labels_select'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; export default () => { new ShortcutsNavigation(); - new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); IssuableLabelSelector(); new LabelsSelect(); - new IssuableTemplateSelectors({ - warnTemplateOverride: true, - }); mountMilestoneDropdown('[name="merge_request[milestone_id]"]'); }; diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index a90cabb3c68..f50763151ef 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -9,6 +9,7 @@ const initShowExperiment = () => { } const props = { + experiment: JSON.parse(element.dataset.experiment), candidates: JSON.parse(element.dataset.candidates), metricNames: JSON.parse(element.dataset.metrics), paramNames: JSON.parse(element.dataset.params), diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index 414636f0a74..a669ea5baaf 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -24,7 +24,7 @@ const initRefSwitcher = () => { }, on: { input(selectedRef) { - visitUrl(joinPaths(networkRootPath, selectedRef)); + visitUrl(joinPaths(networkRootPath, encodeURIComponent(selectedRef))); }, }, }); 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 242c5a1a97b..eab4be4dcf1 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 @@ -176,7 +176,7 @@ export default { <gl-icon v-if="showDailyLimitMessage(option)" v-gl-tooltip.hover - name="question" + name="question-o" :title="scheduleDailyLimitMsg" /> </gl-form-radio> diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 5f15a11e708..e6ee6b702bb 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -3,28 +3,10 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import initClonePanel from '~/clone_panel'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { serializeForm } from '~/lib/utils/forms'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; - -const BRANCH_REF_TYPE = 'heads'; -const TAG_REF_TYPE = 'tags'; -const BRANCH_GROUP_NAME = __('Branches'); -const TAG_GROUP_NAME = __('Tags'); export default class Project { constructor() { initClonePanel(); - // Ref switcher - if (document.querySelector('.js-project-refs-dropdown')) { - Project.initRefSwitcher(); - $('.project-refs-select').on('change', function () { - return $(this).parents('form').trigger('submit'); - }); - } $('.js-hide-no-ssh-message').on('click', function (e) { setCookie('hide_no_ssh_message', 'false'); @@ -48,125 +30,4 @@ export default class Project { static changeProject(url) { return (window.location = url); } - - static initRefSwitcher() { - const refListItem = document.createElement('li'); - const refLink = document.createElement('a'); - - refLink.href = '#'; - - return $('.js-project-refs-dropdown').each(function () { - const $dropdown = $(this); - const selected = $dropdown.data('selected'); - const refType = $dropdown.data('refType'); - const fieldName = $dropdown.data('fieldName'); - const shouldVisit = Boolean($dropdown.data('visit')); - const $form = $dropdown.closest('form'); - const path = $form.find('#path').val(); - const action = $form.attr('action'); - const linkTarget = mergeUrlParams(serializeForm($form[0]), action); - - return initDeprecatedJQueryDropdown($dropdown, { - data(term, callback) { - axios - .get($dropdown.data('refsUrl'), { - params: { - ref: $dropdown.data('ref'), - search: term, - }, - }) - .then(({ data }) => callback(data)) - .catch(() => - createAlert({ - message: __('An error occurred while getting projects'), - }), - ); - }, - selectable: true, - filterable: true, - filterRemote: true, - filterByText: true, - inputFieldName: $dropdown.data('inputFieldName'), - fieldName, - renderRow(ref, _, params) { - const li = refListItem.cloneNode(false); - - const link = refLink.cloneNode(false); - - if (ref === selected) { - // Check group and current ref type to avoid adding a class when tags and branches share the same name - if ( - (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) || - (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) || - !refType - ) { - link.className = 'is-active'; - } - } - - link.textContent = ref; - link.dataset.ref = ref; - if (ref.length > 0 && shouldVisit) { - const urlParams = { [fieldName]: ref }; - if (params.group === BRANCH_GROUP_NAME) { - urlParams.ref_type = BRANCH_REF_TYPE; - } else { - urlParams.ref_type = TAG_REF_TYPE; - } - link.href = mergeUrlParams(urlParams, linkTarget); - } - - li.appendChild(link); - - return li; - }, - id(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel(obj, $el) { - return $el.text().trim(); - }, - clicked(options) { - const { e } = options; - - if (!shouldVisit) { - e.preventDefault(); - } - - // Some pages need to dynamically get the current path - // so they can opt-in to JS getting the path from the - // current URL by not setting a path in the dropdown form - if (shouldVisit && path === undefined) { - e.preventDefault(); - - const selectedUrl = new URL(e.target.href); - const loc = window.location.href; - - if (loc.includes('/-/')) { - const currentRef = $dropdown.data('ref'); - // The split and startWith is to ensure an exact word match - // and avoid partial match ie. currentRef is "dev" and loc is "development" - const splitPathAfterRefPortion = loc.split('/-/')[1].split(currentRef)[1]; - const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/'); - - if (doesPathContainRef) { - // We are ignoring the url containing the ref portion - // and plucking the thereafter portion to reconstructure the url that is correct - const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0]; - selectedUrl.searchParams.set('path', targetPath); - selectedUrl.hash = window.location.hash; - } - } - - // Open in new window if "meta" key is pressed - if (e.metaKey) { - window.open(selectedUrl.href, '_blank'); - } else { - window.location.href = selectedUrl.href; - } - } - }, - }); - }); - } } diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index d2263fa815d..087808c33da 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,3 +1,4 @@ +import 'bootstrap/js/dist/collapse'; import MirrorRepos from '~/mirrors/mirror_repos'; import mountBranchRules from '~/projects/settings/repository/branch_rules/mount_branch_rules'; import mountDefaultBranchSelector from '~/projects/settings/mount_default_branch_selector'; diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js index 885b8ca8e12..d907b8a470d 100644 --- a/app/assets/javascripts/pages/projects/usage_quotas/index.js +++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js @@ -1,9 +1,21 @@ import initProjectStorage from '~/usage_quotas/storage/init_project_storage'; import initSearchSettings from '~/search_settings'; +import { GlTabsBehavior, HISTORY_TYPE_HASH } from '~/tabs'; + +const initGlTabs = () => { + const tabsEl = document.getElementById('js-project-usage-quotas-tabs'); + if (!tabsEl) { + return; + } + + // eslint-disable-next-line no-new + new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH }); +}; const initVueApp = () => { initProjectStorage('js-project-storage-count-app'); }; +initGlTabs(); initVueApp(); initSearchSettings(); diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index b8de2757284..00f7c5d60d1 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -5,6 +5,7 @@ import LengthValidator from '~/validators/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; import EmailFormatValidator from '~/pages/sessions/new/email_format_validator'; import { initLanguageSwitcher } from '~/language_switcher'; +import { initTogglePasswordVisibility } from '~/authentication/password'; import Tracking from '~/tracking'; new UsernameValidator(); // eslint-disable-line no-new @@ -19,3 +20,4 @@ Tracking.enableFormTracking({ }); initLanguageSwitcher(); +initTogglePasswordVisibility(); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index ee48543f0d2..bad8a7cedc6 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -14,7 +14,7 @@ export default class OAuthRememberMe { } bindEvents() { - $('#remember_me', this.container).on('click', this.toggleRememberMe); + $('#remember_me_omniauth', this.container).on('click', this.toggleRememberMe); } toggleRememberMe(event) { 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 549c964cce4..3b38d715ea5 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -128,6 +128,9 @@ export default { }; }, computed: { + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, noContent() { return !this.content.trim(); }, @@ -351,6 +354,8 @@ export default { :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" :autofocus="pageInfo.persisted" + :enable-autocomplete="true" + :autocomplete-data-sources="autocompleteDataSources" :drawio-enabled="true" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" diff --git a/app/assets/javascripts/pages/time_tracking/timelogs/index.js b/app/assets/javascripts/pages/time_tracking/timelogs/index.js new file mode 100644 index 00000000000..41c78fbe3a6 --- /dev/null +++ b/app/assets/javascripts/pages/time_tracking/timelogs/index.js @@ -0,0 +1,3 @@ +import initTimelogsApp from '~/time_tracking'; + +initTimelogsApp(); 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 dbca8bc9be7..fac070d6e47 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,4 +1,5 @@ <script> +import { GlLink, GlPopover } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -10,8 +11,10 @@ import RequestSelector from './request_selector.vue'; export default { components: { + GlPopover, AddRequest, DetailedMetric, + GlLink, RequestSelector, }, directives: { @@ -30,6 +33,10 @@ export default { type: String, required: true, }, + requestMethod: { + type: String, + required: true, + }, peekUrl: { type: String, required: true, @@ -72,6 +79,11 @@ export default { keys: ['request', 'body'], }, { + metric: 'zkt', + header: s__('PerformanceBar|Zoekt calls'), + keys: ['request', 'body'], + }, + { metric: 'external-http', title: 'external', header: s__('PerformanceBar|External Http calls'), @@ -103,9 +115,6 @@ export default { this.currentRequestId = requestId; }, }, - initialRequest() { - return this.currentRequestId === this.requestId; - }, hasHost() { return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host; }, @@ -124,24 +133,47 @@ export default { const fileName = this.requests[0].displayName; return `${fileName}_perf_bar_${Date.now()}.json`; }, + showZoekt() { + return document.body.dataset.page === 'search:show'; + }, + showFlamegraphButtons() { + return this.isGetRequest(this.currentRequestId); + }, + showMemoryReportButton() { + return this.isGetRequest(this.currentRequestId) && this.env === 'development'; + }, memoryReportPath() { - return mergeUrlParams({ performance_bar: 'memory' }, window.location.href); + return mergeUrlParams( + { performance_bar: 'memory' }, + this.store.findRequest(this.currentRequestId).fullUrl, + ); }, }, + created() { + if (!this.showZoekt) { + this.$options.detailedMetrics = this.$options.detailedMetrics.filter( + (item) => item.metric !== 'zkt', + ); + } + }, mounted() { this.currentRequest = this.requestId; }, methods: { + glEmojiTag, changeCurrentRequest(newRequestId) { this.currentRequest = newRequestId; this.$emit('change-request', newRequestId); }, - flamegraphPath(mode) { + flamegraphPath(mode, requestId) { return mergeUrlParams( { performance_bar: 'flamegraph', stackprof_mode: mode }, - window.location.href, + this.store.findRequest(requestId).fullUrl, ); }, + isGetRequest(requestId) { + return this.store.findRequest(requestId)?.method?.toUpperCase() === 'GET'; + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; @@ -159,8 +191,17 @@ export default { class="current-host" :class="{ canary: currentRequest.details.host.canary }" > - <span v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span> - {{ currentRequest.details.host.hostname }} + <span id="canary-emoji" v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span> + <gl-popover placement="bottom" target="canary-emoji" content="Canary" /> + <span + id="host-emoji" + v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('computer')" + ></span> + <gl-popover + placement="bottom" + target="host-emoji" + :content="currentRequest.details.host.hostname" + /> </span> </div> <detailed-metric @@ -177,41 +218,45 @@ export default { id="peek-view-trace" class="view" > - <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{ + <gl-link class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|Trace') - }}</a> + }}</gl-link> </div> <div v-if="currentRequest.details" id="peek-download" class="view"> - <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{ - s__('PerformanceBar|Download') - }}</a> + <gl-link + class="gl-text-blue-200" + is-unsafe-link + :download="downloadName" + :href="downloadPath" + >{{ s__('PerformanceBar|Download') }}</gl-link + > </div> - <div - v-if="currentRequest.details && env === 'development'" - id="peek-memory-report" - class="view" - > - <a class="gl-text-blue-200" :href="memoryReportPath">{{ + <div v-if="showMemoryReportButton" id="peek-memory-report" class="view"> + <gl-link class="gl-text-blue-200" :href="memoryReportPath">{{ s__('PerformanceBar|Memory report') - }}</a> + }}</gl-link> </div> - <div v-if="currentRequest.details" id="peek-flamegraph" class="view"> - <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span> - <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{ + <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view"> + <span id="flamegraph-emoji" class="gl-text-white-200"> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('fire')"></span> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('bar_chart')"></span> + </span> + <gl-popover placement="bottom" target="flamegraph-emoji" content="Flamegraph" /> + <gl-link class="gl-text-blue-200" :href="flamegraphPath('wall', currentRequestId)">{{ s__('PerformanceBar|wall') - }}</a> + }}</gl-link> / - <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{ + <gl-link class="gl-text-blue-200" :href="flamegraphPath('cpu', currentRequestId)">{{ s__('PerformanceBar|cpu') - }}</a> + }}</gl-link> / - <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{ + <gl-link class="gl-text-blue-200" :href="flamegraphPath('object', currentRequestId)">{{ s__('PerformanceBar|object') - }}</a> + }}</gl-link> </div> - <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ + <gl-link v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ s__('PerformanceBar|Stats') - }}</a> + }}</gl-link> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 84fe14fe056..e3e48e61393 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -32,15 +32,21 @@ const initPerformanceBar = (el) => { store, env: performanceBarData.env, requestId: performanceBarData.requestId, + requestMethod: performanceBarData.requestMethod, peekUrl: performanceBarData.peekUrl, - profileUrl: performanceBarData.profileUrl, statsUrl: performanceBarData.statsUrl, }; }, mounted() { PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest); - this.addRequest(this.requestId, window.location.href); + this.addRequest( + this.requestId, + window.location.href, + undefined, + undefined, + this.requestMethod, + ); this.loadRequestDetails(this.requestId); }, beforeDestroy() { @@ -56,12 +62,12 @@ const initPerformanceBar = (el) => { this.addRequest(urlOrRequestId, urlOrRequestId); } }, - addRequest(requestId, requestUrl, operationName) { + addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) { if (!this.store.canTrackRequest(requestUrl)) { return; } - this.store.addRequest(requestId, requestUrl, operationName); + this.store.addRequest(requestId, requestUrl, operationName, requestParams, methodVerb); }, loadRequestDetails(requestId) { const request = this.store.findRequest(requestId); @@ -145,8 +151,8 @@ const initPerformanceBar = (el) => { store: this.store, env: this.env, requestId: this.requestId, + requestMethod: this.requestMethod, peekUrl: this.peekUrl, - profileUrl: this.profileUrl, statsUrl: this.statsUrl, }, on: { diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js index e67143f3ede..3a9788d8ab6 100644 --- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -14,11 +14,13 @@ export default class PerformanceBarService { fireCallback, requestId, requestUrl, + requestParams, operationName, + methodVerb, ] = PerformanceBarService.callbackParams(response, peekUrl); if (fireCallback) { - callback(requestId, requestUrl, operationName); + callback(requestId, requestUrl, operationName, requestParams, methodVerb); } return response; @@ -35,11 +37,14 @@ export default class PerformanceBarService { static callbackParams(response, peekUrl) { const requestId = response.headers && response.headers['x-request-id']; const requestUrl = response.config?.url; + const requestParams = response.config?.params; + const methodVerb = response.config?.method; + const cachedResponse = response.headers && parseBoolean(response.headers['x-gitlab-from-cache']); const fireCallback = requestUrl !== peekUrl && Boolean(requestId) && !cachedResponse; const operationName = response.config?.operationName; - return [fireCallback, requestId, requestUrl, operationName]; + return [fireCallback, requestId, requestUrl, requestParams, operationName, methodVerb]; } } diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js index 2011604534c..34e2763a478 100644 --- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -1,11 +1,22 @@ +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + export default class PerformanceBarStore { constructor() { this.requests = []; } - addRequest(requestId, requestUrl, operationName) { - if (!this.findRequest(requestId)) { - let displayName = PerformanceBarStore.truncateUrl(requestUrl); + addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) { + if (this.findRequest(requestId)) { + this.updateRequestBatchedQueriesCount(requestId); + } else { + let displayName = ''; + + if (methodVerb) { + displayName += `${methodVerb.toUpperCase()} `; + } + + displayName += PerformanceBarStore.truncateUrl(requestUrl); if (operationName) { displayName += ` (${operationName})`; @@ -14,13 +25,31 @@ export default class PerformanceBarStore { this.requests.push({ id: requestId, url: requestUrl, + fullUrl: mergeUrlParams(requestParams, requestUrl), + method: methodVerb, details: {}, + queriesInBatch: 1, // only for GraphQL displayName, }); } return this.requests; } + updateRequestBatchedQueriesCount(requestId) { + const existingRequest = this.findRequest(requestId); + existingRequest.queriesInBatch += 1; + + const oldDisplayName = existingRequest.displayName; + const regex = /\d+ queries batched/; + if (regex.test(oldDisplayName)) { + existingRequest.displayName = oldDisplayName.replace( + regex, + `${existingRequest.queriesInBatch} queries batched`, + ); + } else { + existingRequest.displayName += __(` [${existingRequest.queriesInBatch} queries batched]`); + } + } findRequest(requestId) { return this.requests.find((request) => request.id === requestId); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 3130fe42c3c..c9f43e43b2d 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -24,6 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-geo-migrate-hashed-storage-callout', '.js-unlimited-members-during-trial-alert', '.js-branch-rules-info-callout', + '.js-license-check-deprecation-alert', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 8f76d7535f1..83cd64c17ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -255,7 +255,7 @@ export default { this.canRefetchHeaderPipeline = true; this.$apollo.queries.headerPipeline.refetch(); }, - /* eslint-disable @gitlab/require-i18n-strings */ + // eslint-disable-next-line @gitlab/require-i18n-strings reportFailure({ type, err = 'No error string passed.', skipSentry = false }) { this.showAlert = true; this.alertType = type; @@ -263,7 +263,6 @@ export default { reportToSentry(this.$options.name, `type: ${type}, info: ${err}`); } }, - /* eslint-enable @gitlab/require-i18n-strings */ updateShowLinksState(val) { this.showLinks = val; }, diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index 6d8c35f4482..73143c981ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -62,6 +62,7 @@ export default { }, showTip() { return ( + this.showLinksToggle && this.showLinks && this.showLinksActive && !this.tipPreviouslyDismissed && diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 3da792cb9df..54985a24593 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -35,7 +35,6 @@ const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => { return layers; }; -/* eslint-disable @gitlab/require-i18n-strings */ const getQueryHeaders = (etagResource) => { return { fetchOptions: { @@ -52,6 +51,7 @@ const getQueryHeaders = (etagResource) => { const serializeGqlErr = (gqlError) => { const { locations = [], message = '', path = [] } = gqlError; + // eslint-disable-next-line @gitlab/require-i18n-strings return ` ${message}. Locations: ${locations @@ -74,14 +74,12 @@ const serializeLoadErrors = (errors) => { } if (!isEmpty(networkError)) { - return `Network error: ${networkError.message}`; + return `Network error: ${networkError.message}`; // eslint-disable-line @gitlab/require-i18n-strings } return message; }; -/* eslint-enable @gitlab/require-i18n-strings */ - const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { const stopStartQuery = (query) => { if (!Visibility.hidden()) { diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue index 16f6aa5aaa4..21b585933b8 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue @@ -13,7 +13,7 @@ export default { FailedJobsTable, }, inject: { - fullPath: { + projectPath: { default: '', }, pipelineIid: { @@ -31,7 +31,7 @@ export default { query: GetFailedJobsQuery, variables() { return { - fullPath: this.fullPath, + fullPath: this.projectPath, pipelineIid: this.pipelineIid, }; }, diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue index 661de43fe3c..61748860983 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -17,7 +17,7 @@ export default { JobsTable, }, inject: { - fullPath: { + projectPath: { default: '', }, pipelineIid: { @@ -56,7 +56,7 @@ export default { computed: { queryVariables() { return { - fullPath: this.fullPath, + fullPath: this.projectPath, iid: this.pipelineIid, }; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index 03a2eac89e4..a6297213402 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -1,19 +1,8 @@ <script> -import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { - STARTER_TEMPLATE_NAME, - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, - I18N, -} from '~/ci/pipeline_editor/constants'; +import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants'; import Tracking from '~/tracking'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { isExperimentVariant } from '~/experimentation/utils'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import CiTemplates from './ci_templates.vue'; export default { @@ -21,19 +10,12 @@ export default { GlButton, GlCard, GlSprintf, - GlIcon, - GlLink, - GitlabExperiment, CiTemplates, }, mixins: [Tracking.mixin()], STARTER_TEMPLATE_NAME, - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, - inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'], + inject: ['pipelineEditorPath'], data() { return { gettingStartedTemplateUrl: mergeUrlParams( @@ -43,26 +25,12 @@ export default { tracker: null, }; }, - computed: { - sharedRunnersHelpPagePath() { - return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' }); - }, - runnersAvailabilitySectionExperimentEnabled() { - return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME); - }, - }, - created() { - this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME); - }, methods: { trackEvent(template) { this.track('template_clicked', { label: template, }); }, - trackExperimentEvent(action) { - this.tracker.event(action); - }, }, }; </script> @@ -70,92 +38,42 @@ export default { <div> <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2> - <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME"> - <template #candidate> - <div v-if="anyRunnersAvailable"> - <h2 class="gl-font-base gl-text-gray-900"> - <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" /> - {{ $options.I18N.runners.title }} - </h2> - <p class="gl-text-gray-800 gl-mb-6"> - <gl-sprintf :message="$options.I18N.runners.subtitle"> - <template #settingsLink="{ content }"> - <gl-link - data-testid="settings-link" - :href="ciRunnerSettingsPath" - @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)" - >{{ content }}</gl-link - > - </template> - <template #docsLink="{ content }"> - <gl-link - data-testid="documentation-link" - :href="sharedRunnersHelpPagePath" - @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </p> - </div> - - <div v-else> - <h2 class="gl-font-base gl-text-gray-900"> - <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" /> - {{ $options.I18N.noRunners.title }} - </h2> - <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p> - <gl-button - data-testid="settings-button" - category="primary" - variant="confirm" - :href="ciRunnerSettingsPath" - @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)" - > - {{ $options.I18N.noRunners.cta }} - </gl-button> - </div> - </template> - </gitlab-experiment> - - <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable"> - <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2> - <p class="gl-text-gray-800 gl-mb-6"> - <gl-sprintf :message="$options.I18N.learnBasics.subtitle"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.I18N.learnBasics.subtitle"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> - <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> - <gl-card> - <div class="gl-flex-direction-row"> - <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> - <div class="gl-mb-3"> - <strong class="gl-text-gray-800 gl-mb-2"> - {{ $options.I18N.learnBasics.gettingStarted.title }} - </strong> - </div> - <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> + <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> + <gl-card> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2"> + {{ $options.I18N.learnBasics.gettingStarted.title }} + </strong> </div> + <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> + </div> - <gl-button - category="primary" - variant="confirm" - :href="gettingStartedTemplateUrl" - data-testid="test-template-link" - @click="trackEvent($options.STARTER_TEMPLATE_NAME)" - > - {{ $options.I18N.learnBasics.gettingStarted.cta }} - </gl-button> - </gl-card> - </div> + <gl-button + category="primary" + variant="confirm" + :href="gettingStartedTemplateUrl" + data-testid="test-template-link" + @click="trackEvent($options.STARTER_TEMPLATE_NAME)" + > + {{ $options.I18N.learnBasics.gettingStarted.cta }} + </gl-button> + </gl-card> + </div> - <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> - <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> - <ci-templates /> - </template> + <ci-templates /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index dd62ffb27f7..caeee7edefe 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -36,12 +36,10 @@ export default { }; }, computed: { - actions() { - if (!this.pipeline || !this.pipeline.details) { - return []; - } - const { details } = this.pipeline; - return [...(details.manual_actions || []), ...(details.scheduled_actions || [])]; + hasActions() { + return ( + this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions + ); }, isCancelling() { return this.cancelingPipeline === this.pipeline.id; @@ -75,7 +73,7 @@ export default { <template> <div class="gl-text-right"> <div class="btn-group"> - <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" /> + <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" /> <gl-button v-if="pipeline.flags.retryable" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index fe2ef2c2d71..7ad12d397e5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -144,14 +144,16 @@ export default { <tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate"> <gl-link :href="commitUrl" - class="commit-row-message gl-text-gray-900" + class="commit-row-message gl-font-weight-bold gl-text-gray-900" data-testid="commit-title" @click="trackClick('click_commit_title')" >{{ commitTitle }}</gl-link > </tooltip-on-truncate> </span> - <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span> + <span v-else class="gl-text-gray-500">{{ + __("Can't find HEAD commit for this branch") + }}</span> </div> <div class="gl-mb-2"> <gl-link diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 640129b9c4c..c5537b7ad54 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -346,7 +346,7 @@ export default { </div> <div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex"> - <div class="row-content-block gl-display-flex gl-flex-grow-1"> + <div class="row-content-block gl-display-flex gl-flex-grow-1 gl-border-b-0"> <pipelines-filtered-search class="gl-display-flex gl-flex-grow-1 gl-mr-4" :project-id="projectId" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue index 50d34070e61..262e82677a7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -8,8 +8,10 @@ import Tracking from '~/tracking'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; +import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql'; export default { + name: 'PipelinesManualActions', directives: { GlTooltip: GlTooltipDirective, }, @@ -18,22 +20,52 @@ export default { GlDropdown, GlDropdownItem, GlIcon, + GlLoadingIcon, }, mixins: [Tracking.mixin()], + inject: ['fullPath', 'manualActionsLimit'], props: { - actions: { - type: Array, + iid: { + type: Number, required: true, }, }, + apollo: { + actions: { + query: getPipelineActionsQuery, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + limit: this.manualActionsLimit, + }; + }, + skip() { + return !this.hasDropdownBeenShown; + }, + update({ project }) { + return project?.pipeline?.jobs?.nodes || []; + }, + }, + }, data() { return { isLoading: false, + actions: [], + hasDropdownBeenShown: false, }; }, + computed: { + isActionsLoading() { + return this.$apollo.queries.actions.loading; + }, + isDropdownLimitReached() { + return this.actions.length === this.manualActionsLimit; + }, + }, methods: { async onClickAction(action) { - if (action.scheduled_at) { + if (action.scheduledAt) { const confirmationMessage = sprintf( s__( 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', @@ -54,12 +86,12 @@ export default { * Ideally, the component would not make an api call directly. * However, in order to use the eventhub and know when to * toggle back the `isLoading` property we'd need an ID - * to track the request with a wacther - since this component + * to track the request with a watcher - since this component * is rendered at least 20 times in the same page, moving the * api call directly here is the most performant solution */ axios - .post(`${action.path}.json`) + .post(`${action.playPath}.json`) .then(() => { this.isLoading = false; eventHub.$emit('updateTable'); @@ -69,12 +101,12 @@ export default { createAlert({ message: __('An error occurred while making the request.') }); }); }, - isActionDisabled(action) { - if (action.playable === undefined) { - return false; - } + fetchActions() { + this.hasDropdownBeenShown = true; + + this.$apollo.queries.actions.refetch(); - return !action.playable; + this.trackClick(); }, trackClick() { this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table }); @@ -91,21 +123,37 @@ export default { right lazy icon="play" - @shown="trackClick" + @shown="fetchActions" > + <gl-dropdown-item v-if="isActionsLoading"> + <div class="gl-display-flex"> + <gl-loading-icon class="mr-2" /> + <span>{{ __('Loading...') }}</span> + </div> + </gl-dropdown-item> + <gl-dropdown-item v-for="action in actions" - :key="action.path" - :disabled="isActionDisabled(action)" + v-else + :key="action.id" + :disabled="!action.canPlayJob" @click="onClickAction(action)" > <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> {{ action.name }} - <span v-if="action.scheduled_at"> + <span v-if="action.scheduledAt"> <gl-icon name="clock" /> - <gl-countdown :end-date-string="action.scheduled_at" /> + <gl-countdown :end-date-string="action.scheduledAt" /> </span> </div> </gl-dropdown-item> + + <template #footer> + <gl-dropdown-item v-if="isDropdownLimitReached"> + <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg"> + {{ __('Showing first 50 actions.') }} + </span> + </gl-dropdown-item> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 365572f194b..b2da0df17c0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -15,7 +15,7 @@ import PipelinesStatusBadge from './pipelines_status_badge.vue'; const DEFAULT_TD_CLASS = 'gl-p-5!'; const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!'; + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 960af030421..e15676849da 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -41,7 +41,7 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-direction-column time-ago"> + <div class="gl-display-flex gl-flex-direction-column gl-font-sm time-ago"> <span v-if="showInProgress" class="gl-display-inline-flex gl-align-items-center" diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql new file mode 100644 index 00000000000..d1878c01e91 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql @@ -0,0 +1,24 @@ +query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs( + first: $limit + whenExecuted: ["manual", "delayed"] + retried: false + statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED] + ) { + nodes { + id + name + canPlayJob + manualJob + scheduledAt + scheduled + playPath + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index d94602c23b4..27debec7bb3 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -29,7 +29,7 @@ export const createAppOptions = (selector, apolloProvider, router) => { exposeLicenseScanningData, failedJobsCount, failedJobsSummary, - fullPath, + projectPath, graphqlResourceEtag, pipelineIid, pipelineProjectPath, @@ -50,8 +50,6 @@ export const createAppOptions = (selector, apolloProvider, router) => { testsCount, } = dataset; - // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved - const projectPath = fullPath; const defaultTabValue = getPipelineDefaultTab(window.location.href); return { @@ -83,7 +81,6 @@ export const createAppOptions = (selector, apolloProvider, router) => { exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData), failedJobsCount, failedJobsSummary: JSON.parse(failedJobsSummary), - fullPath, graphqlResourceEtag, pipelineIid, pipelineProjectPath, diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 6dccdb1a3e6..49e2e1644e2 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -1,5 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean, historyReplaceState, @@ -13,6 +15,11 @@ import PipelinesStore from './stores/pipelines_store'; Vue.use(Translate); Vue.use(GlToast); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const el = document.querySelector(selector); @@ -38,22 +45,22 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { projectId, defaultBranchName, params, - ciRunnerSettingsPath, - anyRunnersAvailable, iosRunnersAvailable, registrationToken, + fullPath, } = el.dataset; return new Vue({ el, + apolloProvider, provide: { pipelineEditorPath, artifactsEndpoint, artifactsEndpointPlaceholder, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), - ciRunnerSettingsPath, - anyRunnersAvailable: parseBoolean(anyRunnersAvailable), iosRunnersAvailable: parseBoolean(iosRunnersAvailable), + fullPath, + manualActionsLimit: 50, }, data() { return { diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue index 76fb13919df..21f8a2d3500 100644 --- a/app/assets/javascripts/profile/components/overview_tab.vue +++ b/app/assets/javascripts/profile/components/overview_tab.vue @@ -1,18 +1,44 @@ <script> -import { GlTab } from '@gitlab/ui'; +import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; import ActivityCalendar from './activity_calendar.vue'; export default { i18n: { title: s__('UserProfile|Overview'), + personalProjects: s__('UserProfile|Personal projects'), + viewAll: s__('UserProfile|View all'), + }, + components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList }, + props: { + personalProjects: { + type: Array, + required: true, + }, + personalProjectsLoading: { + type: Boolean, + required: true, + }, }, - components: { GlTab, ActivityCalendar }, }; </script> <template> <gl-tab :title="$options.i18n.title"> <activity-calendar /> + <div class="gl-mx-n3 gl-display-flex gl-flex-wrap"> + <div class="gl-px-3 gl-w-full gl-lg-w-half"></div> + <div class="gl-px-3 gl-w-full gl-lg-w-half" data-testid="personal-projects-section"> + <div + class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" + > + <h4 class="gl-flex-grow-1">{{ $options.i18n.personalProjects }}</h4> + <gl-link href="">{{ $options.i18n.viewAll }}</gl-link> + </div> + <gl-loading-icon v-if="personalProjectsLoading" class="gl-mt-5" size="md" /> + <projects-list v-else :projects="personalProjects" /> + </div> + </div> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue index b39bfabb832..25b94d7dc7f 100644 --- a/app/assets/javascripts/profile/components/profile_tabs.vue +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -1,6 +1,10 @@ <script> import { GlTabs } from '@gitlab/ui'; +import { getUserProjects } from '~/rest_api'; +import { s__ } from '~/locale'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; import OverviewTab from './overview_tab.vue'; import ActivityTab from './activity_tab.vue'; import GroupsTab from './groups_tab.vue'; @@ -12,6 +16,11 @@ import FollowersTab from './followers_tab.vue'; import FollowingTab from './following_tab.vue'; export default { + i18n: { + personalProjectsErrorMessage: s__( + 'UserProfile|An error occurred loading the personal projects. Please refresh the page to try again.', + ), + }, components: { GlTabs, OverviewTab, @@ -62,6 +71,22 @@ export default { component: FollowingTab, }, ], + inject: ['userId'], + data() { + return { + personalProjectsLoading: true, + personalProjects: [], + }; + }, + async mounted() { + try { + const response = await getUserProjects(this.userId, { per_page: 10 }); + this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true }); + this.personalProjectsLoading = false; + } catch (error) { + createAlert({ message: this.$options.i18n.personalProjectsErrorMessage }); + } + }, }; </script> @@ -72,6 +97,8 @@ export default { v-for="{ key, component } in $options.tabs" :key="key" class="container-fluid container-limited" + :personal-projects="personalProjects" + :personal-projects-loading="personalProjectsLoading" /> </gl-tabs> </template> diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 050b004f657..107bfd159dd 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -3,6 +3,8 @@ import $ from 'jquery'; import 'cropper'; import { isString } from 'lodash'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; import { loadCSSFile } from '../lib/utils/css_utils'; (() => { @@ -139,11 +141,20 @@ import { loadCSSFile } from '../lib/utils/css_utils'; } readFile(input) { - const _this = this; const reader = new FileReader(); reader.onload = () => { - _this.modalCropImg.attr('src', reader.result); - return _this.modalCrop.modal('show'); + this.modalCropImg.attr('src', reader.result); + import(/* webpackChunkName: 'bootstrapModal' */ 'bootstrap/js/dist/modal') + .then(() => { + this.modalCrop.modal('show'); + }) + .catch(() => { + createAlert({ + message: s__( + 'UserProfile|Failed to set avatar. Please reload the page to try again.', + ), + }); + }); }; return reader.readAsDataURL(input.files[0]); } diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js index fbe0e3534d8..101e52c873e 100644 --- a/app/assets/javascripts/profile/index.js +++ b/app/assets/javascripts/profile/index.js @@ -13,15 +13,17 @@ export const initProfileTabs = () => { if (!el) return false; - const { followees, followers, userCalendarPath, utcOffset } = el.dataset; + const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset; return new Vue({ el, + name: 'ProfileRoot', provide: { followees: parseInt(followers, 10), followers: parseInt(followees, 10), userCalendarPath, utcOffset, + userId, }, render(createElement) { return createElement(ProfileTabs); diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue index 0fd31381ba6..a4edc988d67 100644 --- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue @@ -93,7 +93,7 @@ export default { data-testid="email-patches-link" data-qa-selector="email_patches" > - {{ s__('DownloadCommit|Email Patches') }} + {{ __('Patches') }} </gl-dropdown-item> <gl-dropdown-item :href="plainDiffPath" diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js index f56884f605f..3179fcb14fd 100644 --- a/app/assets/javascripts/projects/commits/index.js +++ b/app/assets/javascripts/projects/commits/index.js @@ -35,6 +35,10 @@ export const initCommitsRefSwitcher = () => { const { projectId, ref, commitsPath, refType } = el.dataset; const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0]; + const generateRefDestinationUrl = (selectedRef, selectedRefType) => { + const commitsPathSuffix = selectedRefType ? `?ref_type=${selectedRefType}` : ''; + return `${commitsPathPrefix}/${encodeURIComponent(selectedRef)}${commitsPathSuffix}`; + }; const useSymbolicRefNames = Boolean(refType); return new Vue({ el, @@ -48,15 +52,11 @@ export const initCommitsRefSwitcher = () => { }, on: { input(selected) { - if (useSymbolicRefNames) { - const matches = selected.match(/refs\/(heads|tags)\/(.+)/); - if (matches) { - visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`); - } else { - visitUrl(`${commitsPathPrefix}/${selected}`); - } + const matches = selected.match(/refs\/(heads|tags)\/(.+)/); + if (useSymbolicRefNames && matches) { + visitUrl(generateRefDestinationUrl(matches[2], matches[1])); } else { - visitUrl(`${commitsPathPrefix}/${selected}`); + visitUrl(generateRefDestinationUrl(selected)); } }, }, diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index a44855c14d5..fb201576e85 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -121,4 +121,8 @@ export default { text: s__('ProjectTemplates|TYPO3 Distribution'), icon: '.template-option .icon-typo3', }, + laravel: { + text: s__('ProjectTemplates|Laravel Framework'), + icon: '.template-option .icon-laravel', + }, }; diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 1599661505f..0a160a357e5 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -59,6 +59,10 @@ export default { SafeHtml, }, props: { + rootPath: { + type: String, + required: true, + }, projectsUrl: { type: String, required: true, @@ -92,12 +96,14 @@ export default { computed: { initialBreadcrumbs() { - return [ - this.parentGroupUrl - ? { text: this.parentGroupName, href: this.parentGroupUrl } - : { text: s__('ProjectsNew|Projects'), href: this.projectsUrl }, - { text: s__('ProjectsNew|New project'), href: '#' }, - ]; + const breadcrumbs = this.parentGroupUrl + ? [{ text: this.parentGroupName, href: this.parentGroupUrl }] + : [ + { text: s__('Navigation|Your work'), href: this.rootPath }, + { text: s__('ProjectsNew|Projects'), href: this.projectsUrl }, + ]; + breadcrumbs.push({ text: s__('ProjectsNew|New project'), href: '#' }); + return breadcrumbs; }, availablePanels() { return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL); diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index 7330874eefe..5ec50355a82 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -18,6 +18,7 @@ export function initNewProjectCreation() { parentGroupUrl, parentGroupName, projectsUrl, + rootPath, } = el.dataset; const props = { @@ -27,6 +28,7 @@ export function initNewProjectCreation() { parentGroupUrl, parentGroupName, projectsUrl, + rootPath, }; const provide = { diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index b0abe7ac463..dbcb77b67f3 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -187,6 +187,7 @@ export default { :roles="pushAccessLevels.roles" :users="pushAccessLevels.users" :groups="pushAccessLevels.groups" + data-qa-selector="allowed_to_push_content" /> <!-- Allowed to merge --> @@ -197,6 +198,7 @@ export default { :roles="mergeAccessLevels.roles" :users="mergeAccessLevels.users" :groups="mergeAccessLevels.groups" + data-qa-selector="allowed_to_merge_content" /> <!-- Force push --> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue index 721248e53e3..3a5b3409596 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue @@ -101,7 +101,13 @@ export default { <div v-if="statusCheckUrl" class="gl-ml-7 gl-flex-grow-1">{{ statusCheckUrl }}</div> - <div v-for="(item, index) in accessLevels" :key="index" data-testid="access-level"> + <div + v-for="(item, index) in accessLevels" + :key="index" + data-testid="access-level" + data-qa-selector="access_level_content" + :data-qa-role="item.accessLevelDescription" + > <span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span> {{ item.accessLevelDescription }} </div> 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 7709419b6f8..dcf5155644d 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -69,9 +69,14 @@ export default { <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div> - <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{ - $options.i18n.addBranchRule - }}</gl-button> + <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-modal :ref="$options.modalId" 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 b565bda247d..a5ff478a826 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 @@ -153,7 +153,11 @@ export default { </script> <template> - <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"> + <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> @@ -169,7 +173,7 @@ export default { <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> </ul> </div> - <gl-button class="gl-align-self-start" :href="detailsPath"> + <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath"> {{ $options.i18n.detailsButtonLabel }}</gl-button > </div> 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 b79b3fa4573..79ece99e6ec 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 @@ -8,7 +8,7 @@ import ServiceDeskSetting from './service_desk_setting.vue'; export default { customEmailHelpPath: helpPagePath('/user/project/service_desk.html', { - anchor: 'using-a-custom-email-address', + anchor: 'use-a-custom-email-address', }), components: { GlAlert, diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 85550e262e6..8af2e787740 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -102,12 +102,12 @@ export default { }, emailSuffixHelpUrl() { return helpPagePath('user/project/service_desk.html', { - anchor: 'configuring-a-custom-email-address-suffix', + anchor: 'configure-a-custom-email-address-suffix', }); }, customEmailAddressHelpUrl() { return helpPagePath('user/project/service_desk.html', { - anchor: 'using-a-custom-email-address', + anchor: 'use-a-custom-email-address', }); }, }, diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js index b5d00cb7e82..5342874250c 100644 --- a/app/assets/javascripts/protected_branches/constants.js +++ b/app/assets/javascripts/protected_branches/constants.js @@ -9,3 +9,9 @@ export const LEVEL_TYPES = { GROUP: 'group', DEPLOY_KEY: 'deploy_key', }; + +export const BRANCH_RULES_ANCHOR = '#branch-rules'; + +export const IS_PROTECTED_BRANCH_CREATED = 'is_protected_branch_created'; + +export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings'; diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index cd37c0de6a5..cdbe39fd5e0 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,17 +1,24 @@ import $ from 'jquery'; import CreateItemDropdown from '~/create_item_dropdown'; -import { createAlert } from '~/alert'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import AccessDropdown from '~/projects/settings/access_dropdown'; import { initToggle } from '~/toggles'; -import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; +import { expandSection } from '~/settings_panels'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { + BRANCH_RULES_ANCHOR, + PROTECTED_BRANCHES_ANCHOR, + IS_PROTECTED_BRANCH_CREATED, + ACCESS_LEVELS, + LEVEL_TYPES, +} from './constants'; export default class ProtectedBranchCreate { constructor(options) { this.hasLicense = options.hasLicense; - this.$form = $('.js-new-protected-branch'); this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); this.currentProjectUserDefaults = {}; @@ -22,7 +29,7 @@ export default class ProtectedBranchCreate { if (this.hasLicense) { this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle')); } - + this.showSuccessAlertIfNeeded(); this.bindEvents(); } @@ -81,6 +88,49 @@ export default class ProtectedBranchCreate { callback(gon.open_branches); } + // eslint-disable-next-line class-methods-use-this + expandAndScroll(anchor) { + expandSection(anchor); + scrollToElement(anchor); + } + + hasProtectedBranchSuccessAlert() { + return ( + window.gon?.features?.branchRules && + this.isLocalStorageAvailable && + localStorage.getItem(IS_PROTECTED_BRANCH_CREATED) + ); + } + + createSuccessAlert() { + this.alert = createAlert({ + variant: VARIANT_SUCCESS, + containerSelector: '.js-alert-protected-branch-created-container', + title: s__('ProtectedBranch|View protected branches as branch rules'), + message: s__('ProtectedBranch|Manage branch related settings in one area with branch rules.'), + primaryButton: { + text: s__('ProtectedBranch|View branch rule'), + clickHandler: () => { + this.expandAndScroll(BRANCH_RULES_ANCHOR); + }, + }, + secondaryButton: { + text: __('Dismiss'), + clickHandler: () => this.alert.dismiss(), + }, + }); + } + + showSuccessAlertIfNeeded() { + if (!this.hasProtectedBranchSuccessAlert()) { + return; + } + this.expandAndScroll(PROTECTED_BRANCHES_ANCHOR); + + this.createSuccessAlert(); + localStorage.removeItem(IS_PROTECTED_BRANCH_CREATED); + } + getFormData() { const formData = { authenticity_token: this.$form.find('input[name="authenticity_token"]').val(), @@ -127,6 +177,9 @@ export default class ProtectedBranchCreate { axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData()) .then(() => { + if (this.isLocalStorageAvailable) { + localStorage.setItem(IS_PROTECTED_BRANCH_CREATED, 'true'); + } window.location.reload(); }) .catch(() => diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 9826124912b..9a84726d42f 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -42,6 +42,11 @@ export default { required: false, default: '', }, + queryParams: { + type: Object, + required: false, + default: () => {}, + }, refType: { type: String, required: false, @@ -93,6 +98,7 @@ export default { matches: (state) => state.matches, lastQuery: (state) => state.query, selectedRef: (state) => state.selectedRef, + params: (state) => state.params, }), ...mapGetters(['isLoading', 'isQueryPossiblyASha']), i18n() { @@ -186,6 +192,7 @@ export default { this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); + this.setParams(this.queryParams); this.$watch( 'enabledRefTypes', @@ -206,6 +213,7 @@ export default { ...mapActions([ 'setEnabledRefTypes', 'setUseSymbolicRefNames', + 'setParams', 'setProjectId', 'setSelectedRef', ]), diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js index a6019f21e73..3d6b46abf52 100644 --- a/app/assets/javascripts/ref/stores/actions.js +++ b/app/assets/javascripts/ref/stores/actions.js @@ -5,6 +5,8 @@ import * as types from './mutation_types'; export const setEnabledRefTypes = ({ commit }, refTypes) => commit(types.SET_ENABLED_REF_TYPES, refTypes); +export const setParams = ({ commit }, params) => commit(types.SET_PARAMS, params); + export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) => commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames); @@ -29,7 +31,7 @@ export const search = ({ state, dispatch, commit }, query) => { export const searchBranches = ({ commit, state }) => { commit(types.REQUEST_START); - Api.branches(state.projectId, state.query) + Api.branches(state.projectId, state.query, state.params) .then((response) => { commit(types.RECEIVE_BRANCHES_SUCCESS, response); }) diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js index 2bebffc19ab..fb2196fa1d0 100644 --- a/app/assets/javascripts/ref/stores/index.js +++ b/app/assets/javascripts/ref/stores/index.js @@ -14,3 +14,11 @@ export default () => mutations, state: createState(), }); + +export const createRefModule = () => ({ + namespaced: true, + actions, + getters, + mutations, + state: createState(), +}); diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js index 4c602908cae..6178106fe00 100644 --- a/app/assets/javascripts/ref/stores/mutation_types.js +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -1,5 +1,6 @@ export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES'; export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES'; +export const SET_PARAMS = 'SET_PARAMS'; export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_SELECTED_REF = 'SET_SELECTED_REF'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js index 9846ac0adb7..43c4318ad6c 100644 --- a/app/assets/javascripts/ref/stores/mutations.js +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -10,6 +10,9 @@ export default { [types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) { state.useSymbolicRefNames = useSymbolicRefNames; }, + [types.SET_PARAMS](state, params) { + state.params = params; + }, [types.SET_PROJECT_ID](state, projectId) { state.projectId = projectId; }, diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js index 3affa8f8d03..1619b43c02e 100644 --- a/app/assets/javascripts/ref/stores/state.js +++ b/app/assets/javascripts/ref/stores/state.js @@ -15,5 +15,6 @@ export default () => ({ commits: createRefTypeState(), }, selectedRef: null, + params: null, requestCount: 0, }); diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index 043d925198c..24b350c7f18 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -194,9 +194,11 @@ export default { 'gl-border-b-1': isOpen, 'gl-border-b-0': !isOpen, }" - class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100" + class="gl-display-flex gl-justify-content-space-between gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100" > - <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1"> + <h3 + class="card-title h5 gl-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24" + > <gl-link id="user-content-related-issues" class="anchor position-absolute gl-text-decoration-none" diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue new file mode 100644 index 00000000000..44269bccec9 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_create.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { uniqueId } from 'lodash'; +import { __, s__ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlFormTextarea, + RefSelector, + }, + model: { + prop: 'value', + event: 'change', + }, + props: { + value: { type: String, required: true }, + }, + data() { + return { + nameId: uniqueId('tag-name-'), + refId: uniqueId('ref-'), + messageId: uniqueId('message-'), + }; + }, + computed: { + ...mapState('editNew', ['projectId', 'release', 'createFrom']), + }, + methods: { + ...mapActions('editNew', ['updateReleaseTagMessage', 'updateCreateFrom']), + }, + i18n: { + tagNameLabel: __('Tag name'), + refLabel: __('Create from'), + messageLabel: s__('CreateGitTag|Set tag message'), + messagePlaceholder: s__( + 'CreateGitTag|Add a message to the tag. Leaving this blank creates a lightweight tag.', + ), + create: __('Save'), + cancel: s__('Release|Select another tag'), + refSelector: { + noRefSelected: __('No source selected'), + searchPlaceholder: __('Search branches, tags, and commits'), + dropdownHeader: __('Select source'), + }, + }, +}; +</script> +<template> + <div class="gl-p-3" data-testid="create-from-field"> + <gl-form-group + class="gl-mb-3" + :label="$options.i18n.tagNameLabel" + :label-for="nameId" + label-sr-only + > + <gl-form-input :id="nameId" :value="value" autofocus @input="$emit('change', $event)" /> + </gl-form-group> + <gl-form-group class="gl-mb-3" :label="$options.i18n.refLabel" :label-for="refId" label-sr-only> + <ref-selector + :id="refId" + :project-id="projectId" + :value="createFrom" + :translations="$options.i18n.refSelector" + @input="updateCreateFrom" + /> + </gl-form-group> + <gl-form-group + class="gl-mb-3" + :label="$options.i18n.messageLabel" + :label-for="messageId" + label-sr-only + > + <gl-form-textarea + :id="messageId" + :placeholder="$options.i18n.messagePlaceholder" + :no-resize="false" + :value="release.tagMessage" + @input="updateReleaseTagMessage" + /> + </gl-form-group> + <gl-button class="gl-mr-3" variant="confirm" @click="$emit('create')"> + {{ $options.i18n.create }} + </gl-button> + <gl-button @click="$emit('cancel')">{{ $options.i18n.cancel }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 2ac61988393..ec058cc3603 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -1,225 +1,133 @@ <script> -import { - GlCollapse, - GlLink, - GlFormGroup, - GlFormTextarea, - GlDropdownItem, - GlSprintf, -} from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__ } from '~/locale'; -import RefSelector from '~/ref/components/ref_selector.vue'; -import { REF_TYPE_TAGS } from '~/ref/constants'; -import FormFieldContainer from './form_field_container.vue'; + +import TagSearch from './tag_search.vue'; +import TagCreate from './tag_create.vue'; export default { - name: 'TagFieldNew', components: { - GlCollapse, + GlDropdown, GlFormGroup, - GlFormTextarea, - GlLink, - RefSelector, - FormFieldContainer, - GlDropdownItem, - GlSprintf, + GlPopover, + TagSearch, + TagCreate, }, data() { - return { - // Keeps track of whether or not the user has interacted with - // the input field. This is used to avoid showing validation - // errors immediately when the page loads. - isInputDirty: false, - }; + return { id: 'release-tag-name', newTagName: '', show: false, isInputDirty: false }; }, computed: { - ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']), - ...mapGetters('editNew', ['validationErrors']), - tagName: { - get() { - return this.release.tagName; - }, - set(tagName) { - this.updateReleaseTagName(tagName); - - // This setter is used by the `v-model` on the `RefSelector`. - // When this is called, the selection originated from the - // dropdown list of existing tag names, so we know the tag - // already exists and don't need to show the "create from" input - this.updateShowCreateFrom(false); - }, - }, - tagMessage: { - get() { - return this.release.tagMessage; - }, - set(tagMessage) { - this.updateReleaseTagMessage(tagMessage); - }, - }, - createFromModel: { - get() { - return this.createFrom; - }, - set(createFrom) { - this.updateCreateFrom(createFrom); - }, + ...mapState('editNew', ['release', 'showCreateFrom']), + ...mapGetters('editNew', ['validationErrors', 'isSearching', 'isCreating']), + title() { + return this.isCreating ? this.$options.i18n.createTitle : this.$options.i18n.selectTitle; }, showTagNameValidationError() { return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid; }, - tagNameInputId() { - return uniqueId('tag-name-input-'); - }, - createFromSelectorId() { - return uniqueId('create-from-selector-'); - }, tagFeedback() { return this.validationErrors.tagNameValidation.validationErrors[0]; }, + buttonText() { + return this.release?.tagName || s__('Release|Search or create tag name'); + }, + buttonVariant() { + return this.showTagNameValidationError ? 'danger' : 'default'; + }, + createText() { + return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew; + }, }, methods: { ...mapActions('editNew', [ + 'setSearching', + 'setCreating', + 'setNewTag', + 'setExistingTag', 'updateReleaseTagName', - 'updateReleaseTagMessage', - 'updateCreateFrom', 'fetchTagNotes', - 'updateShowCreateFrom', ]), - markInputAsDirty() { - this.isInputDirty = true; + startCreate(query) { + this.newTagName = query; + this.setCreating(); }, - createTagClicked(newTagName) { - this.updateReleaseTagName(newTagName); + selected(tag) { + this.updateReleaseTagName(tag); - // This method is called when the user selects the "create tag" - // option, so the tag does not already exist. Because of this, - // we need to show the "create from" input. - this.updateShowCreateFrom(true); - }, - shouldShowCreateTagOption(isLoading, matches, query) { - // Show the "create tag" option if: - return ( - // we're not currently loading any results, and - !isLoading && - // the search query isn't just whitespace, and - query.trim() && - // the `matches` object is non-null, and - matches && - // the tag name doesn't already exist - !matches.tags.list.some( - (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(), - ) - ); + if (this.isSearching) { + this.fetchTagNotes(tag); + this.setExistingTag(); + this.newTagName = ''; + } else { + this.setNewTag(); + } + + this.hidePopover(); }, - }, - translations: { - tagName: { - noRefSelected: __('No tag selected'), - dropdownHeader: __('Tag name'), - searchPlaceholder: __('Search or create tag'), - label: __('Tag name'), - labelDescription: __('*Required'), + markInputAsDirty() { + this.isInputDirty = true; }, - createFrom: { - noRefSelected: __('No source selected'), - searchPlaceholder: __('Search branches, tags, and commits'), - dropdownHeader: __('Select source'), - label: __('Create from'), - description: __('Existing branch name, tag, or commit SHA'), + showPopover() { + this.show = true; }, - annotatedTag: { - label: s__('CreateGitTag|Set tag message'), - description: s__( - 'CreateGitTag|Add a message to the tag. Leaving this blank creates a %{linkStart}lightweight tag%{linkEnd}.', - ), + hidePopover() { + this.show = false; }, }, - tagMessageId: uniqueId('tag-message-'), - - tagNameEnabledRefTypes: [REF_TYPE_TAGS], - gitTagDocsLink: 'https://git-scm.com/book/en/v2/Git-Basics-Tagging/', + i18n: { + selectTitle: __('Tags'), + createTitle: s__('Release|Create tag'), + label: __('Tag name'), + required: __('(required)'), + create: __('Create'), + cancel: __('Cancel'), + }, }; </script> <template> - <div> + <div class="row"> <gl-form-group - data-testid="tag-name-field" + class="col-md-4 col-sm-10" + :label="$options.i18n.label" + :label-for="id" + :optional-text="$options.i18n.required" :state="!showTagNameValidationError" :invalid-feedback="tagFeedback" - :label="$options.translations.tagName.label" - :label-for="tagNameInputId" - :label-description="$options.translations.tagName.labelDescription" + optional + data-testid="tag-name-field" > - <form-field-container> - <ref-selector - :id="tagNameInputId" - v-model="tagName" - :project-id="projectId" - :translations="$options.translations.tagName" - :enabled-ref-types="$options.tagNameEnabledRefTypes" - :state="!showTagNameValidationError" - @input="fetchTagNotes" - @hide.once="markInputAsDirty" - > - <template #footer="{ isLoading, matches, query }"> - <gl-dropdown-item - v-if="shouldShowCreateTagOption(isLoading, matches, query)" - is-check-item - :is-checked="tagName === query" - @click="createTagClicked(query)" - > - <gl-sprintf :message="__('Create tag %{tagName}')"> - <template #tagName> - <b>{{ query }}</b> - </template> - </gl-sprintf> - </gl-dropdown-item> - </template> - </ref-selector> - </form-field-container> + <gl-dropdown + :id="id" + :variant="buttonVariant" + :text="buttonText" + :toggle-class="['gl-text-gray-900!']" + category="secondary" + class="gl-w-30" + @show.prevent="showPopover" + /> + <gl-popover + :show="show" + :target="id" + :title="title" + :css-classes="['gl-z-index-200', 'release-tag-selector']" + placement="bottom" + triggers="manual" + container="content-body" + show-close-button + @close-button-clicked="hidePopover" + @hide.once="markInputAsDirty" + > + <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200"> + <tag-create + v-if="isCreating" + v-model="newTagName" + @create="selected(newTagName)" + @cancel="setSearching" + /> + <tag-search v-else v-model="newTagName" @create="startCreate" @select="selected" /> + </div> + </gl-popover> </gl-form-group> - <gl-collapse :visible="showCreateFrom"> - <div class="gl-pl-6 gl-border-l-1 gl-border-l-solid gl-border-gray-300"> - <gl-form-group - v-if="showCreateFrom" - :label="$options.translations.createFrom.label" - :label-for="createFromSelectorId" - data-testid="create-from-field" - > - <form-field-container> - <ref-selector - :id="createFromSelectorId" - v-model="createFromModel" - :project-id="projectId" - :translations="$options.translations.createFrom" - /> - </form-field-container> - <template #description>{{ $options.translations.createFrom.description }}</template> - </gl-form-group> - <gl-form-group - v-if="showCreateFrom" - :label="$options.translations.annotatedTag.label" - :label-for="$options.tagMessageId" - data-testid="annotated-tag-message-field" - > - <gl-form-textarea :id="$options.tagMessageId" v-model="tagMessage" /> - <template #description> - <gl-sprintf :message="$options.translations.annotatedTag.description"> - <template #link="{ content }"> - <gl-link - :href="$options.gitTagDocsLink" - rel="noopener noreferrer" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </template> - </gl-form-group> - </div> - </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue new file mode 100644 index 00000000000..33b44c90e1f --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_search.vue @@ -0,0 +1,121 @@ +<script> +import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { debounce } from 'lodash'; +import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants'; +import { __, s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlDropdownItem, + GlSearchBoxByType, + GlSprintf, + }, + model: { + prop: 'query', + event: 'change', + }, + props: { + query: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { tagName: '' }; + }, + computed: { + ...mapState('ref', ['matches']), + ...mapState('editNew', ['projectId', 'release']), + tags() { + return this.matches?.tags?.list || []; + }, + createText() { + return this.query ? this.$options.i18n.createTag : this.$options.i18n.typeNew; + }, + selectedNotShown() { + return this.release.tagName && !this.tags.some((tag) => tag.name === this.release.tagName); + }, + }, + created() { + this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); + }, + mounted() { + this.setProjectId(this.projectId); + this.setEnabledRefTypes([REF_TYPE_TAGS]); + this.search(this.query); + }, + methods: { + ...mapActions('ref', ['setEnabledRefTypes', 'setProjectId', 'search']), + onSearchBoxInput(searchQuery = '') { + const query = searchQuery.trim(); + this.$emit('change', query); + this.debouncedSearch(query); + }, + selected(tagName) { + return (this.release?.tagName ?? '') === tagName; + }, + }, + i18n: { + noResults: __('No results found'), + createTag: s__('Release|Create tag %{tag}'), + typeNew: s__('Release|Or type a new tag name'), + }, +}; +</script> +<template> + <div data-testid="tag-name-search"> + <gl-search-box-by-type + :value="query" + class="gl-border-b-solid gl-border-b-1 gl-border-gray-200" + borderless + autofocus + @input="onSearchBoxInput" + /> + <div class="gl-overflow-y-auto release-tag-list"> + <div v-if="tags.length || release.tagName"> + <gl-dropdown-item + v-if="selectedNotShown" + is-checked + is-check-item + class="gl-list-style-none" + > + {{ release.tagName }} + </gl-dropdown-item> + <gl-dropdown-item + v-for="tag in tags" + :key="tag.name" + :is-checked="selected(tag.name)" + is-check-item + class="gl-list-style-none" + @click="$emit('select', tag.name)" + > + {{ tag.name }} + </gl-dropdown-item> + </div> + <div + v-else + class="gl-my-5 gl-text-gray-500 gl-display-flex gl-font-base gl-justify-content-center" + > + {{ $options.i18n.noResults }} + </div> + </div> + <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200 gl-py-3"> + <gl-button + category="tertiary" + class="gl-justify-content-start! gl-rounded-0!" + block + :disabled="!query" + @click="$emit('create', query)" + > + <gl-sprintf :message="createText"> + <template #tag> + <span class="gl-font-weight-bold">{{ query }}</span> + </template> + </gl-sprintf> + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index 0a3f8b5e63b..efd82edcdf0 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { createRefModule } from '../ref/stores'; import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; import createEditNewModule from './stores/modules/edit_new'; @@ -12,6 +13,7 @@ export default () => { const store = createStore({ modules: { editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }), + ref: createRefModule(), }, }); diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index a7d8825ed33..f5191e000f7 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -274,3 +274,9 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => { }); }); }; + +export const setSearching = ({ commit }) => commit(types.SET_SEARCHING); +export const setCreating = ({ commit }) => commit(types.SET_CREATING); + +export const setExistingTag = ({ commit }) => commit(types.SET_EXISTING_TAG); +export const setNewTag = ({ commit }) => commit(types.SET_NEW_TAG); diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/constants.js b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js new file mode 100644 index 00000000000..0f12f150525 --- /dev/null +++ b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js @@ -0,0 +1,4 @@ +export const SEARCH = 'SEARCH'; +export const CREATE = 'CREATE'; +export const EXISTING_TAG = 'EXISTING_TAG'; +export const NEW_TAG = 'NEW_TAG'; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index 8ff479058f2..edf6c81c9e9 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -4,6 +4,7 @@ import { hasContent } from '~/lib/utils/text_utility'; import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility'; import { validateTag, ValidationResult } from '~/lib/utils/ref_validator'; import { i18n } from '~/releases/constants'; +import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants'; /** * @param {Object} link The link to test @@ -169,10 +170,23 @@ export const releaseDeleteMutationVariables = (state) => ({ }, }); -export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) => - includeTagNotes && tagNotes - ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n` +export const formattedReleaseNotes = ({ + includeTagNotes, + release: { description, tagMessage }, + tagNotes, + showCreateFrom, +}) => { + const notes = showCreateFrom ? tagMessage : tagNotes; + return includeTagNotes && notes + ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n` : description; +}; export const releasedAtChanged = ({ originalReleasedAt, release }) => originalReleasedAt !== release.releasedAt; + +export const isSearching = ({ step }) => step === SEARCH; +export const isCreating = ({ step }) => step === CREATE; + +export const isExistingTag = ({ tagStep }) => tagStep === EXISTING_TAG; +export const isNewTag = ({ tagStep }) => tagStep === NEW_TAG; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js index e52eccd6a21..fc450970cde 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js @@ -29,3 +29,9 @@ export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR'; export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES'; export const UPDATE_RELEASED_AT = 'UPDATE_RELEASED_AT'; + +export const SET_SEARCHING = 'SET_SEARCHING'; +export const SET_CREATING = 'SET_CREATING'; + +export const SET_EXISTING_TAG = 'SET_EXISTING_TAG'; +export const SET_NEW_TAG = 'SET_NEW_TAG'; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index ccd168aafc9..7ff18245a80 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -1,6 +1,7 @@ import { uniqueId, cloneDeep } from 'lodash'; import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants'; import * as types from './mutation_types'; +import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants'; const findReleaseLink = (release, id) => { return release.assets.links.find((l) => l.id === id); @@ -127,4 +128,17 @@ export default { [types.UPDATE_RELEASED_AT](state, releasedAt) { state.release.releasedAt = releasedAt; }, + + [types.SET_SEARCHING](state) { + state.step = SEARCH; + }, + [types.SET_CREATING](state) { + state.step = CREATE; + }, + [types.SET_EXISTING_TAG](state) { + state.tagStep = EXISTING_TAG; + }, + [types.SET_NEW_TAG](state) { + state.tagStep = NEW_TAG; + }, }; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js index 3112becfa9e..7bd3968dd93 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -1,3 +1,5 @@ +import { SEARCH, EXISTING_TAG } from './constants'; + export default ({ isExistingRelease, projectId, @@ -62,4 +64,6 @@ export default ({ includeTagNotes: false, existingRelease: null, originalReleasedAt: new Date(), + step: SEARCH, + tagStep: EXISTING_TAG, }); diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 236351005e7..334e7964bc2 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -34,12 +34,14 @@ export default { ForkSuggestion, WebIdeLink, CodeIntelligence, + AiGenie: () => import('ee_component/ai/components/ai_genie.vue'), }, mixins: [getRefMixin, glFeatureFlagMixin()], inject: { originalBranch: { default: '', }, + explainCodeAvailable: { default: false }, }, apollo: { projectInfo: { @@ -142,6 +144,9 @@ export default { }; }, computed: { + shouldRenderGenie() { + return this.explainCodeAvailable; + }, isLoggedIn() { return isLoggedIn(); }, @@ -316,9 +321,9 @@ export default { </script> <template> - <div> + <div class="gl-relative"> <gl-loading-icon v-if="isLoading" size="sm" /> - <div v-if="blobInfo && !isLoading" class="file-holder"> + <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder"> <blob-header :blob="blobInfo" :hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs" @@ -393,5 +398,11 @@ export default { :wrap-text-nodes="glFeatures.highlightJs" /> </div> + <ai-genie + v-if="shouldRenderGenie" + container-id="fileHolder" + :file-path="path" + class="gl-ml-7" + /> </div> </template> diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 1a834ba1d82..f3dbf98312e 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -1,13 +1,15 @@ <script> import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; -import { createAlert } from '~/alert'; +import { createAlert, VARIANT_INFO } from '~/alert'; import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import eventHub from '../event_hub'; import { POLLING_INTERVAL_DEFAULT, POLLING_INTERVAL_BACKOFF, FIVE_MINUTES_IN_MS, + FORK_UPDATED_EVENT, } from '../constants'; import forkDetailsQuery from '../queries/fork_details.query.graphql'; import ConflictsModal from './fork_sync_conflicts_modal.vue'; @@ -22,7 +24,11 @@ export const i18n = { behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'), limitedVisibility: s__('ForksDivergence|Source project has a limited visibility.'), error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'), - sync: s__('ForksDivergence|Update fork'), + updateFork: s__('ForksDivergence|Update fork'), + createMergeRequest: s__('ForksDivergence|Create merge request'), + successMessage: s__( + 'ForksDivergence|Successfully fetched and merged from the upstream repository.', + ), }; export default { @@ -55,7 +61,16 @@ export default { }); }, result({ loading }) { - this.handlePolingInterval(loading); + if (!loading && this.isSyncing) { + this.increasePollInterval(); + } + if (this.isForkUpdated) { + createAlert({ + message: this.$options.i18n.successMessage, + variant: VARIANT_INFO, + }); + eventHub.$emit(FORK_UPDATED_EVENT); + } }, pollInterval() { return this.pollInterval; @@ -86,6 +101,11 @@ export default { required: false, default: '', }, + canSyncBranch: { + type: Boolean, + required: false, + default: false, + }, aheadComparePath: { type: String, required: false, @@ -96,12 +116,21 @@ export default { required: false, default: '', }, + createMrPath: { + type: String, + required: false, + default: '', + }, + canUserCreateMrInFork: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { project: {}, currentPollInterval: null, - isSyncTriggered: false, }; }, computed: { @@ -126,6 +155,9 @@ export default { isSyncing() { return this.forkDetails?.isSyncing; }, + isForkUpdated() { + return this.isUpToDate && this.currentPollInterval; + }, ahead() { return this.project?.forkDetails?.ahead; }, @@ -163,12 +195,16 @@ export default { hasBehindAheadMessage() { return this.behindAheadMessage.length > 0; }, - isSyncButtonAvailable() { + hasUpdateButton() { return ( this.glFeatures.synchronizeFork && + this.canSyncBranch && ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence) ); }, + hasCreateMrButton() { + return this.canUserCreateMrInFork && this.ahead && this.createMrPath; + }, forkDivergenceMessage() { if (!this.forkDetails) { return this.$options.i18n.limitedVisibility; @@ -186,9 +222,8 @@ export default { }, watch: { hasConflicts(newVal) { - if (newVal && this.isSyncTriggered) { + if (newVal && this.currentPollInterval) { this.showConflictsModal(); - this.isSyncTriggered = false; } }, }, @@ -227,7 +262,6 @@ export default { this.$refs.modal.show(); }, startSyncing() { - this.isSyncTriggered = true; this.syncForkWithPolling(); }, checkIfSyncIsPossible() { @@ -237,18 +271,11 @@ export default { this.startSyncing(); } }, - handlePolingInterval(loading) { - if (!loading && this.isSyncing) { - const backoff = POLLING_INTERVAL_BACKOFF; - const interval = this.currentPollInterval; - const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS); - this.currentPollInterval = this.currentPollInterval - ? newInterval - : POLLING_INTERVAL_DEFAULT; - } - if (this.currentPollInterval === FIVE_MINUTES_IN_MS) { - this.$apollo.queries.forkDetailsQuery.stopPolling(); - } + increasePollInterval() { + const backoff = POLLING_INTERVAL_BACKOFF; + const interval = this.currentPollInterval; + const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS); + this.currentPollInterval = this.currentPollInterval ? newInterval : POLLING_INTERVAL_DEFAULT; }, }, }; @@ -283,16 +310,29 @@ export default { > {{ $options.i18n.inaccessibleProject }} </div> - <gl-button - v-if="isSyncButtonAvailable" - :disabled="forkDetails.isSyncing" - @click="checkIfSyncIsPossible" - > - <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" /> - <span>{{ $options.i18n.sync }}</span> - </gl-button> + <div class="gl-display-flex gl-xs-display-none!"> + <gl-button + v-if="hasCreateMrButton" + class="gl-ml-4" + :href="createMrPath" + data-testid="create-mr-button" + > + <span>{{ $options.i18n.createMergeRequest }}</span> + </gl-button> + <gl-button + v-if="hasUpdateButton" + class="gl-ml-4" + :disabled="forkDetails.isSyncing" + data-testid="update-fork-button" + @click="checkIfSyncIsPossible" + > + <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" /> + <span>{{ $options.i18n.updateFork }}</span> + </gl-button> + </div> <conflicts-modal ref="modal" + :selected-branch="selectedBranch" :source-name="sourceName" :source-path="sourcePath" :source-default-branch="sourceDefaultBranch" diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue index 0bfb90bb3ec..ffe4fd4cd38 100644 --- a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue +++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue @@ -19,10 +19,9 @@ export const i18n = { "ForksDivergence|Fetch the latest changes from the upstream repository's default branch:", ), step2Text: s__( - "ForksDivergence|Check out to a new branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.", + "ForksDivergence|Check out to a branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.", ), step3Text: s__('ForksDivergence|Push the updates to remote:'), - step4Text: s__("ForksDivergence|Create a merge request to your project's default branch."), copyToClipboard: __('Copy to clipboard'), close: __('Close'), }; @@ -53,12 +52,20 @@ export default { required: false, default: '', }, + selectedBranch: { + type: String, + required: true, + default: '', + }, }, computed: { instructionsStep1() { const baseUrl = getBaseURL(); return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`; }, + instructionsStep2() { + return `git checkout ${this.selectedBranch}\ngit merge FETCH_HEAD`; + }, }, methods: { show() { @@ -69,9 +76,7 @@ export default { }, }, i18n, - instructionsStep2: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD', - instructionsStep2Clipboard: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD', - instructionsStep3: 'git commit\ngit push', + instructionsStep3: 'git push', }; </script> <template> @@ -100,14 +105,12 @@ export default { <b> {{ $options.i18n.step2 }}</b> {{ $options.i18n.step2Text }} </p> <div class="gl-display-flex gl-mb-4"> - <pre - class="gl-w-full gl-mb-0 gl-mr-3" - data-testid="resolve-conflict-instructions" - v-html="$options.instructionsStep2 /* eslint-disable-line vue/no-v-html */" - ></pre> + <pre class="gl-w-full gl-mb-0 gl-mr-3" data-testid="resolve-conflict-instructions">{{ + instructionsStep2 + }}</pre> <modal-copy-button modal-id="fork-sync-conflicts-modal" - :text="$options.instructionsStep2Clipboard" + :text="instructionsStep2" :title="$options.i18n.copyToClipboard" class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" /> @@ -127,9 +130,6 @@ export default { class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3" /> </div> - <p> - <b> {{ $options.i18n.step4 }}</b> {{ $options.i18n.step4Text }} - </p> <template #modal-footer> <gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button> </template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 2d2e21dfd92..82dd1fda2a0 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -12,6 +12,8 @@ import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_ima import SignatureBadge from '~/commit/components/signature_badge.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; +import eventHub from '../event_hub'; +import { FORK_UPDATED_EVENT } from '../constants'; export default { components: { @@ -97,10 +99,19 @@ export default { this.commit = null; }, }, + mounted() { + eventHub.$on(FORK_UPDATED_EVENT, this.refetchLastCommit); + }, + beforeDestroy() { + eventHub.$off(FORK_UPDATED_EVENT, this.refetchLastCommit); + }, methods: { toggleShowDescription() { this.showDescription = !this.showDescription; }, + refetchLastCommit() { + this.$apollo.queries.commit.refetch(); + }, }, defaultAvatarUrl, safeHtmlConfig: { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index a6191203b2f..b711f671850 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -112,3 +112,5 @@ export const POLLING_INTERVAL_DEFAULT = 2500; export const POLLING_INTERVAL_BACKOFF = 2; export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal'; + +export const FORK_UPDATED_EVENT = 'fork:updated'; diff --git a/app/assets/javascripts/repository/event_hub.js b/app/assets/javascripts/repository/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/repository/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 0db9dcb43df..5a3958d8e4a 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -32,7 +32,16 @@ Vue.use(PerformancePlugin, { export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; - const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; + const { + projectPath, + projectShortPath, + ref, + escapedRef, + fullName, + resourceId, + userId, + explainCodeAvailable, + } = dataset; const router = createRouter(projectPath, escapedRef); apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -70,11 +79,15 @@ export default function setupVueRepositoryList() { return null; } const { + selectedBranch, sourceName, sourcePath, sourceDefaultBranch, + createMrPath, + canSyncBranch, aheadComparePath, behindComparePath, + canUserCreateMrInFork, } = forkEl.dataset; return new Vue({ el: forkEl, @@ -82,13 +95,16 @@ export default function setupVueRepositoryList() { render(h) { return h(ForkInfo, { props: { + canSyncBranch: parseBoolean(canSyncBranch), projectPath, - selectedBranch: ref, + selectedBranch, sourceName, sourcePath, sourceDefaultBranch, aheadComparePath, behindComparePath, + createMrPath, + canUserCreateMrInFork, }, }); }, @@ -138,6 +154,7 @@ export default function setupVueRepositoryList() { projectId, value: refType ? joinPaths('refs', refType, ref) : ref, useSymbolicRefNames: true, + queryParams: { sort: 'updated_desc' }, }, on: { input(selectedRef) { @@ -151,8 +168,8 @@ export default function setupVueRepositoryList() { initLastCommitApp(); initBlobControlsApp(); - initForkInfo(); initRefSwitcher(); + initForkInfo(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); @@ -273,6 +290,7 @@ export default function setupVueRepositoryList() { store: createStore(), router, apolloProvider, + provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) }, render(h) { return h(App); }, diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue deleted file mode 100644 index 3ad5642afc7..00000000000 --- a/app/assets/javascripts/saved_replies/components/list_item.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlButton, GlModal, GlModalDirective, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql'; - -export default { - components: { - GlButton, - GlModal, - GlSprintf, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - props: { - reply: { - type: Object, - required: true, - }, - }, - data() { - return { - isDeleting: false, - modalId: uniqueId('delete-saved-reply-'), - }; - }, - computed: { - id() { - return getIdFromGraphQLId(this.reply.id); - }, - }, - methods: { - onDelete() { - this.isDeleting = true; - - this.$apollo.mutate({ - mutation: deleteSavedReplyMutation, - variables: { - id: this.reply.id, - }, - update: (cache) => { - const cacheId = cache.identify(this.reply); - cache.evict({ id: cacheId }); - }, - }); - }, - }, - actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } }, - actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } }, -}; -</script> - -<template> - <li class="gl-mb-5"> - <div class="gl-display-flex gl-align-items-center"> - <strong data-testid="saved-reply-name">{{ reply.name }}</strong> - <div class="gl-ml-auto"> - <gl-button - v-gl-tooltip - :to="{ name: 'edit', params: { id: id } }" - icon="pencil" - :title="__('Edit')" - :aria-label="__('Edit')" - class="gl-mr-3" - data-testid="saved-reply-edit-btn" - /> - <gl-button - v-gl-modal="modalId" - v-gl-tooltip - icon="remove" - :aria-label="__('Delete')" - :title="__('Delete')" - variant="danger" - category="secondary" - data-testid="saved-reply-delete-btn" - :loading="isDeleting" - /> - </div> - </div> - <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div> - <gl-modal - :title="__('Delete saved reply')" - :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>{{ reply.name }}</strong></template - > - </gl-sprintf> - </gl-modal> - </li> -</template> diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index d71785d7fac..1e4b1e36514 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -15,6 +15,7 @@ export const initSearchApp = () => { const store = createStore({ query, navigation, + useNewNavigation: gon.use_new_navigation, }); initTopbar(store); diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 60de63c7d7a..317145d4cd1 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -1,21 +1,23 @@ <script> import { mapState, mapGetters } from 'vuex'; import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue'; +import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants'; import ResultsFilters from './results_filters.vue'; -import LanguageFilter from './language_filter.vue'; +import LanguageFilter from './language_filter/index.vue'; export default { name: 'GlobalSearchSidebar', components: { ResultsFilters, ScopeNavigation, + ScopeNewNavigation, LanguageFilter, + SidebarPortal, }, - mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['urlQuery']), + ...mapState(['urlQuery', 'useNewNavigation']), ...mapGetters(['currentScope']), showIssueAndMergeFilters() { return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS; @@ -23,12 +25,23 @@ export default { showBlobFilter() { return this.currentScope === SCOPE_BLOB; }, + showOldNavigation() { + return Boolean(this.currentScope); + }, }, }; </script> <template> + <section v-if="useNewNavigation"> + <sidebar-portal> + <scope-new-navigation /> + <results-filters v-if="showIssueAndMergeFilters" /> + <language-filter v-if="showBlobFilter" /> + </sidebar-portal> + </section> <section + v-else class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5" > <scope-navigation /> diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue index f7873a994aa..feff3f77dd2 100644 --- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue @@ -1,10 +1,15 @@ <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: { @@ -16,9 +21,13 @@ export default { type: Object, required: true, }, + trackingNamespace: { + type: String, + required: true, + }, }, computed: { - ...mapState(['query']), + ...mapState(['query', 'useNewNavigation']), ...mapGetters(['queryLanguageFilters']), dataFilters() { return Object.values(this.filtersData?.filters || []); @@ -30,8 +39,11 @@ export default { get() { return intersection(this.flatDataFilterValues, this.queryLanguageFilters); }, - set(value) { + async set(value) { this.setQuery({ key: this.filtersData?.filterParam, value }); + + await Vue.nextTick(); + this.trackSelectCheckbox(); }, }, labelCountClasses() { @@ -40,9 +52,15 @@ export default { }, methods: { ...mapActions(['setQuery']), - getFormatedCount(count) { + 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, @@ -51,7 +69,7 @@ export default { <template> <div class="gl-mx-5"> - <h5 class="gl-mt-0">{{ filtersData.header }}</h5> + <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" @@ -67,7 +85,7 @@ export default { {{ f.label }} </span> <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount"> - {{ getFormatedCount(f.count) }} + {{ getFormattedCount(f.count) }} </span> </span> </gl-form-checkbox> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index e7aa3d61409..56e44d454a1 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -1,5 +1,7 @@ <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 { @@ -7,13 +9,17 @@ export default { components: { RadioFilter, }, + computed: { + ...mapState(['useNewNavigation']), + }, confidentialFilterData, + HR_DEFAULT_CLASSES, }; </script> <template> <div> <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" /> - <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> + <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/components/language_filter/data.js index df44a58a14b..df44a58a14b 100644 --- a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js +++ b/app/assets/javascripts/search/sidebar/components/language_filter/data.js diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index b2f8d3e1f5f..40b50f657f0 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -3,10 +3,18 @@ import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data'; -import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants'; -import { convertFiltersData } from '../utils'; -import CheckboxFilter from './checkbox_filter.vue'; +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; +import { convertFiltersData } from '../../utils'; +import CheckboxFilter from '../checkbox_filter.vue'; +import { + trackShowMore, + trackShowHasOverMax, + trackSubmitQuery, + trackResetQuery, + TRACKING_ACTION_SELECT, +} from './tracking'; + +import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data'; export default { name: 'LanguageFilter', @@ -30,7 +38,7 @@ export default { reset: s__('GlobalSearch|Reset filters'), }, computed: { - ...mapState(['aggregations', 'sidebarDirty']), + ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']), ...mapGetters([ 'languageAggregationBuckets', 'currentUrlQueryHasLanguageFilters', @@ -76,11 +84,21 @@ export default { ]), onShowMore() { this.showAll = true; + trackShowMore(); + + if (this.hasOverMax) { + trackShowHasOverMax(); + } + }, + submitQuery() { + trackSubmitQuery(); + this.applyQuery(); }, trimBuckets(length) { return this.languageAggregationBuckets.slice(0, length); }, cleanResetFilters() { + trackResetQuery(); if (this.currentUrlQueryHasLanguageFilters) { return this.resetLanguageQueryWithRedirect(); } @@ -89,6 +107,7 @@ export default { }, }, HR_DEFAULT_CLASSES, + TRACKING_ACTION_SELECT, }; </script> @@ -96,15 +115,18 @@ export default { <gl-form v-if="hasBuckets" class="gl-pt-5 gl-md-pt-0 language-filter-checkbox" - @submit.prevent="applyQuery" + @submit.prevent="submitQuery" > - <hr :class="dividerClasses" /> + <hr v-if="!useNewNavigation" :class="dividerClasses" /> <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" /> + <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> @@ -125,7 +147,7 @@ export default { </gl-button> </div> <div v-if="!aggregations.error"> - <hr :class="$options.HR_DEFAULT_CLASSES" /> + <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5" > diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js new file mode 100644 index 00000000000..db107830329 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js @@ -0,0 +1,39 @@ +import Tracking from '~/tracking'; +import { MAX_ITEM_LENGTH } from './data'; + +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 ${MAX_ITEM_LENGTH} 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/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue index 0733dc72d2e..477ba37dab7 100644 --- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -16,7 +16,7 @@ export default { }, }, computed: { - ...mapState(['query']), + ...mapState(['query', 'useNewNavigation']), ...mapGetters(['currentScope']), ANY() { return this.filterData.filters.ANY; @@ -56,7 +56,7 @@ export default { <template> <div> - <h5 class="gl-mt-0">{{ filterData.header }}</h5> + <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filterData.header }}</h5> <gl-form-radio-group v-model="selectedFilter"> <gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value"> {{ radioLabel(f) }} diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index 7d995f26684..24804baef44 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -1,6 +1,7 @@ <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'; @@ -15,7 +16,7 @@ export default { ConfidentialityFilter, }, computed: { - ...mapState(['urlQuery', 'sidebarDirty']), + ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']), ...mapGetters(['currentScope']), showReset() { return this.urlQuery.state || this.urlQuery.confidential; @@ -26,6 +27,9 @@ export default { 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']), @@ -35,7 +39,7 @@ export default { <template> <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> - <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" /> + <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"> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index 02a3870f499..fc41baee831 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -3,8 +3,8 @@ import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; -import { formatSearchResultCount } from '../../store/utils'; import { slugifyWithUnderscore } from '../../../lib/utils/text_utility'; export default { @@ -22,15 +22,17 @@ export default { ...mapState(['navigation', 'urlQuery']), }, created() { - this.fetchSidebarCount(); + if (this.urlQuery?.search) { + this.fetchSidebarCount(); + } }, methods: { ...mapActions(['fetchSidebarCount']), - showFormatedCount(count) { - return formatSearchResultCount(count); + showFormatedCount(countString) { + return formatSearchResultCount(countString); }, - isCountOverLimit(count) { - return count.includes('+'); + isCountOverLimit(countString) { + return Boolean(addCountOverLimit(countString)); }, handleClick(scope) { this.track('click_menu_item', { label: `vertical_navigation_${scope}` }); diff --git a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue new file mode 100644 index 00000000000..86b7cc577a6 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue @@ -0,0 +1,40 @@ +<script> +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'; + +export default { + name: 'ScopeNewNavigation', + i18n: { + countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), + }, + components: { + NavItem, + }, + mixins: [Tracking.mixin()], + computed: { + ...mapState(['navigation', 'urlQuery']), + ...mapGetters(['navigationItems']), + }, + created() { + if (this.urlQuery?.search) { + this.fetchSidebarCount(); + } + }, + methods: { + ...mapActions(['fetchSidebarCount']), + }, + NAV_LINK_DEFAULT_CLASSES, + NAV_LINK_COUNT_DEFAULT_CLASSES, +}; +</script> + +<template> + <nav data-testid="search-filter" class="gl-py-2 gl-relative"> + <ul class="gl-px-2 gl-list-style-none"> + <nav-item v-for="item in navigationItems" :key="`menu-${item.title}`" :item="item" /> + </ul> + </nav> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index c3deabfcc26..44d6b537b7b 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -1,5 +1,7 @@ <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 { @@ -7,13 +9,17 @@ export default { components: { RadioFilter, }, + computed: { + ...mapState(['useNewNavigation']), + }, stateFilterData, + HR_DEFAULT_CLASSES, }; </script> <template> <div> <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" /> - <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> + <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 19b1ad0905b..9519154a571 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -4,7 +4,7 @@ export const SCOPE_BLOB = 'blobs'; export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', - 'gl-flex-wrap-nowrap', + 'gl-flex-nowrap', 'gl-text-gray-900', ]; export const NAV_LINK_DEFAULT_CLASSES = [ @@ -14,3 +14,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [ export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal']; export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100']; export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block']; + +export const TRACKING_LABEL_CHECKBOX = 'Checkbox'; diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js index 4357d6202df..78e03fcdeee 100644 --- a/app/assets/javascripts/search/sidebar/utils.js +++ b/app/assets/javascripts/search/sidebar/utils.js @@ -1,4 +1,4 @@ -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; export const convertFiltersData = (rawBuckets) => rawBuckets.reduce( diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index da2bf4b602e..3d6ca2a6eee 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -4,7 +4,7 @@ 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/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; import { @@ -140,8 +140,7 @@ export const fetchLanguageAggregation = ({ commit, state }) => { commit(types.REQUEST_AGGREGATIONS); return axios .get(getAggregationsUrl()) - .then((result) => { - const { data } = result; + .then(({ data }) => { commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data)); }) .catch((e) => { diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index ba4fe85db9d..c8ee0a3f9d9 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,6 +1,6 @@ import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; export const MAX_FREQUENT_ITEMS = 5; @@ -17,3 +17,15 @@ export const SIDEBAR_PARAMS = [ ]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; + +export const ICON_MAP = { + blobs: 'code', + issues: 'issues', + merge_requests: 'merge-request', + commits: 'commit', + notes: 'comments', + milestones: 'tag', + users: 'users', + projects: 'project', + wiki_blobs: 'overview', +}; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index 36d98233e28..135c9a3d67c 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,7 +1,8 @@ import { findKey, has } from 'lodash'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; +import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; -import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants'; export const frequentGroups = (state) => { return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY]; @@ -26,3 +27,13 @@ export const queryLanguageFilters = (state) => state.query[languageFilterData.fi 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, + icon: ICON_MAP[item.scope] || '', + link: item.link, + is_active: Boolean(item?.active), + pill_count: `${formatSearchResultCount(item?.count)}${addCountOverLimit(item?.count)}` || '', + items: [], + })); diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index e20a43808cf..634f8f7a7fa 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -7,11 +7,11 @@ import createState from './state'; Vue.use(Vuex); -export const getStoreConfig = ({ query, navigation }) => ({ +export const getStoreConfig = ({ query, navigation, useNewNavigation }) => ({ actions, getters, mutations, - state: createState({ query, navigation }), + state: createState({ query, navigation, useNewNavigation }), }); const createStore = (config) => new Vuex.Store(getStoreConfig(config)); diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index d85a135bb4e..a62b6728819 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; -const createState = ({ query, navigation }) => ({ +const createState = ({ query, navigation, useNewNavigation }) => ({ urlQuery: cloneDeep(query), query, groups: [], @@ -14,6 +14,7 @@ const createState = ({ query, navigation }) => ({ }, sidebarDirty: false, navigation, + useNewNavigation, aggregations: { error: false, fetching: false, diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index 8e484e69646..2f02ef3475c 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -2,7 +2,7 @@ import { isEqual, orderBy } from 'lodash'; import AccessorUtilities from '~/lib/utils/accessor'; import { formatNumber } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, @@ -144,3 +144,7 @@ export const prepareSearchAggregations = (state, aggregationData) => return item; }); + +export const addCountOverLimit = (count = '') => { + return count.includes('+') ? '+' : ''; +}; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js deleted file mode 100644 index 94244eeb12e..00000000000 --- a/app/assets/javascripts/search_autocomplete.js +++ /dev/null @@ -1,520 +0,0 @@ -/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ - -import $ from 'jquery'; -import { escape, throttle } from 'lodash'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { s__, __, sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import axios from './lib/utils/axios_utils'; -import { spriteIcon } from './lib/utils/common_utils'; -import { - isInGroupsPage, - isInProjectPage, - getGroupSlug, - getProjectSlug, -} from './search_autocomplete_utils'; - -/** - * Search input in top navigation bar. - * On click, opens a dropdown - * As the user types it filters the results - * When the user clicks `x` button it cleans the input and closes the dropdown. - */ - -const KEYCODE = { - ESCAPE: 27, - BACKSPACE: 8, - ENTER: 13, - UP: 38, - DOWN: 40, -}; - -function setSearchOptions() { - const $projectOptionsDataEl = $('.js-search-project-options'); - const $groupOptionsDataEl = $('.js-search-group-options'); - const $dashboardOptionsDataEl = $('.js-search-dashboard-options'); - - if ($projectOptionsDataEl.length) { - gl.projectOptions = gl.projectOptions || {}; - - const projectPath = $projectOptionsDataEl.data('projectPath'); - - gl.projectOptions[projectPath] = { - name: $projectOptionsDataEl.data('name'), - issuesPath: $projectOptionsDataEl.data('issuesPath'), - issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'), - mrPath: $projectOptionsDataEl.data('mrPath'), - }; - } - - if ($groupOptionsDataEl.length) { - gl.groupOptions = gl.groupOptions || {}; - - const groupPath = $groupOptionsDataEl.data('groupPath'); - - gl.groupOptions[groupPath] = { - name: $groupOptionsDataEl.data('name'), - issuesPath: $groupOptionsDataEl.data('issuesPath'), - mrPath: $groupOptionsDataEl.data('mrPath'), - }; - } - - if ($dashboardOptionsDataEl.length) { - gl.dashboardOptions = { - name: s__('SearchAutocomplete|All GitLab'), - issuesPath: $dashboardOptionsDataEl.data('issuesPath'), - mrPath: $dashboardOptionsDataEl.data('mrPath'), - }; - } -} - -export class SearchAutocomplete { - constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) { - setSearchOptions(); - this.bindEventContext(); - this.wrap = wrap || $('.search'); - this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts'); - this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath'); - this.projectId = projectId || this.optsEl.data('autocompleteProjectId') || ''; - this.projectRef = projectRef || this.optsEl.data('autocompleteProjectRef') || ''; - this.dropdown = this.wrap.find('.dropdown'); - this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); - this.dropdownMenu = this.dropdown.find('.dropdown-menu'); - this.dropdownContent = this.dropdown.find('.dropdown-content'); - this.scopeInputEl = this.getElement('#scope'); - this.searchInput = this.getElement('.search-input'); - this.projectInputEl = this.getElement('#search_project_id'); - this.groupInputEl = this.getElement('#group_id'); - this.searchCodeInputEl = this.getElement('#search_code'); - this.repositoryInputEl = this.getElement('#repository_ref'); - this.clearInput = this.getElement('.js-clear-input'); - this.scrollFadeInitialized = false; - this.saveOriginalState(); - - // Only when user is logged in - if (gon.current_user_id) { - this.createAutocomplete(); - } - - this.bindEvents(); - this.dropdownToggle.dropdown(); - this.searchInput.addClass('js-autocomplete-disabled'); - } - - // Finds an element inside wrapper element - bindEventContext() { - this.onSearchInputBlur = this.onSearchInputBlur.bind(this); - this.onClearInputClick = this.onClearInputClick.bind(this); - this.onSearchInputFocus = this.onSearchInputFocus.bind(this); - this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); - this.onSearchInputChange = this.onSearchInputChange.bind(this); - this.setScrollFade = this.setScrollFade.bind(this); - } - getElement(selector) { - return this.wrap.find(selector); - } - - saveOriginalState() { - return (this.originalState = this.serializeState()); - } - - createAutocomplete() { - return initDeprecatedJQueryDropdown(this.searchInput, { - filterInputBlur: false, - filterable: true, - filterRemote: true, - highlight: true, - icon: true, - enterCallback: false, - filterInput: 'input#search', - search: { - fields: ['text'], - }, - id: this.getSearchText, - data: this.getData.bind(this), - selectable: true, - clicked: this.onClick.bind(this), - trackSuggestionClickedLabel: 'search_autocomplete_suggestion', - }); - } - - getSearchText(selectedObject) { - return selectedObject.id ? selectedObject.text : ''; - } - - getData(term, callback) { - if (!term) { - const contents = this.getCategoryContents(); - if (contents) { - const deprecatedJQueryDropdownInstance = this.searchInput.data('deprecatedJQueryDropdown'); - - if (deprecatedJQueryDropdownInstance) { - deprecatedJQueryDropdownInstance.filter.options.callback(contents); - } - this.enableAutocomplete(); - } - return; - } - - // Prevent multiple ajax calls - if (this.loadingSuggestions) { - return; - } - - this.loadingSuggestions = true; - - return axios - .get(this.autocompletePath, { - params: { - project_id: this.projectId, - project_ref: this.projectRef, - term, - }, - }) - .then((response) => { - const options = this.scopedSearchOptions(term); - - // List results - let lastCategory = null; - for (let i = 0, len = response.data.length; i < len; i += 1) { - const suggestion = response.data[i]; - // Add group header before list each group - if (lastCategory !== suggestion.category) { - options.push({ type: 'separator' }); - options.push({ - type: 'header', - content: suggestion.category, - }); - lastCategory = suggestion.category; - } - - // Add the suggestion - options.push({ - id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, - icon: this.getAvatar(suggestion), - category: suggestion.category, - text: suggestion.label, - url: suggestion.url, - }); - } - - callback(options); - - this.loadingSuggestions = false; - this.highlightFirstRow(); - this.setScrollFade(); - }) - .catch(() => { - this.loadingSuggestions = false; - }); - } - - getCategoryContents() { - const userName = gon.current_username; - const { projectOptions, groupOptions, dashboardOptions } = gl; - - // Get options - let options; - if (isInProjectPage() && projectOptions) { - options = projectOptions[getProjectSlug()]; - } else if (isInGroupsPage() && groupOptions) { - options = groupOptions[getGroupSlug()]; - } else if (dashboardOptions) { - options = dashboardOptions; - } - - const { issuesPath, mrPath, name, issuesDisabled } = options; - const baseItems = []; - - if (name) { - baseItems.push({ - type: 'header', - content: `${name}`, - }); - } - - const issueItems = [ - { - text: s__('SearchAutocomplete|Issues assigned to me'), - url: `${issuesPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Issues I've created"), - url: `${issuesPath}/?author_username=${userName}`, - }, - ]; - const mergeRequestItems = [ - { - text: s__('SearchAutocomplete|Merge requests assigned to me'), - url: `${mrPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"), - url: `${mrPath}/?reviewer_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests I've created"), - url: `${mrPath}/?author_username=${userName}`, - }, - ]; - - let items; - if (issuesDisabled) { - items = baseItems.concat(mergeRequestItems); - } else { - items = baseItems.concat(...issueItems, ...mergeRequestItems); - } - return items; - } - - // Add option to proceed with the search for each - // scope that is currently available, namely: - // - // - Search in this project - // - Search in this group (or project's group) - // - Search in all GitLab - scopedSearchOptions(term) { - const icon = spriteIcon('search', 's16 inline-search-icon'); - const projectId = this.projectInputEl.val(); - const groupId = this.groupInputEl.val(); - const options = []; - - if (projectId) { - const projectOptions = gl.projectOptions[getProjectSlug()]; - const url = groupId - ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}&nav_source=navbar` - : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&nav_source=navbar`; - - options.push({ - icon, - text: term, - template: sprintf( - s__(`SearchAutocomplete|in project %{projectName}`), - { - projectName: `<i>${projectOptions.name}</i>`, - }, - false, - ), - url, - }); - } - - if (groupId) { - const groupOptions = gl.groupOptions[getGroupSlug()]; - options.push({ - icon, - text: term, - template: sprintf( - s__(`SearchAutocomplete|in group %{groupName}`), - { - groupName: `<i>${groupOptions.name}</i>`, - }, - false, - ), - url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}&nav_source=navbar`, - }); - } - - options.push({ - icon, - text: term, - template: s__('SearchAutocomplete|in all GitLab'), - url: `${gon.relative_url_root}/search?search=${term}&nav_source=navbar`, - }); - - return options; - } - - serializeState() { - return { - // Search Criteria - search_project_id: this.projectInputEl.val(), - group_id: this.groupInputEl.val(), - search_code: this.searchCodeInputEl.val(), - repository_ref: this.repositoryInputEl.val(), - scope: this.scopeInputEl.val(), - }; - } - - bindEvents() { - this.searchInput.on('input', this.onSearchInputChange); - this.searchInput.on('keyup', this.onSearchInputKeyUp); - this.searchInput.on('focus', this.onSearchInputFocus); - this.searchInput.on('blur', this.onSearchInputBlur); - this.clearInput.on('click', this.onClearInputClick); - this.dropdownContent.on( - 'scroll', - throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - ); - - this.searchInput.on('click', (e) => { - e.stopPropagation(); - }); - } - - enableAutocomplete() { - this.setScrollFade(); - - // No need to enable anything if user is not logged in - if (!gon.current_user_id) { - return; - } - - // If the dropdown is closed, we'll open it - if (!this.dropdown.hasClass('show')) { - this.loadingSuggestions = false; - this.dropdownToggle.dropdown('toggle'); - - const trackEvent = 'click_search_bar'; - const trackCategory = undefined; // will be default set in event method - - Tracking.event(trackCategory, trackEvent, { - label: 'main_navigation', - property: 'navigation', - }); - - return this.searchInput.removeClass('js-autocomplete-disabled'); - } - } - - onSearchInputChange() { - this.enableAutocomplete(); - } - - onSearchInputKeyUp(e) { - switch (e.keyCode) { - case KEYCODE.ESCAPE: - this.restoreOriginalState(); - break; - case KEYCODE.ENTER: - this.disableAutocomplete(); - break; - default: - } - this.wrap.toggleClass('has-value', Boolean(e.target.value)); - } - - onSearchInputFocus() { - this.isFocused = true; - this.wrap.addClass('search-active'); - if (this.getValue() === '') { - return this.getData(); - } - } - - getValue() { - return this.searchInput.val(); - } - - onClearInputClick(e) { - e.preventDefault(); - this.wrap.toggleClass('has-value', Boolean(e.target.value)); - return this.searchInput.val('').focus(); - } - - onSearchInputBlur() { - this.isFocused = false; - this.wrap.removeClass('search-active'); - // If input is blank then restore state - if (this.searchInput.val() === '') { - this.restoreOriginalState(); - } - this.dropdownMenu.removeClass('show'); - } - - restoreOriginalState() { - const inputs = Object.keys(this.originalState); - for (let i = 0, len = inputs.length; i < len; i += 1) { - const input = inputs[i]; - this.getElement(`#${input}`).val(this.originalState[input]); - } - } - - resetSearchState() { - const inputs = Object.keys(this.originalState); - const results = []; - for (let i = 0, len = inputs.length; i < len; i += 1) { - const input = inputs[i]; - results.push(this.getElement(`#${input}`).val('')); - } - return results; - } - - disableAutocomplete() { - if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { - this.searchInput.addClass('js-autocomplete-disabled'); - this.dropdownToggle.dropdown('toggle'); - this.restoreMenu(); - } - } - - restoreMenu() { - const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; - return this.dropdownContent.html(html); - } - - onClick(item, $el, e) { - if (window.location.pathname.indexOf(item.url) !== -1) { - if (!e.metaKey) e.preventDefault(); - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - if (item.category === 'Projects') { - this.projectInputEl.val(item.id); - } - // eslint-disable-next-line @gitlab/require-i18n-strings - if (item.category === 'Groups') { - this.groupInputEl.val(item.id); - } - $el.removeClass('is-active'); - this.disableAutocomplete(); - return this.searchInput.val('').focus(); - } - } - - highlightFirstRow() { - this.searchInput.data('deprecatedJQueryDropdown').highlightRowAtIndex(null, 0); - } - - getAvatar(item) { - if (!Object.prototype.hasOwnProperty.call(item, 'avatar_url')) { - return false; - } - - const { label, id } = item; - const avatarUrl = item.avatar_url; - const avatar = avatarUrl - ? `<img class="search-item-avatar" src="${avatarUrl}" />` - : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( - escape(label), - )}</div>`; - - return avatar; - } - - isScrolledUp() { - const el = this.dropdownContent[0]; - const currentPosition = this.contentClientHeight + el.scrollTop; - - return currentPosition < this.maxPosition; - } - - initScrollFade() { - const el = this.dropdownContent[0]; - this.scrollFadeInitialized = true; - - this.contentClientHeight = el.clientHeight; - this.maxPosition = el.scrollHeight; - this.dropdownMenu.addClass('dropdown-content-faded-mask'); - } - - setScrollFade() { - this.initScrollFade(); - - this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp()); - } -} - -export default function initSearchAutocomplete(opts) { - return new SearchAutocomplete(opts); -} diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js deleted file mode 100644 index a9a0f941e93..00000000000 --- a/app/assets/javascripts/search_autocomplete_utils.js +++ /dev/null @@ -1,19 +0,0 @@ -import { getPagePath } from './lib/utils/common_utils'; - -export const isInGroupsPage = () => getPagePath() === 'groups'; - -export const isInProjectPage = () => getPagePath() === 'projects'; - -export const getProjectSlug = () => { - if (isInProjectPage()) { - return document?.body?.dataset?.project; - } - return null; -}; - -export const getGroupSlug = () => { - if (isInProjectPage() || isInGroupsPage()) { - return document?.body?.dataset?.group; - } - return null; -}; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index ccfaa678201..e96f71981e5 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,7 +1,6 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { parseErrorMessage } from '~/lib/utils/error_message'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; @@ -34,9 +33,6 @@ export const i18n = { 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.', ), securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), - genericErrorText: s__( - `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`, - ), }; export default { @@ -128,9 +124,8 @@ export default { dismissedProjects.add(this.projectFullPath); this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); }, - onError(error) { - const { message, userFacing } = parseErrorMessage(error); - this.errorMessage = userFacing ? message : i18n.genericErrorText; + onError(message) { + this.errorMessage = message; }, dismissAlert() { this.errorMessage = ''; diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 6beb6cd4d34..1d5ff5eb16f 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -6,6 +6,7 @@ import { REPORT_TYPE_SAST_IAC, REPORT_TYPE_DAST, REPORT_TYPE_DAST_PROFILES, + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, @@ -67,6 +68,30 @@ export const DAST_PROFILES_DESCRIPTION = s__( ); export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); +export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); +export const BAS_BADGE_TOOLTIP = s__( + 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', +); +export const BAS_DESCRIPTION = s__( + 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', +); +export const BAS_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', +); +export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); +export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); + +export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( + 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', +); +export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', + { anchor: 'extend-dynamic-application-security-testing-dast' }, +); +export const BAS_DAST_FEATURE_FLAG_NAME = s__( + 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', +); + export const SECRET_DETECTION_NAME = __('Secret Detection'); export const SECRET_DETECTION_DESCRIPTION = __( 'Analyze your source code and git history for secrets.', @@ -142,6 +167,7 @@ export const SCANNER_NAMES_MAP = { COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, SECRET_DETECTION: SECRET_DETECTION_NAME, DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, + BAS: BAS_SHORT_NAME, GENERIC: s__('ciReport|Manually added'), }; @@ -223,6 +249,25 @@ export const securityFeatures = [ configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, }, }, + { + anchor: 'bas', + badge: { + alwaysDisplay: true, + text: BAS_BADGE_TEXT, + tooltipText: BAS_BADGE_TOOLTIP, + variant: 'info', + }, + description: BAS_DESCRIPTION, + name: BAS_NAME, + helpPath: BAS_HELP_PATH, + secondary: { + configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, + description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, + name: BAS_DAST_FEATURE_FLAG_NAME, + }, + shortName: BAS_SHORT_NAME, + type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + }, ]; export const complianceFeatures = [ diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 19b412d66ca..d1b705fe2fc 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -1,7 +1,10 @@ <script> import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants'; +import { + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + REPORT_TYPE_SAST_IAC, +} from '~/vue_shared/security_reports/constants'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import FeatureCardBadge from './feature_card_badge.vue'; @@ -68,8 +71,7 @@ export default { }; }, hasSecondary() { - const { name, description, configurationText } = this.feature.secondary ?? {}; - return Boolean(name && description && configurationText); + return Boolean(this.feature.secondary); }, // This condition is a temporary hack to not display any wrong information // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307. @@ -78,7 +80,17 @@ export default { return this.feature.type !== REPORT_TYPE_SAST_IAC; }, hasBadge() { - return Boolean(this.available && this.feature.badge?.text); + const shouldDisplay = this.available || this.feature.badge?.alwaysDisplay; + return Boolean(shouldDisplay && this.feature.badge?.text); + }, + hasEnabledStatus() { + return ( + this.isNotSastIACTemporaryHack && + this.feature.type !== REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION + ); + }, + showSecondaryConfigurationHelpPath() { + return Boolean(this.available && this.feature.secondary?.configurationHelpPath); }, }, methods: { @@ -118,19 +130,25 @@ export default { :badge-href="feature.badge.badgeHref" /> - <template v-if="enabled"> - <span> - <gl-icon name="check-circle-filled" /> - <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> - </span> - </template> - - <template v-else-if="available"> - <span>{{ $options.i18n.notEnabled }}</span> + <template v-if="hasEnabledStatus"> + <template v-if="enabled"> + <span> + <gl-icon name="check-circle-filled" /> + <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> + </span> + </template> + + <template v-else-if="available"> + <span>{{ $options.i18n.notEnabled }}</span> + </template> + + <template v-else> + {{ $options.i18n.availableWith }} + </template> </template> - <template v-else> - {{ $options.i18n.availableWith }} + <template v-else-if="!available"> + <span>{{ $options.i18n.availableWith }}</span> </template> </div> </div> @@ -186,6 +204,16 @@ export default { > {{ feature.secondary.configurationText }} </gl-button> + + <gl-button + v-else-if="showSecondaryConfigurationHelpPath" + icon="external-link" + :href="feature.secondary.configurationHelpPath" + category="secondary" + class="gl-mt-5" + > + {{ $options.i18n.configurationGuide }} + </gl-button> </div> </gl-card> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 8b40b48b54a..c61c02c8b3a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,7 +1,7 @@ <script> -import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; -import { s__, sprintf } from '~/locale'; +import { __ } from '~/locale'; const AVAILABILITY_STATUS = { NOT_SET: 'NOT_SET', @@ -11,6 +11,7 @@ const AVAILABILITY_STATUS = { export default { components: { GlAvatarLabeled, + GlBadge, GlIcon, }, props: { @@ -25,30 +26,23 @@ export default { }, }, computed: { - userLabel() { - const { name, status } = this.user; - if (!status || status?.availability !== AVAILABILITY_STATUS.BUSY) { - return name; - } - return sprintf( - s__('UserAvailability|%{author} (Busy)'), - { - author: name, - }, - false, - ); + isBusy() { + return this.user?.status?.availability === AVAILABILITY_STATUS.BUSY; }, hasCannotMergeIcon() { return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge; }, }, + i18n: { + busy: __('Busy'), + }, }; </script> <template> <gl-avatar-labeled :size="32" - :label="userLabel" + :label="user.name" :sub-label="`@${user.username}`" :src="user.avatarUrl || user.avatar || user.avatar_url" class="gl-align-items-center gl-relative sidebar-participant" @@ -61,6 +55,9 @@ export default { class="merge-icon" :size="12" /> + <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2"> + {{ $options.i18n.busy }} + </gl-badge> </template> </gl-avatar-labeled> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue index bed84dc5706..72084fdafb1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue +++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue @@ -1,10 +1,11 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlBadge, GlSprintf } from '@gitlab/ui'; import { isUserBusy } from '~/set_status_modal/utils'; export default { name: 'UserNameWithStatus', components: { + GlBadge, GlSprintf, }, props: { @@ -40,17 +41,17 @@ export default { </script> <template> <span :class="containerClasses"> - <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')"> + <gl-sprintf :message="s__('UserAvailability|%{author}%{badgeStart}Busy%{badgeEnd}')"> <template #author - >{{ name }} - <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal" + ><span>{{ name }}</span + ><span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-ml-1" >({{ pronouns }})</span ></template > - <template #span="{ content }" - ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{ - content - }}</span> + <template #badge="{ content }"> + <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2"> + {{ content }} + </gl-badge> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 190b8c1de62..5a9545f3460 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -106,6 +106,19 @@ export default { ), }); }, + subscribeToMore: { + document() { + return this.dateQueries[this.issuableType].subscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return this.skipIssueDueDateSubscription; + }, + }, }, }, computed: { @@ -163,6 +176,12 @@ export default { dataTestId() { return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date'; }, + issuableId() { + return this.issuable.id; + }, + skipIssueDueDateSubscription() { + return this.issuableType !== TYPE_ISSUE || !this.issuableId || this.isLoading; + }, }, methods: { epicDatePopoverEl() { @@ -302,6 +321,7 @@ export default { v-if="!isLoading" ref="datePicker" class="gl-relative" + :value="parsedDate" :min-date="minDate" :max-date="maxDate" :default-date="parsedDate" 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 227d85d952b..8535398decf 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 @@ -78,6 +78,7 @@ export default { v-model.trim="labelTitle" :placeholder="__('Name new label')" :autofocus="true" + data-testid="label-title" /> </div> <div class="dropdown-content px-2"> @@ -113,6 +114,7 @@ export default { category="primary" variant="confirm" class="float-left d-flex align-items-center" + data-testid="create-click" @click="handleCreateClick" > <gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" /> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js index 852ef0c6283..881d84a7d6e 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js @@ -1,5 +1,6 @@ export const SCOPED_LABEL_DELIMITER = '::'; export const DEBOUNCE_DROPDOWN_DELAY = 200; +export const DEFAULT_LABEL_COLOR = '#6699cc'; export const DropdownVariant = { Sidebar: 'sidebar', diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index 1174ec3f01e..30eeb0fbe31 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -13,6 +13,7 @@ import { WORKSPACE_GROUP } from '~/issues/constants'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '../../../constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; +import { DEFAULT_LABEL_COLOR } from './constants'; const errorMessage = __('Error creating label.'); @@ -44,11 +45,16 @@ export default { type: String, required: true, }, + searchKey: { + type: String, + required: false, + default: '', + }, }, data() { return { - labelTitle: '', - selectedColor: '', + labelTitle: this.searchKey, + selectedColor: DEFAULT_LABEL_COLOR, labelCreateInProgress: false, error: undefined, }; diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue index 76c47305369..581537264db 100644 --- a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue +++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue @@ -26,10 +26,11 @@ export default { }, }, methods: { - moveIssue(targetProject) { + async moveIssue(targetProject) { this.moveInProgress = true; - return this.$apollo - .mutate({ + + try { + const { data } = await this.$apollo.mutate({ mutation: moveIssueMutation, variables: { moveIssueInput: { @@ -38,24 +39,25 @@ export default { targetProjectPath: targetProject.full_path, }, }, - }) - .then(({ data = {} }) => { - if (!data.issueMove) return; + }); + + if (!data.issueMove) return; + + const { errors } = data.issueMove; + if (errors?.length > 0) { + throw new Error(`Error moving the issue. Error message: ${errors[0].message}`); + } - const { errors } = data.issueMove; - if (errors?.length > 0) { - throw new Error(`Error moving the issue. Error message: ${errors[0].message}`); - } - visitUrl(data.issueMove?.issue.webUrl); - }) - .catch((error) => { - this.moveInProgress = false; - createAlert({ - message: this.$options.i18n.moveErrorMessage, - captureError: true, - error, - }); + visitUrl(data.issueMove?.issue.webUrl); + } catch (error) { + createAlert({ + message: this.$options.i18n.moveErrorMessage, + captureError: true, + error, }); + } finally { + this.moveInProgress = false; + } }, }, }; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index a9d102eb303..bbd3cda0ad3 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -69,7 +69,7 @@ export default { }, participantLabel() { return sprintf( - n__('%{count} participant', '%{count} participants', this.participants.length), + n__('%{count} Participant', '%{count} Participants', this.participants.length), { count: this.loading ? '' : this.participantCount }, ); }, diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index a3710d9534e..99f9d5e872c 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -6,6 +6,7 @@ import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; const SUCCESS_STATE = 'success'; +const JUST_APPROVED = 'approved'; export default { i18n: { @@ -42,7 +43,7 @@ export default { }, watch: { users: { - handler(users) { + handler(users, previousUsers) { this.loadingStates = users.reduce( (acc, user) => ({ ...acc, @@ -50,14 +51,41 @@ export default { }), this.loadingStates, ); + if (previousUsers) { + users.forEach((user) => { + const userPreviousState = previousUsers.find(({ id }) => id === user.id); + if ( + userPreviousState && + user.mergeRequestInteraction.approved && + !userPreviousState.mergeRequestInteraction.approved + ) { + this.showApprovalAnimation(user.id); + } + }); + } }, immediate: true, }, }, methods: { + showApprovalAnimation(userId) { + this.loadingStates[userId] = JUST_APPROVED; + + setTimeout(() => { + this.loadingStates[userId] = null; + }, 1500); + }, + approveAnimation(userId) { + return { + 'merge-request-approved-icon': this.loadingStates[userId] === JUST_APPROVED, + }; + }, approvedByTooltipTitle(user) { return sprintf(s__('MergeRequest|Approved by @%{username}'), user); }, + reviewedButNotApprovedTooltip(user) { + return sprintf(s__('MergeRequest|Reviewed by @%{username} but not yet approved'), user); + }, toggleShowLess() { this.showLess = !this.showLess; }, @@ -105,35 +133,38 @@ export default { {{ user.name }} </div> </reviewer-avatar-link> - <gl-icon - v-if="user.mergeRequestInteraction.approved" - v-gl-tooltip.left - :size="16" - :title="approvedByTooltipTitle(user)" - name="status-success" - class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" - data-testid="re-approved" - /> - <gl-icon - v-if="loadingStates[user.id] === $options.SUCCESS_STATE" - :size="24" - name="check" - class="float-right gl-py-2 gl-mr-2 gl-text-green-500" - data-testid="re-request-success" - /> <gl-button - v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed" + v-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed" v-gl-tooltip.left :title="$options.i18n.reRequestReview" :aria-label="$options.i18n.reRequestReview" :loading="loadingStates[user.id] === $options.LOADING_STATE" - class="float-right gl-text-gray-500!" + class="float-right gl-text-gray-500! gl-mr-2" size="small" icon="redo" variant="link" data-testid="re-request-button" @click="reRequestReview(user.id)" /> + <gl-icon + v-if="user.mergeRequestInteraction.approved" + v-gl-tooltip.left + :size="16" + :title="approvedByTooltipTitle(user)" + name="status-success" + class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" + :class="approveAnimation(user.id)" + data-testid="approved" + /> + <gl-icon + v-else-if="user.mergeRequestInteraction.reviewed" + v-gl-tooltip.left + :size="16" + :title="reviewedButNotApprovedTooltip(user)" + name="dotted-circle" + class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0" + data-testid="reviewed-not-approved" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 19e72da65f2..4721c6fee61 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -81,7 +81,7 @@ export default { }, }, apollo: { - currentAttribute: { + issuable: { query() { const { current } = this.issuableAttributeQuery; const { query } = current[this.issuableType]; @@ -95,11 +95,12 @@ export default { }; }, update(data) { + return data.workspace?.issuable || {}; + }, + result({ data }) { if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; } - - return data?.workspace?.issuable.attribute; }, error(error) { createAlert({ @@ -108,13 +109,26 @@ export default { error, }); }, + subscribeToMore: { + document() { + return issuableAttributesQueries[this.issuableAttribute].subscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return this.shouldSkipRealTimeEpicLinkUpdates; + }, + }, }, }, data() { return { updating: false, selectedTitle: null, - currentAttribute: null, + issuable: {}, hasCurrentAttribute: false, editConfirmation: false, tracking: { @@ -125,6 +139,12 @@ export default { }; }, computed: { + currentAttribute() { + return this.issuable.attribute; + }, + issuableId() { + return this.issuable.id; + }, issuableAttributeQuery() { return this.issuableAttributesQueries[this.issuableAttribute]; }, @@ -135,7 +155,7 @@ export default { return this.currentAttribute?.webUrl; }, loading() { - return this.$apollo.queries.currentAttribute.loading; + return this.$apollo.queries.issuable.loading; }, attributeTypeTitle() { return this.widgetTitleText[this.issuableAttribute]; @@ -170,6 +190,9 @@ export default { ? !this.editConfirmation : false; }, + shouldSkipRealTimeEpicLinkUpdates() { + return !this.issuableId || this.issuableAttribute !== IssuableAttributeType.Epic; + }, }, methods: { updateAttribute({ id }) { diff --git a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue index 6dacf4e10d3..ba0bf783315 100644 --- a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue @@ -32,9 +32,22 @@ export default { return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }]; }, }, + watch: { + collapsed(value) { + this.updateLayout(value); + }, + }, + mounted() { + this.page = document.querySelector('.layout-page'); + }, methods: { toggle() { this.$emit('toggle'); + this.updateLayout(); + }, + updateLayout(collapsed) { + this.page?.classList.remove(collapsed ? 'right-sidebar-expanded' : 'right-sidebar-collapsed'); + this.page?.classList.add(collapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'); }, }, }; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 7bca83c4142..0f82182c6e2 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -13,6 +13,7 @@ import { WORKSPACE_PROJECT, } from '~/issues/constants'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import issuableDatesUpdatedSubscription from '../graphql_shared/subscriptions/work_item_dates.subscription.graphql'; import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; @@ -218,6 +219,7 @@ export const dueDateQueries = { [TYPE_ISSUE]: { query: issueDueDateQuery, mutation: updateIssueDueDateMutation, + subscription: issuableDatesUpdatedSubscription, }, [TYPE_EPIC]: { query: epicDueDateQuery, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 99c3fdf82d4..2828b9fbf1a 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -148,7 +148,7 @@ function mountSidebarAssigneesWidget() { name: 'SidebarAssigneesRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), directlyInviteMembers: Object.prototype.hasOwnProperty.call( el.dataset, 'directlyInviteMembers', @@ -162,7 +162,7 @@ function mountSidebarAssigneesWidget() { issuableType, issuableId: id, allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1, - editable, + editable: parseBoolean(editable), }, scopedSlots: { collapsed: ({ users }) => @@ -415,7 +415,7 @@ function mountSidebarDueDateWidget() { name: 'SidebarDueDateWidgetRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), }, render: (createElement) => createElement(SidebarDueDateWidget, { @@ -476,7 +476,7 @@ function mountIssuableLockForm(store) { render: (createElement) => createElement(IssuableLockForm, { props: { - isEditable: editable, + isEditable: parseBoolean(editable), }, }), }); @@ -523,7 +523,7 @@ function mountSidebarSubscriptionsWidget() { name: 'SidebarSubscriptionsWidgetRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), }, render: (createElement) => createElement(SidebarSubscriptionsWidget, { @@ -587,7 +587,7 @@ function mountSidebarSeverityWidget() { name: 'SidebarSeverityWidgetRoot', apolloProvider, provide: { - canUpdate: editable, + canUpdate: parseBoolean(editable), }, render: (createElement) => createElement(SidebarSeverityWidget, { @@ -645,7 +645,7 @@ function mountCopyEmailToClipboard() { }); } -export function mountMoveIssuesButton() { +export async function mountMoveIssuesButton() { const el = document.querySelector('.js-move-issues'); if (!el) { @@ -658,7 +658,7 @@ export function mountMoveIssuesButton() { el, name: 'MoveIssuesRoot', apolloProvider: new VueApollo({ - defaultClient: gqlClient, + defaultClient: await gqlClient(), }), render: (createElement) => createElement(MoveIssuesButton, { diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index baf906bb96c..ea3b3633ea7 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -1,3 +1,5 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + export default class SidebarStore { constructor(options) { if (!SidebarStore.singleton) { @@ -12,7 +14,7 @@ export default class SidebarStore { const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options; this.currentUser = currentUser; this.rootPath = rootPath; - this.editable = editable; + this.editable = parseBoolean(editable); this.timeEstimate = 0; this.totalTimeSpent = 0; this.humanTimeEstimate = ''; diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js index 6b90fb80abf..a61b4e4f066 100644 --- a/app/assets/javascripts/sidebar/utils.js +++ b/app/assets/javascripts/sidebar/utils.js @@ -12,7 +12,7 @@ export const updateGlobalTodoCount = (additionalTodoCount) => { if (countContainer === null) return; - const currentCount = parseInt(countContainer.innerText, 10); + const currentCount = parseInt(countContainer.innerText, 10) || 0; const todoToggleEvent = new CustomEvent('todo:toggle', { detail: { diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index b613e356a7a..bab167bb7e4 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - import $ from 'jquery'; import { createAlert } from '~/alert'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -66,7 +64,7 @@ export default class SingleFileDiff { } else { this.$chevronDownIcon.removeClass('gl-display-none'); this.$chevronRightIcon.addClass('gl-display-none'); - return this.getContentHTML(cb); + return this.getContentHTML(cb); // eslint-disable-line consistent-return } } diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index e6aa3be0371..24dd978585c 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -42,7 +42,7 @@ export default { <label> {{ __('Visibility level') }} <gl-link v-if="helpLink" :href="helpLink" target="_blank" - ><gl-icon :size="12" name="question" + ><gl-icon :size="12" name="question-o" /></gl-link> </label> <gl-form-group id="visibility-level-setting" class="gl-mb-0"> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue index b3d4ecdda47..fa9da6cef9d 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -1,10 +1,9 @@ <script> import * as Sentry from '@sentry/browser'; -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { s__ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql'; -import { contextSwitcherItems } from '../mock_data'; import { trackContextAccess, formatContextSwitcherItems } from '../utils'; import NavItem from './nav_item.vue'; import ProjectsList from './projects_list.vue'; @@ -14,12 +13,13 @@ export default { i18n: { contextNavigation: s__('Navigation|Context navigation'), switchTo: s__('Navigation|Switch to...'), - searchPlaceholder: s__('Navigation|Search for projects or groups'), + searchPlaceholder: s__('Navigation|Search your projects or groups'), + searchingLabel: s__('Navigation|Retrieving search results'), + searchError: s__('Navigation|There was an error fetching search results.'), }, apollo: { groupsAndProjects: { query: searchUserProjectsAndGroups, - debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, manual: true, variables() { return { @@ -28,6 +28,7 @@ export default { }; }, result(response) { + this.hasError = false; try { const { data: { @@ -41,11 +42,11 @@ export default { this.projects = formatContextSwitcherItems(projects); this.groups = formatContextSwitcherItems(groups); } catch (e) { - Sentry.captureException(e); + this.handleError(e); } }, error(e) { - Sentry.captureException(e); + this.handleError(e); }, skip() { return !this.searchString; @@ -54,11 +55,17 @@ export default { }, components: { GlSearchBoxByType, + GlLoadingIcon, + GlAlert, NavItem, ProjectsList, GroupsList, }, props: { + persistentLinks: { + type: Array, + required: true, + }, username: { type: String, required: true, @@ -82,19 +89,36 @@ export default { searchString: '', projects: [], groups: [], + hasError: false, }; }, computed: { isSearch() { return Boolean(this.searchString); }, + isSearching() { + return this.$apollo.queries.groupsAndProjects.loading; + }, }, - contextSwitcherItems, created() { if (this.currentContext.namespace) { trackContextAccess(this.username, this.currentContext); } }, + methods: { + /** + * This needs to be exposed publicly so that we can auto-focus the search input when the parent + * GlCollapse is shown. + */ + focusInput() { + this.$refs['search-box'].focusInput(); + }, + handleError(e) { + Sentry.captureException(e); + this.hasError = true; + }, + }, + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, }; </script> @@ -102,21 +126,36 @@ export default { <div> <div class="gl-p-1 gl-border-b gl-border-gray-50 gl-bg-white"> <gl-search-box-by-type + ref="search-box" v-model="searchString" class="context-switcher-search-box" :placeholder="$options.i18n.searchPlaceholder" + :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS" borderless /> </div> - <nav :aria-label="$options.i18n.contextNavigation"> + <gl-loading-icon + v-if="isSearching" + class="gl-mt-5" + size="md" + :label="$options.i18n.searchingLabel" + /> + <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2"> + {{ $options.i18n.searchError }} + </gl-alert> + <nav v-else :aria-label="$options.i18n.contextNavigation"> <ul class="gl-p-0 gl-list-style-none"> <li v-if="!isSearch"> <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> {{ $options.i18n.switchTo }} </div> <ul :aria-label="$options.i18n.switchTo" class="gl-p-0"> - <nav-item :item="$options.contextSwitcherItems.yourWork" /> - <nav-item :item="$options.contextSwitcherItems.explore" /> + <nav-item + v-for="item in persistentLinks" + :key="item.link" + :item="item" + :link-classes="{ [item.link_classes]: item.link_classes }" + /> </ul> </li> <projects-list 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 e0b6870872c..e56ef9e410b 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -38,13 +38,13 @@ export default { <button v-collapse-toggle.context-switcher type="button" - class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-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" + 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" > <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 :name="context.icon" :size="16" /> + <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" /> </span> <gl-avatar v-else @@ -55,11 +55,11 @@ export default { :src="context.avatar" class="gl-mr-3 gl-ml-4" /> - <div class="gl-overflow-auto"> + <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"> - <gl-icon :name="collapseIcon" /> + <gl-icon class="gl-text-gray-400" :name="collapseIcon" /> </span> </button> </template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index e79b609545e..98417b7cd25 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -7,7 +7,7 @@ export default { }, props: { count: { - type: Number, + type: [Number, String], required: true, }, href: { diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index d3bb31a69fa..4cff4642cf7 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -1,6 +1,10 @@ <script> import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; import { __ } from '~/locale'; +import { DROPDOWN_Y_OFFSET } from '../constants'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -147; export default { components: { @@ -16,7 +20,22 @@ export default { required: true, }, }, + data() { + return { + dropdownOpen: false, + }; + }, toggleId: 'create-menu-toggle', + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], + }, }; </script> @@ -30,9 +49,17 @@ export default { text-sr-only :toggle-text="$options.i18n.createNew" :toggle-id="$options.toggleId" + :popper-options="$options.popperOptions" data-qa-selector="new_menu_toggle" + @shown="dropdownOpen = true" + @hidden="dropdownOpen = false" /> - <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar"> + <gl-tooltip + v-if="!dropdownOpen" + :target="`#${$options.toggleId}`" + placement="bottom" + container="#super-sidebar" + > {{ $options.i18n.createNew }} </gl-tooltip> </div> 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 5269c7f8d5e..56143a29f52 100644 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue @@ -1,13 +1,20 @@ <script> +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import { s__ } from '~/locale'; import AccessorUtilities from '~/lib/utils/accessor'; import { getTopFrequentItems, formatContextSwitcherItems } from '../utils'; import ItemsList from './items_list.vue'; export default { components: { + GlIcon, + GlButton, ItemsList, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { title: { type: String, @@ -29,12 +36,16 @@ export default { data() { return { cachedFrequentItems: [], + isItemsListEditable: false, }; }, computed: { isEmpty() { return !this.cachedFrequentItems.length; }, + allowItemsEditing() { + return !this.isEmpty && AccessorUtilities.canUseLocalStorage(); + }, }, created() { this.getItemsFromLocalStorage(); @@ -52,6 +63,27 @@ export default { Sentry.captureException(e); } }, + toggleItemsListEditablity() { + this.isItemsListEditable = !this.isItemsListEditable; + }, + 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)), + ); + + // Update the list + this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); + } catch (e) { + Sentry.captureException(e); + } + }, + }, + i18n: { + frequentItemsEditToggle: s__('Navigation|Toggle edit mode'), }, }; </script> @@ -61,14 +93,32 @@ export default { <div data-testid="list-title" aria-hidden="true" - class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" + class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" > - {{ title }} + <span class="gl-flex-grow-1">{{ title }}</span> + <gl-button + v-if="allowItemsEditing" + v-gl-tooltip.left + size="small" + category="tertiary" + :aria-label="$options.i18n.frequentItemsEditToggle" + :title="$options.i18n.frequentItemsEditToggle" + :class="{ 'gl-bg-gray-100!': isItemsListEditable }" + class="gl-p-2!" + @click="toggleItemsListEditablity" + > + <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" /> + </gl-button> </div> <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3"> {{ pristineText }} </div> - <items-list :aria-label="title" :items="cachedFrequentItems"> + <items-list + :aria-label="title" + :items="cachedFrequentItems" + :editable="isItemsListEditable" + @remove-item="handleItemRemove" + > <template #view-all-items> <slot name="view-all-items"></slot> </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 6798607b954..e8a54b0515e 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 @@ -6,73 +6,64 @@ import { GlToken, GlTooltipDirective, GlResizeObserverDirective, + GlModal, } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import { debounce } from 'lodash'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { debounce, clamp } from 'lodash'; import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { + MIN_SEARCH_TERM, SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, } from '~/vue_shared/global_search/constants'; import { - FIRST_DROPDOWN_INDEX, - SEARCH_BOX_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, IS_SEARCHING, - IS_FOCUSED, - IS_NOT_FOCUSED, + SEARCH_MODAL_ID, + SEARCH_INPUT_SELECTOR, + SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; -import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from './global_search_default_items.vue'; -import HeaderSearchScopedItems from './global_search_scoped_items.vue'; +import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from './global_search_default_items.vue'; +import GlobalSearchScopedItems from './global_search_scoped_items.vue'; export default { - name: 'HeaderSearchApp', + name: 'GlobalSearchModal', + SEARCH_MODAL_ID, i18n: { SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, + MIN_SEARCH_TERM, }, directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, - HeaderSearchDefaultItems, - HeaderSearchScopedItems, - HeaderSearchAutocompleteItems, - DropdownKeyboardNavigation, + GlobalSearchDefaultItems, + GlobalSearchScopedItems, + GlobalSearchAutocompleteItems, GlIcon, GlToken, - }, - data() { - return { - showDropdown: false, - isFocused: false, - currentFocusIndex: SEARCH_BOX_INDEX, - }; + GlModal, }, computed: { ...mapState(['search', 'loading', 'searchContext']), - ...mapGetters(['searchQuery', 'searchOptions']), + ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), searchText: { get() { return this.search; @@ -81,51 +72,26 @@ export default { this.setSearch(value); }, }, - currentFocusedOption() { - return this.searchOptions[this.currentFocusIndex]; - }, - currentFocusedId() { - return this.currentFocusedOption?.html_id; - }, - isLoggedIn() { - return Boolean(gon?.current_username); - }, - showSearchDropdown() { - if (!this.showDropdown || !this.isLoggedIn) { - return false; - } - return this.searchOptions?.length > 0; - }, showDefaultItems() { return !this.searchText; }, searchTermOverMin() { return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, - defaultIndex() { - if (this.showDefaultItems) { - return SEARCH_BOX_INDEX; - } - return FIRST_DROPDOWN_INDEX; - }, - - searchInputDescribeBy() { - if (this.isLoggedIn) { - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; - } - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + showScopedSearchItems() { + return this.searchTermOverMin && this.scopedSearchOptions.length > 1; }, - dropdownResultsDescription() { - if (!this.showSearchDropdown) { - return ''; // This allows aria-live to see register an update when the dropdown is shown - } - + searchResultsDescription() { if (this.showDefaultItems) { return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { count: this.searchOptions.length, }); } + if (!this.searchTermOverMin) { + return this.$options.i18n.MIN_SEARCH_TERM; + } + return this.loading ? this.$options.i18n.SEARCH_RESULTS_LOADING : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { @@ -135,12 +101,10 @@ export default { searchBarClasses() { return { [IS_SEARCHING]: this.searchTermOverMin, - [IS_FOCUSED]: this.isFocused, - [IS_NOT_FOCUSED]: !this.isFocused, }; }, showScopeHelp() { - return this.searchTermOverMin && this.isFocused; + return this.searchTermOverMin; }, searchBarItem() { return this.searchOptions?.[0]; @@ -159,47 +123,7 @@ export default { }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), - openDropdown() { - this.showDropdown = true; - - // check isFocused state to avoid firing duplicate events - if (!this.isFocused) { - this.isFocused = true; - this.$emit('expandSearchBar', true); - - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - } - }, - closeDropdown() { - this.showDropdown = false; - }, - collapseAndCloseSearchBar() { - // we need a delay on this method - // for the search bar not to remove - // the clear button from dom - // and register clicks on dropdown items - setTimeout(() => { - this.showDropdown = false; - this.isFocused = false; - this.$emit('collapseSearchBar'); - - Tracking.event(undefined, 'blur_input', { - label: 'global_search', - property: 'navigation_top', - }); - }, 200); - }, - submitSearch() { - if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { - return null; - } - return visitUrl(this.currentFocusedOption?.url || this.searchQuery); - }, getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { - this.openDropdown(); if (!searchTerm) { this.clearAutocomplete(); } else { @@ -216,105 +140,174 @@ export default { } inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; }, + getFocusableOptions() { + return Array.from( + this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [], + ); + }, + onKeydown(event) { + const { code, target } = event; + + let stop = true; + + const elements = this.getFocusableOptions(); + if (elements.length < 1) return; + + const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); + + if (code === HOME_KEY) { + this.focusItem(0, elements); + } else if (code === END_KEY) { + this.focusItem(elements.length - 1, elements); + } else if (code === ARROW_UP_KEY) { + if (isSearchInput) return; + + if (elements.indexOf(target) === 0) { + this.focusSearchInput(); + return; + } + this.focusNextItem(event, elements, -1); + } else if (code === ARROW_DOWN_KEY) { + this.focusNextItem(event, elements, 1); + } else if (code === ESC_KEY) { + this.$refs.searchModal.close(); + } else { + stop = false; + } + + if (stop) { + event.preventDefault(); + } + }, + focusSearchInput() { + this.$refs.searchInputBox.$el.querySelector('input').focus(); + }, + focusNextItem(event, elements, offset) { + const { target } = event; + const currentIndex = elements.indexOf(target); + const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1); + + this.focusItem(nextIndex, elements); + }, + focusItem(index, elements) { + this.nextFocusedItemIndex = index; + + elements[index]?.focus(); + }, + submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return; + } + visitUrl(this.searchQuery); + }, }, - SEARCH_BOX_INDEX, - FIRST_DROPDOWN_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <form - v-outside="closeDropdown" - role="search" - :aria-label="$options.i18n.SEARCH_GITLAB" - class="header-search gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" - data-testid="header-search-form" + <gl-modal + ref="searchModal" + :modal-id="$options.SEARCH_MODAL_ID" + hide-header + hide-footer + hide-header-close + scrollable + body-class="gl-p-0!" + modal-class="global-search-modal" + :centered="false" > - <gl-search-box-by-type - id="search" - ref="searchInputBox" - v-model="searchText" - role="searchbox" - class="gl-z-index-1" - data-qa-selector="search_term_field" - autocomplete="off" - :placeholder="$options.i18n.SEARCH_GITLAB" - :aria-activedescendant="currentFocusedId" - :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focus="openDropdown" - @click="openDropdown" - @blur="collapseAndCloseSearchBar" - @input="getAutocompleteOptions" - @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="closeDropdown" - /> - <gl-token - v-if="showScopeHelp" - v-gl-resize-observer-directive="observeTokenWidth" - class="in-search-scope-help" - :view-only="true" - :title="scopeTokenTitle" - ><gl-icon - v-if="infieldHelpIcon" - class="gl-mr-2" - :aria-label="infieldHelpContent" - :name="infieldHelpIcon" - :size="16" - />{{ - getTruncatedScope( - sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { - scope: infieldHelpContent, - }), - ) - }} - </gl-token> - <kbd - v-show="!isFocused" - v-gl-tooltip.bottom.hover.html - class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" - :title="$options.i18n.KBD_HELP" - >/</kbd - > - <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ - searchInputDescribeBy - }}</span> - <span - role="region" - :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" - class="gl-sr-only" - aria-live="polite" - aria-atomic="true" + <form + role="search" + :aria-label="$options.i18n.SEARCH_GITLAB" + class="gl-relative gl-rounded-base gl-w-full" + :class="searchBarClasses" + data-testid="global-search-form" > - {{ dropdownResultsDescription }} - </span> - <div - v-if="showSearchDropdown" - data-testid="header-search-dropdown-menu" - class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3" - > - <div class="header-search-dropdown-content gl-py-2"> - <dropdown-keyboard-navigation - v-model="currentFocusIndex" - :max="searchOptions.length - 1" - :min="$options.FIRST_DROPDOWN_INDEX" - :default-index="defaultIndex" - @tab="closeDropdown" - /> - <header-search-default-items - v-if="showDefaultItems" - :current-focused-option="currentFocusedOption" + <div class="gl-p-1"> + <gl-search-box-by-type + id="search" + ref="searchInputBox" + v-model="searchText" + role="searchbox" + data-testid="global-search-input" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_GITLAB" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + borderless + @input="getAutocompleteOptions" + @keydown.enter.stop.prevent="submitSearch" + @keydown="onKeydown" /> - <template v-else> - <header-search-scoped-items - v-if="searchTermOverMin" - :current-focused-option="currentFocusedOption" + <gl-token + v-if="showScopeHelp" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help gl-sm-display-block gl-display-none" + view-only + :title="scopeTokenTitle" + > + <gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" /> - <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> + {{ + getTruncatedScope( + sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), + ) + }} + </gl-token> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> + {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} + </span> + </div> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ searchResultsDescription }} + </span> + <div + ref="resultsList" + data-testid="global-search-results" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + @keydown="onKeydown" + > + <global-search-default-items v-if="showDefaultItems" /> + <template v-else> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> </template> </div> - </div> - </form> + + <template v-if="searchContext"> + <input + v-if="searchContext.group" + type="hidden" + name="group_id" + :value="searchContext.group.id" + /> + <input + v-if="searchContext.project" + type="hidden" + name="project_id" + :value="searchContext.project.id" + /> + + <template v-if="searchContext.group || searchContext.project"> + <input type="hidden" name="scope" :value="searchContext.scope" /> + <input type="hidden" name="search_code" :value="searchContext.code_search" /> + </template> + + <input type="hidden" name="snippets" :value="searchContext.for_snippets" /> + <input type="hidden" name="repository_ref" :value="searchContext.ref" /> + </template> + </form> + </gl-modal> </template> 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 1838214def6..cd623200b03 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,113 +1,36 @@ <script> -import { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlAvatar, - GlAlert, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { - GROUPS_CATEGORY, - PROJECTS_CATEGORY, - MERGE_REQUEST_CATEGORY, - ISSUES_CATEGORY, - RECENT_EPICS_CATEGORY, - AUTOCOMPLETE_ERROR_MESSAGE, -} from '~/vue_shared/global_search/constants'; -import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; +import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchAutocompleteItems', + name: 'GlobalSearchAutocompleteItems', i18n: { AUTOCOMPLETE_ERROR_MESSAGE, }, components: { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, GlAvatar, GlAlert, GlLoadingIcon, + GlDisclosureDropdownGroup, }, directives: { SafeHtml, }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, computed: { - ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']), - ...mapGetters(['autocompleteGroupedSearchOptions']), - }, - watch: { - currentFocusedOption() { - const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; - - if (focusedElement) { - focusedElement.scrollIntoView(false); - } + ...mapState(['search', 'loading', 'autocompleteError']), + ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), + isPrecededByScopedOptions() { + return this.scopedSearchOptions.length > 1; }, }, methods: { - truncateNamespace(string) { - if (string.split(' / ').length > 2) { - return truncateNamespace(string); - } - - return string; - }, highlightedName(val) { return highlight(val, this.search); }, - avatarSize(data) { - if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) { - return LARGE_AVATAR_PX; - } - - return SMALL_AVATAR_PX; - }, - isOptionFocused(data) { - return this.currentFocusedOption?.html_id === data.html_id; - }, - isProjectsCategory(data) { - return data.category === PROJECTS_CATEGORY; - }, - getEntityId(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_id || data.id || this.searchContext?.group?.id; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_id || data.id || this.searchContext?.project?.id; - default: - return data.id; - } - }, - getEntitytName(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_name || data.value || data.label || this.searchContext?.group?.name; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_name || data.value || data.label || this.searchContext?.project?.name; - default: - return data.label; - } - }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -115,46 +38,46 @@ export default { <template> <div> - <template v-if="!loading"> - <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category"> - <gl-dropdown-divider v-if="index > 0" /> - <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="data in option.data" - :id="data.html_id" - :ref="data.html_id" - :key="data.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(data) }" - :aria-selected="isOptionFocused(data)" - :aria-label="data.label" - tabindex="-1" - :href="data.url" - > - <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> + <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> + <gl-disclosure-dropdown-group + v-for="group in autocompleteGroupedSearchOptions" + :key="group.name" + :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :group="group" + bordered + > + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> <gl-avatar - v-if="data.avatar_url !== undefined" - :src="data.avatar_url" - :entity-id="getEntityId(data)" - :entity-name="getEntitytName(data)" - :size="avatarSize(data)" + v-if="item.avatar_url !== undefined" + class="gl-mr-3" + :src="item.avatar_url" + :entity-id="item.entity_id" + :entity-name="item.entity_name" + :size="item.avatar_size" :shape="$options.AVATAR_SHAPE_OPTION_RECT" + aria-hidden="true" /> <span class="gl-display-flex gl-flex-direction-column"> <span - v-safe-html="highlightedName(data.value || data.label)" + v-safe-html="highlightedName(item.text)" class="gl-text-gray-900" + data-testid="autocomplete-item-name" ></span> <span - v-if="data.value" - v-safe-html="truncateNamespace(data.label)" + v-if="item.value" + v-safe-html="item.namespace" class="gl-font-sm gl-text-gray-500" + data-testid="autocomplete-item-namespace" ></span> </span> </div> - </gl-dropdown-item> - </div> - </template> + </template> + </gl-disclosure-dropdown-group> + </ul> + <gl-loading-icon v-else size="lg" class="my-4" /> + <gl-alert v-if="autocompleteError" class="gl-text-body gl-mt-2" 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 f0d398297e9..239c61fd750 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,23 +1,15 @@ <script> -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchDefaultItems', + name: 'GlobalSearchDefaultItems', i18n: { ALL_GITLAB, }, components: { - GlDropdownSectionHeader, - GlDropdownItem, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['searchContext']), @@ -29,30 +21,18 @@ export default { this.$options.i18n.ALL_GITLAB ); }, - }, - methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; + defaultItemsGroup() { + return { + name: this.sectionHeader, + items: this.defaultSearchOptions, + }; }, }, }; </script> <template> - <div> - <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="option in defaultSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="option.title" - tabindex="-1" - :href="option.url" - > - <span aria-hidden="true">{{ option.title }}</span> - </gl-dropdown-item> - </div> + <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" /> + </ul> </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 1ef88492b23..76600f829f6 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,47 +1,26 @@ <script> -import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; +import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; -import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants'; import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; export default { - name: 'HeaderSearchScopedItems', - i18n: { - SCOPED_SEARCH_ITEM_ARIA_LABEL, - }, + name: 'GlobalSearchScopedItems', components: { - GlDropdownItem, GlIcon, GlToken, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['search']), - ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']), + ...mapGetters(['scopedSearchGroup']), }, methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; - }, - ariaLabel(option) { - return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, { - search: this.search, - description: option.description || option.icon, - scope: option.scope || '', - }); - }, - titleLabel(option) { + titleLabel(item) { return sprintf(s__('GlobalSearch|in %{scope}'), { search: this.search, - scope: option.scope || option.description, + scope: item.scope || item.description, }); }, getTruncatedScope(scope) { @@ -53,35 +32,23 @@ export default { <template> <div> - <gl-dropdown-item - v-for="option in scopedSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - class="gl-max-w-full" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="ariaLabel(option)" - tabindex="-1" - :href="option.url" - :title="titleLabel(option)" - > - <span - ref="token-text-content" - class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full" - > - <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" /> - <span class="gl-flex-grow-1 gl-relative"> - <gl-token - class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!" - :view-only="true" + <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!"> + <template #list-item="{ item }"> + <span + class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full" > - <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" /> - <span>{{ getTruncatedScope(titleLabel(option)) }}</span> - </gl-token> - {{ search }} - </span> - </span> - </gl-dropdown-item> + <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" /> + <span class="gl-flex-grow-1"> + <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(item)) }}</span> + </gl-token> + {{ search }} + </span> + </span> + </template> + </gl-disclosure-dropdown-group> + </ul> </div> </template> 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 b9bb4e573fd..cb267df6122 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -8,10 +8,6 @@ export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; -export const FIRST_DROPDOWN_INDEX = 0; - -export const SEARCH_BOX_INDEX = -1; - export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; @@ -20,14 +16,13 @@ export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; export const SCOPE_TOKEN_MAX_LENGTH = 36; -export const INPUT_FIELD_PADDING = 52; - -export const HEADER_INIT_EVENTS = ['input', 'focus']; +export const INPUT_FIELD_PADDING = 84; export const IS_SEARCHING = 'is-searching'; -export const IS_FOCUSED = 'is-focused'; -export const IS_NOT_FOCUSED = 'is-not-focused'; 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_FIELD_MAX_WIDTH = '640px'; +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 f86463b94d1..4a42f416206 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,5 @@ import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; - import { MSG_ISSUES_ASSIGNED_TO_ME, MSG_ISSUES_IVE_CREATED, @@ -10,8 +9,10 @@ import { MSG_IN_ALL_GITLAB, PROJECTS_CATEGORY, GROUPS_CATEGORY, - DROPDOWN_ORDER, + SEARCH_RESULTS_ORDER, } from '~/vue_shared/global_search/constants'; +import { getFormattedItem } from '../utils'; + import { ICON_GROUP, ICON_SUBGROUP, @@ -62,32 +63,27 @@ export const defaultSearchOptions = (state, getters) => { const issues = [ { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${getters.scopedIssuesPath}/?author_username=${userName}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, ]; const mergeRequests = [ { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${getters.scopedMRPath}/?assignee_username=${userName}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, + text: MSG_MR_IM_REVIEWER, + href: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${getters.scopedMRPath}/?author_username=${userName}`, + text: MSG_MR_IVE_CREATED, + href: `${getters.scopedMRPath}/?author_username=${userName}`, }, ]; return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests]; @@ -145,58 +141,64 @@ export const allUrl = (state) => { }; export const scopedSearchOptions = (state, getters) => { - const options = []; + const items = []; if (state.searchContext?.project) { - options.push({ - html_id: 'scoped-in-project', + items.push({ + text: 'scoped-in-project', scope: state.searchContext.project?.name || '', scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: getters.projectUrl, + href: getters.projectUrl, }); } if (state.searchContext?.group) { - options.push({ - html_id: 'scoped-in-group', + items.push({ + text: 'scoped-in-group', scope: state.searchContext.group?.name || '', scopeCategory: GROUPS_CATEGORY, icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, - url: getters.groupUrl, + href: getters.groupUrl, }); } - options.push({ - html_id: 'scoped-in-all', + items.push({ + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: getters.allUrl, + href: getters.allUrl, }); - return options; + return items; +}; + +export const scopedSearchGroup = (state, getters) => { + const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : []; + return { items }; }; export const autocompleteGroupedSearchOptions = (state) => { const groupedOptions = {}; const results = []; - state.autocompleteOptions.forEach((option) => { - const category = groupedOptions[option.category]; + state.autocompleteOptions.forEach((item) => { + const group = groupedOptions[item.category]; + const formattedItem = getFormattedItem(item, state.searchContext); - if (category) { - category.data.push(option); + if (group) { + group.items.push(formattedItem); } else { - groupedOptions[option.category] = { - category: option.category, - data: [option], + groupedOptions[item.category] = { + name: formattedItem.category, + items: [formattedItem], }; - results.push(groupedOptions[option.category]); + results.push(groupedOptions[formattedItem.category]); } }); return results.sort( - (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category), + (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name), ); }; @@ -206,8 +208,8 @@ export const searchOptions = (state, getters) => { } const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( - (options, group) => { - return [...options, ...group.data]; + (items, group) => { + return [...items, ...group.items]; }, [], ); @@ -216,5 +218,5 @@ export const searchOptions = (state, getters) => { return sortedAutocompleteOptions; } - return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); + return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions); }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js index 6e65345757f..d7d9ebecd16 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js @@ -2,5 +2,4 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; - export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js index 19b4d4ec330..9936c3f59d8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js @@ -8,11 +8,7 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = [...state.autocompleteOptions].concat( - data.map((d, i) => { - return { html_id: `autocomplete-${d.category}-${i}`, ...d }; - }), - ); + state.autocompleteOptions = [...state.autocompleteOptions].concat(data); state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js new file mode 100644 index 00000000000..11d1fa1ab95 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -0,0 +1,81 @@ +import { pickBy } from 'lodash'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; +import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; + +const getTruncatedNamespace = (string) => { + if (string.split(' / ').length > 2) { + return truncateNamespace(string); + } + + return string; +}; +const getAvatarSize = (category) => { + if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) { + return LARGE_AVATAR_PX; + } + + return SMALL_AVATAR_PX; +}; + +const getEntityId = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_id || item.id || searchContext?.group?.id; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_id || item.id || searchContext?.project?.id; + default: + return item.id; + } +}; +const getEntityName = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_name || item.value || item.label || searchContext?.group?.name; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_name || item.value || item.label || searchContext?.project?.name; + default: + return item.label; + } +}; + +export const getFormattedItem = (item, searchContext) => { + const { id, category, value, label, url: href, avatar_url } = item; + let namespace; + const text = value || label; + if (value) { + namespace = getTruncatedNamespace(label); + } + const avatarSize = getAvatarSize(category); + const entityId = getEntityId(item, searchContext); + const entityName = getEntityName(item, searchContext); + + return pickBy( + { + id, + category, + value, + label, + text, + href, + avatar_url, + avatar_size: avatarSize, + namespace, + entity_id: entityId, + entity_name: entityName, + }, + (val) => val !== undefined, + ); +}; diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue index 78b5ed2d31e..4fa15f1cd76 100644 --- a/app/assets/javascripts/super_sidebar/components/groups_list.vue +++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue @@ -36,16 +36,19 @@ export default { storageKey() { return `${this.username}/frequent-groups`; }, - viewAllItem() { + viewAllProps() { return { - link: this.viewAllLink, - title: s__('Navigation|View all groups'), - icon: 'group', + item: { + link: this.viewAllLink, + title: s__('Navigation|View all your groups'), + icon: 'group', + }, + linkClasses: { 'dashboard-shortcuts-groups': true }, }; }, }, i18n: { - title: s__('Navigation|Frequent groups'), + title: s__('Navigation|Frequently visited groups'), searchTitle: s__('Navigation|Groups'), pristineText: s__('Navigation|Groups you visit often will appear here.'), noResultsText: s__('Navigation|No group matches found'), @@ -61,7 +64,7 @@ export default { :search-results="searchResults" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </search-results> <frequent-items-list @@ -72,7 +75,7 @@ export default { :pristine-text="$options.i18n.pristineText" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </frequent-items-list> </template> diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index fb23a4f2deb..01b214f4b2b 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -5,6 +5,11 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { __ } from '~/locale'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; +import Tracking from '~/tracking'; +import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS } from '../constants'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -4; export default { components: { @@ -14,6 +19,7 @@ export default { GlDisclosureDropdownGroup, GitlabVersionCheckBadge, }, + mixins: [Tracking.mixin({ property: 'nav_help_menu' })], i18n: { help: __('Help'), support: __('Support'), @@ -46,21 +52,63 @@ export default { text: this.$options.i18n.version, href: helpPagePath('update/index'), version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`, + extraAttrs: { + ...this.trackingAttrs('version_help_dropdown'), + }, }, ], }, helpLinks: { items: [ - { text: this.$options.i18n.help, href: helpPagePath() }, - { text: this.$options.i18n.support, href: this.sidebarData.support_path }, - { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' }, - { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` }, - { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' }, + { + text: this.$options.i18n.help, + href: helpPagePath(), + extraAttrs: { + ...this.trackingAttrs('help'), + }, + }, + { + text: this.$options.i18n.support, + href: this.sidebarData.support_path, + extraAttrs: { + ...this.trackingAttrs('support'), + }, + }, + { + text: this.$options.i18n.docs, + href: 'https://docs.gitlab.com', + extraAttrs: { + ...this.trackingAttrs('gitlab_documentation'), + }, + }, + { + text: this.$options.i18n.plans, + href: `${PROMO_URL}/pricing`, + extraAttrs: { + ...this.trackingAttrs('compare_gitlab_plans'), + }, + }, + { + text: this.$options.i18n.forum, + href: 'https://forum.gitlab.com/', + extraAttrs: { + ...this.trackingAttrs('community_forum'), + }, + }, { text: this.$options.i18n.contribute, href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + extraAttrs: { + ...this.trackingAttrs('contribute_to_gitlab'), + }, + }, + { + text: this.$options.i18n.feedback, + href: 'https://about.gitlab.com/submit-feedback', + extraAttrs: { + ...this.trackingAttrs('submit_feedback'), + }, }, - { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, ], }, helpActions: { @@ -70,6 +118,9 @@ export default { action: this.showKeyboardShortcuts, extraAttrs: { class: 'js-shortcuts-modal-trigger', + 'data-track-action': 'click_button', + 'data-track-label': 'keyboard_shortcuts_help', + 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'], }, shortcut: '?', }, @@ -79,6 +130,11 @@ export default { count: this.showWhatsNewNotification && this.sidebarData.whats_new_most_recent_release_items_count, + extraAttrs: { + 'data-track-action': 'click_button', + 'data-track-label': 'whats_new', + 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'], + }, }, ].filter(Boolean), }, @@ -118,12 +174,40 @@ export default { this.toggleWhatsNewDrawer(); } }, + + trackingAttrs(label) { + return { + ...HELP_MENU_TRACKING_DEFAULTS, + 'data-track-label': label, + }; + }, + + trackDropdownToggle(show) { + this.track('click_toggle', { + label: show ? 'show_help_dropdown' : 'hide_help_dropdown', + }); + }, + }, + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], }, }; </script> <template> - <gl-disclosure-dropdown ref="dropdown"> + <gl-disclosure-dropdown + ref="dropdown" + :popper-options="$options.popperOptions" + @shown="trackDropdownToggle(true)" + @hidden="trackDropdownToggle(false)" + > <template #toggle> <gl-button category="tertiary" icon="question-o" class="btn-with-notification"> <span v-if="showWhatsNewNotification" class="notification-dot-info"></span> diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue index 0a72105fcc4..8ee7d57c47c 100644 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -1,18 +1,29 @@ <script> +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import NavItem from './nav_item.vue'; export default { components: { + GlIcon, + GlButton, ProjectAvatar, NavItem, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { items: { type: Array, required: false, default: () => [], }, + editable: { + type: Boolean, + required: false, + default: false, + }, }, }; </script> @@ -34,6 +45,21 @@ export default { aria-hidden="true" /> </template> + <template #actions> + <gl-button + v-if="editable" + v-gl-tooltip.left + size="small" + category="tertiary" + :aria-label="__('Remove')" + :title="__('Remove')" + class="gl-align-self-center gl-p-1! gl-absolute gl-right-4" + data-testid="item-remove" + @click.stop.prevent="$emit('remove-item', item)" + > + <gl-icon name="close" /> + </gl-button> + </template> </nav-item> <slot name="view-all-items"></slot> </ul> diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue index 94fc6aedcc0..d37e863bed9 100644 --- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue @@ -16,7 +16,12 @@ export default { </script> <template> - <gl-disclosure-dropdown :items="items" placement="center"> + <gl-disclosure-dropdown + :items="items" + placement="center" + @shown="$emit('shown')" + @hidden="$emit('hidden')" + > <template #toggle> <slot></slot> </template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index cd5363ad7a5..223fbe6d078 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -1,15 +1,41 @@ <script> import { kebabCase } from 'lodash'; -import { GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; +import { GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + CLICK_MENU_ITEM_ACTION, + TRACKING_UNKNOWN_ID, + TRACKING_UNKNOWN_PANEL, +} from '~/super_sidebar/constants'; export default { + i18n: { + pinItem: s__('Navigation|Pin item'), + unpinItem: s__('Navigation|Unpin item'), + }, name: 'NavItem', components: { + GlButton, GlCollapse, GlIcon, GlBadge, }, + inject: { + pinnedItemIds: { default: { ids: [] } }, + panelSupportsPins: { default: false }, + panelType: { default: '' }, + }, props: { + draggable: { + type: Boolean, + required: false, + default: false, + }, + isStatic: { + type: Boolean, + required: false, + default: false, + }, item: { type: Object, required: true, @@ -53,25 +79,53 @@ export default { } return this.item.is_active; }, + isPinnable() { + return this.panelSupportsPins && !this.isSection && !this.isStatic; + }, + isPinned() { + return this.pinnedItemIds.ids.includes(this.item.id); + }, + trackingProps() { + // Set extra event data to debug missing IDs / Panel Types + const extraData = + !this.item.id || !this.panelType + ? { 'data-track-extra': JSON.stringify({ title: this.item.title }) } + : {}; + + return { + 'data-track-action': CLICK_MENU_ITEM_ACTION, + 'data-track-label': this.item.id ?? TRACKING_UNKNOWN_ID, + 'data-track-property': this.panelType + ? `nav_panel_${this.panelType}` + : TRACKING_UNKNOWN_PANEL, + ...extraData, + }; + }, linkProps() { if (this.isSection) { return { 'aria-controls': this.itemId, 'aria-expanded': String(this.expanded), + 'data-qa-menu-item': this.item.title, }; } return { ...this.$attrs, + ...this.trackingProps, href: this.item.link, 'aria-current': this.isActive ? 'page' : null, + 'data-qa-submenu-item': this.item.title, }; }, computedLinkClasses() { return { // Reset user agent styles on <button> 'gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left': this.isSection, - 'gl-w-full gl-focus': this.isSection, + 'gl-w-full gl-focus--focus': this.isSection, + 'nav-item-link': !this.isSection, 'gl-bg-t-gray-a-08': this.isActive, + 'gl-py-2': this.isPinnable, + 'gl-py-3': !this.isPinnable, ...this.linkClasses, }; }, @@ -92,11 +146,10 @@ export default { <component :is="elem" v-bind="linkProps" - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-text-decoration-none!" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none!" :class="computedLinkClasses" - data-qa-selector="sidebar_menu_link" + data-qa-selector="nav_item_link" data-testid="nav-item-link" - :data-qa-menu-item="item.title" @click="click" > <div @@ -107,26 +160,50 @@ export default { ></div> <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> + <gl-icon + v-else-if="draggable" + name="grip" + class="gl-text-gray-400 gl-ml-2 draggable-icon" + /> </slot> </div> - <div class="gl-pr-3 gl-text-gray-900"> + <div class="gl-pr-3 gl-text-gray-900 gl-truncate-end"> {{ item.title }} - <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500"> + <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end"> {{ item.subtitle }} </div> </div> - <span v-if="isSection || hasPill" class="gl-flex-grow-1 gl-text-right gl-mr-3"> + <slot name="actions"></slot> + <span v-if="isSection || hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3"> <gl-badge v-if="hasPill" size="sm" variant="info"> {{ pillData }} </gl-badge> <gl-icon v-else-if="isSection" :name="collapseIcon" /> + <gl-button + v-else-if="isPinnable && !isPinned" + size="small" + category="tertiary" + icon="thumbtack" + :aria-label="$options.i18n.pinItem" + @click.prevent="$emit('pin-add', item.id)" + /> + <gl-button + v-else-if="isPinnable && isPinned" + size="small" + category="tertiary" + :aria-label="$options.i18n.unpinItem" + icon="thumbtack-solid" + @click.prevent="$emit('pin-remove', item.id)" + /> </span> </component> <gl-collapse v-if="isSection" :id="itemId" v-model="expanded" + data-qa-selector="menu_section" + :data-qa-section="item.title" :aria-label="item.title" class="gl-list-style-none gl-p-0" tag="ul" @@ -135,6 +212,8 @@ export default { v-for="subItem of item.items" :key="`${item.title}-${subItem.title}`" :item="subItem" + @pin-add="(itemId) => $emit('pin-add', itemId)" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" /> </gl-collapse> </li> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue new file mode 100644 index 00000000000..9595bdfb632 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -0,0 +1,104 @@ +<script> +import { GlCollapse, GlIcon } from '@gitlab/ui'; +import Draggable from 'vuedraggable'; +import { s__ } from '~/locale'; +import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../constants'; +import NavItem from './nav_item.vue'; + +export default { + i18n: { + pinned: s__('Navigation|Pinned'), + emptyHint: s__('Navigation|Your pinned items appear here.'), + }, + name: 'PinnedSection', + components: { + Draggable, + GlCollapse, + GlIcon, + NavItem, + }, + props: { + items: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false', + draggableItems: this.items, + }; + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + itemIds() { + return this.draggableItems.map((item) => item.id); + }, + }, + watch: { + expanded(newExpanded) { + setCookie(SIDEBAR_PINS_EXPANDED_COOKIE, newExpanded, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }, + items(newItems) { + this.draggableItems = newItems; + }, + }, + methods: { + handleDrag(event) { + if (event.oldIndex === event.newIndex) return; + this.$emit( + 'pin-reorder', + this.items[event.oldIndex].id, + this.items[event.newIndex].id, + event.oldIndex < event.newIndex, + ); + }, + }, +}; +</script> + +<template> + <section class="gl-mx-2"> + <a + href="#" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none!" + @click.prevent="expanded = !expanded" + > + <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> + <gl-icon name="thumbtack" class="gl-ml-2 item-icon" /> + </div> + + <span class="gl-font-weight-bold gl-font-sm gl-flex-grow-1">{{ $options.i18n.pinned }}</span> + <gl-icon :name="collapseIcon" class="gl-mr-3" /> + </a> + <gl-collapse v-model="expanded"> + <draggable + v-if="items.length > 0" + v-model="draggableItems" + class="gl-p-0 gl-m-0" + data-testid="pinned-nav-items" + handle=".draggable-icon" + tag="ul" + @end="handleDrag" + > + <nav-item + v-for="item of draggableItems" + :key="item.id" + draggable + :item="item" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + </draggable> + <div v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> + {{ $options.i18n.emptyHint }} + </div> + </gl-collapse> + <hr aria-hidden="true" class="gl-my-2 gl-mx-4" /> + </section> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue index a545de06bd4..78860e35eb1 100644 --- a/app/assets/javascripts/super_sidebar/components/projects_list.vue +++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue @@ -36,16 +36,19 @@ export default { storageKey() { return `${this.username}/frequent-projects`; }, - viewAllItem() { + viewAllProps() { return { - link: this.viewAllLink, - title: s__('Navigation|View all projects'), - icon: 'project', + item: { + link: this.viewAllLink, + title: s__('Navigation|View all your projects'), + icon: 'project', + }, + linkClasses: { 'dashboard-shortcuts-projects': true }, }; }, }, i18n: { - title: s__('Navigation|Frequent projects'), + title: s__('Navigation|Frequently visited projects'), searchTitle: s__('Navigation|Projects'), pristineText: s__('Navigation|Projects you visit often will appear here.'), noResultsText: s__('Navigation|No project matches found'), @@ -62,7 +65,7 @@ export default { :search-results="searchResults" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </search-results> <frequent-items-list @@ -73,7 +76,7 @@ export default { :pristine-text="$options.i18n.pristineText" > <template #view-all-items> - <nav-item :item="viewAllItem" /> + <nav-item v-bind="viewAllProps" /> </template> </frequent-items-list> </template> diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue index 7c172110bad..cfd6184bc47 100644 --- a/app/assets/javascripts/super_sidebar/components/search_results.vue +++ b/app/assets/javascripts/super_sidebar/components/search_results.vue @@ -1,10 +1,17 @@ <script> +import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; import ItemsList from './items_list.vue'; export default { components: { + GlCollapse, + GlIcon, ItemsList, }, + directives: { + CollapseToggle: GlCollapseToggleDirective, + }, props: { title: { type: String, @@ -20,30 +27,69 @@ export default { default: () => [], }, }, + data() { + return { + expanded: true, + }; + }, computed: { isEmpty() { return !this.searchResults.length; }, + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + }, + created() { + this.collapseId = uniqueId('expandable-section-'); }, + buttonClasses: [ + // Reset user agent styles + 'gl-appearance-none', + 'gl-border-0', + 'gl-bg-transparent', + // Text styles + 'gl-text-left', + 'gl-text-transform-uppercase', + 'gl-text-secondary', + 'gl-font-weight-bold', + 'gl-font-xs', + 'gl-line-height-12', + 'gl-letter-spacing-06em', + // Border + 'gl-border-t', + 'gl-border-gray-50', + // Spacing + 'gl-my-3', + 'gl-pt-2', + 'gl-w-full', + // Layout + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-align-items-center', + ], }; </script> <template> - <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3"> - <div - data-testid="list-title" - aria-hidden="true" - class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" + <li class="gl-border-t gl-border-gray-50 gl-mx-3"> + <button + v-collapse-toggle="collapseId" + :class="$options.buttonClasses" + data-testid="search-results-toggle" > {{ title }} - </div> - <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3"> - {{ noResultsText }} - </div> - <items-list :aria-label="title" :items="searchResults"> - <template #view-all-items> - <slot name="view-all-items"></slot> - </template> - </items-list> + <gl-icon :name="collapseIcon" :size="16" /> + </button> + <gl-collapse :id="collapseId" v-model="expanded"> + <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3"> + {{ noResultsText }} + </div> + <items-list :aria-label="title" :items="searchResults"> + <template #view-all-items> + <slot name="view-all-items"></slot> + </template> + </items-list> + </gl-collapse> </li> </template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index fc8968c50ea..ca165dd8602 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -1,24 +1,157 @@ <script> +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { PANELS_WITH_PINS } from '../constants'; import NavItem from './nav_item.vue'; +import PinnedSection from './pinned_section.vue'; export default { name: 'SidebarMenu', components: { NavItem, + PinnedSection, + }, + + provide() { + return { + pinnedItemIds: this.changedPinnedItemIds, + panelSupportsPins: this.supportsPins, + panelType: this.panelType, + }; }, props: { items: { type: Array, required: true, }, + pinnedItemIds: { + type: Array, + required: false, + default: () => [], + }, + panelType: { + type: String, + required: false, + default: '', + }, + updatePinsUrl: { + type: String, + required: true, + }, + }, + + data() { + return { + // 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 }, + }; + }, + + computed: { + // Returns the list of items that we want to have static at the top. + // Only sidebars that support pins also support a static section. + staticItems() { + if (!this.supportsPins) return []; + return this.items.filter((item) => !item.items || item.items.length === 0); + }, + + // Returns only the items that aren't static at the top and makes sure no + // section shows as active (and expanded) when one of its items is pinned. + nonStaticItems() { + if (!this.supportsPins) return this.items; + + return this.items + .filter((item) => item.items && item.items.length > 0) + .map((item) => { + const hasActivePinnedChild = item.items.some((childItem) => { + return childItem.is_active && this.changedPinnedItemIds.ids.includes(childItem.id); + }); + const showAsActive = item.is_active && !hasActivePinnedChild; + + return { ...item, is_active: showAsActive }; + }); + }, + + // Returns a flat list of all items that are in sections, but not the sections. + // Only items from sections (item.items) can be pinned. + flatPinnableItems() { + return this.nonStaticItems.flatMap((item) => item.items).filter(Boolean); + }, + + pinnedItems() { + return this.changedPinnedItemIds.ids + .map((id) => this.flatPinnableItems.find((item) => item.id === id)) + .filter(Boolean); + }, + supportsPins() { + return PANELS_WITH_PINS.includes(this.panelType); + }, + }, + methods: { + createPin(itemId) { + this.changedPinnedItemIds.ids.push(itemId); + this.updatePins(); + }, + destroyPin(itemId) { + this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId); + this.updatePins(); + }, + movePin(fromId, toId, isDownwards) { + const fromIndex = this.changedPinnedItemIds.ids.indexOf(fromId); + this.changedPinnedItemIds.ids.splice(fromIndex, 1); + + let toIndex = this.changedPinnedItemIds.ids.indexOf(toId); + + // If the item was moved downwards, we insert it *after* the item it was dragged on to. + // This matches how vuedraggable previews the change while still dragging. + if (isDownwards) toIndex += 1; + + this.changedPinnedItemIds.ids.splice(toIndex, 0, fromId); + + this.updatePins(); + }, + updatePins() { + axios + .put(this.updatePinsUrl, { + panel: this.panelType, + menu_item_ids: this.changedPinnedItemIds.ids, + }) + .then((response) => { + this.changedPinnedItemIds.ids = response.data; + }) + .catch((e) => { + Sentry.captureException(e); + }); + }, }, }; </script> <template> <nav class="gl-py-2 gl-relative"> + <section v-if="staticItems.length > 0" class="gl-mx-2"> + <ul class="gl-p-0 gl-m-0"> + <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> + </ul> + <hr aria-hidden="true" class="gl-my-2 gl-mx-4" /> + </section> + + <pinned-section + v-if="supportsPins" + :items="pinnedItems" + @pin-remove="destroyPin" + @pin-reorder="movePin" + /> + <ul class="gl-px-2 gl-list-style-none"> - <nav-item v-for="item in items" :key="`menu-${item.title}`" :item="item" /> + <nav-item + v-for="item in nonStaticItems" + :key="item.id" + :item="item" + @pin-add="createPin" + @pin-remove="destroyPin" + /> </ul> </nav> </template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index e8df534346b..4b54e317639 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -1,7 +1,13 @@ <script> import { GlButton, GlCollapse } from '@gitlab/ui'; import { __ } from '~/locale'; -import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + sidebarState, + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, +} from '../constants'; +import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; import UserBar from './user_bar.vue'; import SidebarPortalTarget from './sidebar_portal_target.vue'; import ContextSwitcherToggle from './context_switcher_toggle.vue'; @@ -20,6 +26,7 @@ export default { SidebarMenu, SidebarPortalTarget, }, + mixins: [glFeatureFlagsMixin()], i18n: { skipToMainContent: __('Skip to main content'), }, @@ -30,10 +37,7 @@ export default { }, }, data() { - return { - contextSwitcherOpened: false, - isCollapased: isCollapsed(), - }; + return sidebarState; }, computed: { menuItems() { @@ -44,6 +48,37 @@ export default { collapseSidebar() { toggleSuperSidebarCollapsed(true, false); }, + onContextSwitcherShown() { + this.$refs['context-switcher'].focusInput(); + }, + onHoverAreaMouseEnter() { + this.openPeekTimer = setTimeout(this.openPeek, SUPER_SIDEBAR_PEEK_OPEN_DELAY); + }, + onHoverAreaMouseLeave() { + clearTimeout(this.openPeekTimer); + }, + onSidebarMouseEnter() { + clearTimeout(this.closePeekTimer); + }, + onSidebarMouseLeave() { + this.closePeekTimer = setTimeout(this.closePeek, SUPER_SIDEBAR_PEEK_CLOSE_DELAY); + }, + closePeek() { + if (this.isPeek) { + this.isPeek = false; + this.isCollapsed = true; + } + }, + openPeek() { + this.isPeek = true; + this.isCollapsed = false; + + // Cancel and start the timer to close sidebar, in case the user moves + // the cursor fast enough away to not trigger a mouseenter event. + // This is cancelled if the user moves the cursor into the sidebar. + this.onSidebarMouseEnter(); + this.onSidebarMouseLeave(); + }, }, }; </script> @@ -51,14 +86,22 @@ export default { <template> <div> <div class="super-sidebar-overlay" @click="collapseSidebar"></div> + <div + v-if="!isPeek && glFeatures.superSidebarPeek" + class="super-sidebar-hover-area gl-fixed gl-left-0 gl-top-0 gl-bottom-0 gl-w-3" + data-testid="super-sidebar-hover-area" + @mouseenter="onHoverAreaMouseEnter" + @mouseleave="onHoverAreaMouseLeave" + ></div> <aside id="super-sidebar" - :aria-hidden="String(isCollapased)" class="super-sidebar" + :class="{ 'super-sidebar-peek': isPeek }" data-testid="super-sidebar" data-qa-selector="navbar" - :inert="isCollapased" - tabindex="-1" + :inert="isCollapsed" + @mouseenter="onSidebarMouseEnter" + @mouseleave="onSidebarMouseLeave" > <gl-button class="super-sidebar-skip-to gl-sr-only-focusable gl-absolute gl-left-3 gl-right-3 gl-top-3" @@ -67,23 +110,36 @@ export default { > {{ $options.i18n.skipToMainContent }} </gl-button> - <user-bar :sidebar-data="sidebarData" /> + <user-bar :has-collapse-button="!isPeek" :sidebar-data="sidebarData" /> <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> <div class="gl-flex-grow-1 gl-overflow-auto"> <context-switcher-toggle :context="sidebarData.current_context_header" - :expanded="contextSwitcherOpened" + :expanded="contextSwitcherOpen" + data-qa-selector="context_switcher" /> - <gl-collapse id="context-switcher" v-model="contextSwitcherOpened"> + <gl-collapse + id="context-switcher" + v-model="contextSwitcherOpen" + data-qa-selector="context_section" + @shown="onContextSwitcherShown" + > <context-switcher + ref="context-switcher" + :persistent-links="sidebarData.context_switcher_links" :username="sidebarData.username" :projects-path="sidebarData.projects_path" :groups-path="sidebarData.groups_path" :current-context="sidebarData.current_context" /> </gl-collapse> - <gl-collapse :visible="!contextSwitcherOpened"> - <sidebar-menu :items="menuItems" /> + <gl-collapse :visible="!contextSwitcherOpen"> + <sidebar-menu + :items="menuItems" + :panel-type="sidebarData.panel_type" + :pinned-item-ids="sidebarData.pinned_items" + :update-pins-url="sidebarData.update_pins_url" + /> <sidebar-portal-target /> </gl-collapse> </div> @@ -92,5 +148,14 @@ export default { </div> </div> </aside> + <a + v-for="shortcutLink in sidebarData.shortcut_links" + :key="shortcutLink.href" + :href="shortcutLink.href" + :class="shortcutLink.css_class" + class="gl-display-none" + > + {{ shortcutLink.title }} + </a> </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 new file mode 100644 index 00000000000..3064b91ca7d --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -0,0 +1,80 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants'; +import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tooltipContainer: { + type: String, + required: false, + default: null, + }, + tooltipPlacement: { + type: String, + required: false, + default: 'right', + }, + }, + i18n: { + collapseSidebar: __('Collapse sidebar'), + expandSidebar: __('Expand sidebar'), + navigationSidebar: __('Navigation sidebar'), + }, + data() { + return sidebarState; + }, + computed: { + tooltipTitle() { + if (this.isPeek) return ''; + + return this.isCollapsed + ? this.$options.i18n.expandSidebar + : this.$options.i18n.collapseSidebar; + }, + tooltip() { + return { + placement: this.tooltipPlacement, + container: this.tooltipContainer, + title: this.tooltipTitle, + }; + }, + ariaExpanded() { + return String(!this.isCollapsed); + }, + }, + methods: { + toggle() { + toggleSuperSidebarCollapsed(!this.isCollapsed, true); + this.focusOtherToggle(); + }, + focusOtherToggle() { + this.$nextTick(() => { + const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; + const otherToggle = document.querySelector(`.${classSelector}`); + otherToggle?.focus(); + }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover="tooltip" + aria-controls="super-sidebar" + :aria-expanded="ariaExpanded" + :aria-label="$options.i18n.navigationSidebar" + icon="sidebar" + category="tertiary" + :disabled="isPeek" + @click="toggle" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index e27acb60372..f311c5242f5 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,19 +1,24 @@ <script> -import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import { highCountTrim } from '~/lib/utils/text_utility'; import logo from '../../../../views/shared/_logo.svg'; -import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; +import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants'; import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; import MergeRequestMenu from './merge_request_menu.vue'; import UserMenu from './user_menu.vue'; +import SuperSidebarToggle from './super_sidebar_toggle.vue'; +import { SEARCH_MODAL_ID } from './global_search/constants'; export default { // "GitLab Next" is a proper noun, so don't translate "Next" /* eslint-disable-next-line @gitlab/require-i18n-strings */ NEXT_LABEL: 'Next', logo, + JS_TOGGLE_COLLAPSE_CLASS, + SEARCH_MODAL_ID, components: { Counter, CreateMenu, @@ -21,29 +26,63 @@ export default { GlButton, MergeRequestMenu, UserMenu, + SearchModal: () => + import( + /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue' + ), + SuperSidebarToggle, }, i18n: { - collapseSidebar: __('Collapse sidebar'), createNew: __('Create new...'), + homepage: __('Homepage'), issues: __('Issues'), mergeRequests: __('Merge requests'), search: __('Search'), + searchKbdHelp: sprintf( + s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), todoList: __('To-Do list'), + stopImpersonating: __('Stop impersonating'), }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, SafeHtml, }, - inject: ['rootPath'], + inject: ['rootPath', 'isImpersonating'], props: { + hasCollapseButton: { + default: true, + type: Boolean, + required: false, + }, sidebarData: { type: Object, required: true, }, }, + data() { + return { + mrMenuShown: false, + todoCount: this.sidebarData.todos_pending_count, + }; + }, + computed: { + formattedTodoCount() { + return highCountTrim(this.todoCount); + }, + }, + mounted() { + document.addEventListener('todo:toggle', this.updateTodos); + }, + beforeDestroy() { + document.removeEventListener('todo:toggle', this.updateTodos); + }, methods: { - collapseSidebar() { - toggleSuperSidebarCollapsed(true, true, true); + updateTodos(e) { + this.todoCount = e.detail.count || 0; }, }, }; @@ -51,8 +90,15 @@ export default { <template> <div class="user-bar"> - <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-2"> - <a :href="rootPath"> + <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2"> + <a + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" + :href="rootPath" + :title="$options.i18n.homepage" + data-track-action="click_link" + data-track-label="gitlab_logo_link" + data-track-property="nav_core_menu" + > <img v-if="sidebarData.logo_url" data-testid="brand-header-custom-logo" @@ -66,54 +112,86 @@ export default { variant="success" :href="sidebarData.canary_toggle_com_url" size="sm" - >{{ $options.NEXT_LABEL }}</gl-badge + class="gl-ml-2" > + {{ $options.NEXT_LABEL }} + </gl-badge> <div class="gl-flex-grow-1"></div> - <gl-button - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.collapseSidebar" - :aria-label="$options.i18n.collapseSidebar" - icon="sidebar" - category="tertiary" - @click="collapseSidebar" + <super-sidebar-toggle + v-if="hasCollapseButton" + :class="$options.JS_TOGGLE_COLLAPSE_CLASS" + tooltip-placement="bottom" + tooltip-container="super-sidebar" + data-testid="super-sidebar-collapse-button" /> <create-menu :groups="sidebarData.create_new_menu_groups" /> + <gl-button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="$options.i18n.searchKbdHelp" + v-gl-modal="$options.SEARCH_MODAL_ID" + data-testid="super-sidebar-search-button" icon="search" :aria-label="$options.i18n.search" category="tertiary" - href="/search" /> + <search-modal /> + <user-menu :data="sidebarData" /> + + <gl-button + v-if="isImpersonating" + v-gl-tooltip + :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"> <counter v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" - class="gl-flex-basis-third" + class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" :count="sidebarData.assigned_open_issues_count" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" + data-track-action="click_link" + data-track-label="issues_link" + data-track-property="nav_core_menu" /> <merge-request-menu class="gl-flex-basis-third gl-display-block!" :items="sidebarData.merge_request_menu" + @shown="mrMenuShown = true" + @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests" + v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" class="gl-w-full" icon="merge-request-open" :count="sidebarData.total_merge_requests_count" :label="$options.i18n.mergeRequests" + data-track-action="click_dropdown" + data-track-label="merge_requests_menu" + data-track-property="nav_core_menu" /> </merge-request-menu> <counter v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" - class="gl-flex-basis-third" + class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" - :count="sidebarData.todos_pending_count" + :count="formattedTodoCount" href="/dashboard/todos" :label="$options.i18n.todoList" data-qa-selector="todos_shortcut_button" + data-track-action="click_link" + data-track-label="todos_link" + data-track-property="nav_core_menu" /> </div> </div> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 34bbb3ce177..c90d1ad9c3e 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -11,13 +11,17 @@ import { s__, __, sprintf } from '~/locale'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; +import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants'; import UserNameGroup from './user_name_group.vue'; +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -211; + export default { - feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391533', + feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/403059', i18n: { newNavigation: { - badgeLabel: s__('NorthstarNavigation|Alpha'), + badgeLabel: s__('NorthstarNavigation|Beta'), sectionTitle: s__('NorthstarNavigation|Navigation redesign'), }, setStatus: s__('SetStatusModal|Set status'), @@ -72,6 +76,10 @@ export default { return { text: this.$options.i18n.startTrial, href: this.data.trial.url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'start_trial', + }, }; }, editProfileItem() { @@ -80,6 +88,8 @@ export default { href: this.data.settings.profile_path, extraAttrs: { 'data-qa-selector': 'edit_profile_link', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_edit_profile', }, }; }, @@ -87,6 +97,10 @@ export default { return { text: this.$options.i18n.preferences, href: this.data.settings.profile_preferences_path, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_preferences', + }, }; }, addBuyPipelineMinutesMenuItem() { @@ -99,6 +113,8 @@ export default { href: this.data.pipeline_minutes?.buy_pipeline_minutes_path, extraAttrs: { class: 'js-follow-link', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'buy_pipeline_minutes', }, }; }, @@ -106,6 +122,10 @@ export default { return { text: this.$options.i18n.gitlabNext, href: this.data.canary_toggle_com_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'switch_to_canary', + }, }; }, feedbackItem() { @@ -114,6 +134,8 @@ export default { href: this.$options.feedbackUrl, extraAttrs: { target: '_blank', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'provide_nav_beta_feedback', }, }; }, @@ -139,9 +161,12 @@ export default { 'data-default-emoji': 'speech_balloon', }; - if (!this.data.status.customized) { + const { busy, customized } = this.data.status; + + if (!busy && !customized) { return defaultData; } + return { ...defaultData, 'data-current-emoji': this.data.status.emoji, @@ -164,15 +189,20 @@ export default { }, methods: { onShow() { - this.trackEvents(); - this.initCallout(); + this.initBuyCIMinsCallout(); + }, + closeDropdown() { + this.$refs.userDropdown.close(); }, - initCallout() { + initBuyCIMinsCallout() { if (this.showNotificationDot) { PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el); } }, - trackEvents() { + /* We're not sure this event is tracked by anyone + whether it stays will depend on the outcome of this discussion: + https://gitlab.com/gitlab-org/gitlab/-/issues/402713#note_1343072135 */ + trackBuyCIMins() { if (this.addBuyPipelineMinutesMenuItem) { const { 'track-action': trackAction, @@ -182,6 +212,22 @@ export default { this.track(trackAction, { label, property }); } }, + trackSignOut() { + this.track(USER_MENU_TRACKING_DEFAULTS['data-track-action'], { + label: 'user_sign_out', + property: USER_MENU_TRACKING_DEFAULTS['data-track-property'], + }); + }, + }, + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], }, }; </script> @@ -189,7 +235,8 @@ export default { <template> <div> <gl-disclosure-dropdown - placement="right" + ref="userDropdown" + :popper-options="$options.popperOptions" data-testid="user-dropdown" data-qa-selector="user_menu" @shown="onShow" @@ -220,6 +267,7 @@ export default { v-if="data.status.can_update" :item="statusItem" data-testid="status-item" + @action="closeDropdown" /> <gl-disclosure-dropdown-item @@ -243,6 +291,7 @@ export default { :item="buyPipelineMinutesItem" v-bind="buyPipelineMinutesCalloutData" data-testid="buy-pipeline-minutes-item" + @action="trackBuyCIMins" > <template #list-item> <span class="gl-display-flex gl-flex-direction-column"> @@ -279,6 +328,7 @@ export default { bordered :group="signOutGroup" data-testid="sign-out-group" + @action="trackSignOut" /> </gl-disclosure-dropdown> 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 2489f462122..57958a03edd 100644 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -1,16 +1,22 @@ <script> -import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui'; +import { + GlBadge, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlTooltip, +} from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; - import { s__ } from '~/locale'; +import { USER_MENU_TRACKING_DEFAULTS } from '../constants'; export default { i18n: { user: { - busy: s__('UserProfile|(Busy)'), + busy: s__('UserProfile|Busy'), }, }, components: { + GlBadge, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip, @@ -31,7 +37,13 @@ export default { }; if (this.user.has_link_to_profile) { item.href = this.user.link_to_profile; + + item.extraAttrs = { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_profile', + }; } + return item; }, }, @@ -47,9 +59,9 @@ export default { <span class="gl-font-weight-bold"> {{ user.name }} </span> - <span v-if="user.status.busy" class="gl-text-gray-500">{{ - $options.i18n.user.busy - }}</span> + <gl-badge v-if="user.status.busy" size="sm" variant="warning"> + {{ $options.i18n.user.busy }} + </gl-badge> </span> <span class="gl-text-gray-400">@{{ user.username }}</span> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index acc03bc48c7..4f5b027c138 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -5,10 +5,44 @@ import Vue from 'vue'; export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount'; +export const JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse'; +export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand'; export const portalState = Vue.observable({ ready: false, }); +export const sidebarState = Vue.observable({ + contextSwitcherOpen: false, + isCollapsed: false, + isPeek: false, + openPeekTimer: null, + closePeekTimer: null, +}); + export const MAX_FREQUENT_PROJECTS_COUNT = 5; export const MAX_FREQUENT_GROUPS_COUNT = 3; + +export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200; +export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500; + +export const TRACKING_UNKNOWN_ID = 'item_without_id'; +export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown'; +export const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; + +export const PANELS_WITH_PINS = ['group', 'project']; + +export const USER_MENU_TRACKING_DEFAULTS = { + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', +}; + +export const HELP_MENU_TRACKING_DEFAULTS = { + 'data-track-property': 'nav_help_menu', + 'data-track-action': 'click_link', +}; + +export const SIDEBAR_PINS_EXPANDED_COOKIE = 'sidebar_pinned_section_expanded'; +export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10; + +export const DROPDOWN_Y_OFFSET = 4; diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js deleted file mode 100644 index 5e5ad97eb68..00000000000 --- a/app/assets/javascripts/super_sidebar/mock_data.js +++ /dev/null @@ -1,54 +0,0 @@ -import { s__ } from '~/locale'; - -export const contextSwitcherItems = { - yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' }, - explore: { title: s__('Navigation|Explore'), link: '/explore', icon: 'compass' }, - recentProjects: [ - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Orange', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64', - }, - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Lemon', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64', - }, - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Coconut', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64', - }, - ], - recentGroups: [ - { - title: 'Developer Evangelism at GitLab', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64', - }, - { - title: 'security-products', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64', - }, - { - title: 'Tanuki-Workshops', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64', - }, - ], -}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 4395cc2f5f0..fdd29a1719c 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,12 +1,16 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; +import { JS_TOGGLE_EXPAND_CLASS } from './constants'; +import createStore from './components/global_search/store'; import { bindSuperSidebarCollapsedEvents, initSuperSidebarCollapsedState, } from './super_sidebar_collapsed_state_manager'; import SuperSidebar from './components/super_sidebar.vue'; +import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; Vue.use(VueApollo); @@ -23,6 +27,11 @@ export const initSuperSidebar = () => { initSuperSidebarCollapsedState(); const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset; + const sidebarData = JSON.parse(sidebar); + const searchData = convertObjectPropsToCamelCase(sidebarData.search); + + const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; + const isImpersonating = parseBoolean(sidebarData.is_impersonating); return new Vue({ el, @@ -31,15 +40,48 @@ export const initSuperSidebar = () => { provide: { rootPath, toggleNewNavEndpoint, + isImpersonating, }, + store: createStore({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search: '', + }), render(h) { return h(SuperSidebar, { props: { - sidebarData: JSON.parse(sidebar), + sidebarData, }, }); }, }); }; +/** + * Guard against multiple instantiations, since the js-* class is persisted + * in the Vue component. + */ +let toggleInstantiated = false; + +export const initSuperSidebarToggle = () => { + const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`); + + if (!el || toggleInstantiated) return false; + + toggleInstantiated = true; + + return new Vue({ + el, + name: 'SuperSidebarToggleRoot', + render(h) { + // Copy classes from HAML-defined button to ensure same positioning, + // including JS_TOGGLE_EXPAND_CLASS. + return h(SuperSidebarToggle, { class: el.className }); + }, + }); +}; + requestIdleCallback(initStatusTriggers); diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js index 549c6c17e44..17e07146678 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js @@ -1,14 +1,15 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { debounce } from 'lodash'; import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { sidebarState } from './constants'; export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed'; export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed'; export const SIDEBAR_COLLAPSED_COOKIE_EXPIRATION = 365 * 10; +export const SIDEBAR_TRANSITION_DURATION = 200; export const findPage = () => document.querySelector('.page-with-super-sidebar'); export const findSidebar = () => document.querySelector('.super-sidebar'); -export const findToggles = () => document.querySelectorAll('.js-super-sidebar-toggle'); export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS); @@ -19,15 +20,15 @@ export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true'; -export const toggleSuperSidebarCollapsed = (collapsed, saveCookie, isUserAction) => { - const sidebar = findSidebar(); - sidebar.ariaHidden = collapsed; - sidebar.inert = collapsed; - - if (!collapsed && isUserAction) sidebar.focus(); +export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => { + clearTimeout(sidebarState.openPeekTimer); + clearTimeout(sidebarState.closePeekTimer); findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed); + sidebarState.isPeek = false; + sidebarState.isCollapsed = collapsed; + if (saveCookie && isDesktopBreakpoint()) { setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, { expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION, @@ -41,11 +42,5 @@ export const initSuperSidebarCollapsedState = () => { }; export const bindSuperSidebarCollapsedEvents = () => { - findToggles().forEach((elem) => { - elem.addEventListener('click', () => { - toggleSuperSidebarCollapsed(!isCollapsed(), true, true); - }); - }); - window.addEventListener('resize', debounce(initSuperSidebarCollapsedState, 100)); }; diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index 065e1080897..d79252f6bb7 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - // Syntax Highlighter // // Applies a syntax highlighting color scheme CSS class to any element with the @@ -14,6 +12,7 @@ export default function syntaxHighlight($els = null) { if (!$els || $els.length === 0) return; const els = $els.get ? $els.get() : $els; + // eslint-disable-next-line consistent-return const handler = (el) => { if (el.classList === undefined) { return el; diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql new file mode 100644 index 00000000000..3ba0ab29530 --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql @@ -0,0 +1,69 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query timeTrackingReport( + $startDate: Time + $endDate: Time + $projectId: ProjectID + $groupId: GroupID + $username: String + $first: Int + $last: Int + $before: String + $after: String +) { + timelogs( + startDate: $startDate + endDate: $endDate + projectId: $projectId + groupId: $groupId + username: $username + first: $first + last: $last + after: $after + before: $before + sort: SPENT_AT_DESC + ) { + count + totalSpentTime + nodes { + id + project { + id + webUrl + fullPath + nameWithNamespace + } + timeSpent + user { + id + name + username + avatarUrl + webPath + } + spentAt + note { + id + body + } + summary + issue { + id + title + webUrl + state + reference + } + mergeRequest { + id + title + webUrl + state + reference + } + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue new file mode 100644 index 00000000000..33b0ac4b58e --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue @@ -0,0 +1,50 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { IssuableStatusText } from '~/issues/constants'; + +export default { + components: { + GlLink, + }, + props: { + timelog: { + type: Object, + required: true, + }, + }, + computed: { + subject() { + const { issue, mergeRequest } = this.timelog; + return issue || mergeRequest; + }, + issuableStatus() { + return IssuableStatusText[this.subject.state]; + }, + issuableFullReference() { + return this.timelog.project.fullPath + this.subject.reference; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-gap-2 gl-text-left!"> + <gl-link + :href="subject.webUrl" + class="gl-text-gray-900 gl-hover-text-gray-900 gl-font-weight-bold" + data-testid="title-container" + > + {{ subject.title }} + </gl-link> + <span> + <gl-link + :href="subject.webUrl" + class="gl-text-gray-900 gl-hover-text-gray-900" + data-testid="reference-container" + > + {{ issuableFullReference }} + </gl-link> + • <span data-testid="state-container">{{ issuableStatus }}</span> + </span> + </div> +</template> diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue new file mode 100644 index 00000000000..2069e4a6722 --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue @@ -0,0 +1,229 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { + GlButton, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlKeysetPagination, + GlDatepicker, +} from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { formatTimeSpent } from '~/lib/utils/datetime_utility'; +import { s__ } from '~/locale'; +import getTimelogsQuery from './queries/get_timelogs.query.graphql'; +import TimelogsTable from './timelogs_table.vue'; + +const ENTRIES_PER_PAGE = 20; + +// Define initial dates to current date and time +const INITIAL_TO_DATE = new Date(); +const INITIAL_FROM_DATE = new Date(); + +// Set the initial 'from' date to 30 days before the current date +INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30); + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlKeysetPagination, + GlDatepicker, + TimelogsTable, + }, + props: { + limitToHours: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projectId: null, + groupId: null, + username: null, + timeSpentFrom: INITIAL_FROM_DATE, + timeSpentTo: INITIAL_TO_DATE, + cursor: { + first: ENTRIES_PER_PAGE, + after: null, + last: null, + before: null, + }, + queryVariables: { + startDate: INITIAL_FROM_DATE, + endDate: INITIAL_TO_DATE, + projectId: null, + groupId: null, + username: null, + }, + pageInfo: {}, + report: [], + totalSpentTime: 0, + }; + }, + apollo: { + report: { + query: getTimelogsQuery, + variables() { + return { + ...this.queryVariables, + ...this.cursor, + }; + }, + update({ timelogs: { nodes = [], pageInfo = {}, totalSpentTime = 0 } = {} }) { + this.pageInfo = pageInfo; + this.totalSpentTime = totalSpentTime; + return nodes; + }, + error(error) { + createAlert({ message: s__('TimeTrackingReport|Something went wrong. Please try again.') }); + Sentry.captureException(error); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.report.loading; + }, + showPagination() { + return this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage; + }, + formattedTotalSpentTime() { + return formatTimeSpent(this.totalSpentTime, this.limitToHours); + }, + }, + methods: { + nullIfBlank(value) { + return value === '' ? null : value; + }, + runReport() { + this.cursor = { + first: ENTRIES_PER_PAGE, + after: null, + last: null, + before: null, + }; + + this.queryVariables = { + startDate: this.nullIfBlank(this.timeSpentFrom), + endDate: this.nullIfBlank(this.timeSpentTo), + projectId: this.nullIfBlank(this.projectId), + groupId: this.nullIfBlank(this.groupId), + username: this.nullIfBlank(this.username), + }; + }, + nextPage(item) { + this.cursor = { + first: ENTRIES_PER_PAGE, + after: item, + last: null, + before: null, + }; + }, + prevPage(item) { + this.cursor = { + first: null, + after: null, + last: ENTRIES_PER_PAGE, + before: item, + }; + }, + clearTimeSpentFromDate() { + this.timeSpentFrom = null; + }, + clearTimeSpentToDate() { + this.timeSpentTo = null; + }, + }, + i18n: { + username: s__('TimeTrackingReport|Username'), + from: s__('TimeTrackingReport|From'), + to: s__('TimeTrackingReport|To'), + runReport: s__('TimeTrackingReport|Run report'), + totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-gap-5 gl-mt-5"> + <form + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3" + @submit.prevent="runReport" + > + <gl-form-group + :label="$options.i18n.username" + label-for="timelog-form-username" + class="gl-mb-0 gl-md-form-input-md gl-w-full" + > + <gl-form-input + id="timelog-form-username" + v-model="username" + data-testid="form-username" + class="gl-w-full" + /> + </gl-form-group> + <gl-form-group + key="time-spent-from" + :label="$options.i18n.from" + class="gl-mb-0 gl-md-form-input-md gl-w-full" + > + <gl-datepicker + v-model="timeSpentFrom" + :target="null" + show-clear-button + autocomplete="off" + data-testid="form-from-date" + class="gl-max-w-full!" + @clear="clearTimeSpentFromDate" + /> + </gl-form-group> + <gl-form-group + key="time-spent-to" + :label="$options.i18n.to" + class="gl-mb-0 gl-md-form-input-md gl-w-full" + > + <gl-datepicker + v-model="timeSpentTo" + :target="null" + show-clear-button + autocomplete="off" + data-testid="form-to-date" + class="gl-max-w-full!" + @clear="clearTimeSpentToDate" + /> + </gl-form-group> + <gl-button + class="gl-align-self-end gl-w-full gl-md-w-auto" + variant="confirm" + @click="runReport" + >{{ $options.i18n.runReport }}</gl-button + > + </form> + <div + v-if="!isLoading" + data-testid="table-container" + class="gl-display-flex gl-flex-direction-column" + > + <div v-if="report.length" class="gl-display-flex gl-gap-2 gl-border-t gl-py-4"> + <span class="gl-font-weight-bold">{{ $options.i18n.totalTimeSpentText }}</span> + <span data-testid="total-time-spent-container">{{ formattedTotalSpentTime }}</span> + </div> + + <timelogs-table :limit-to-hours="limitToHours" :entries="report" /> + + <gl-keyset-pagination + v-if="showPagination" + v-bind="pageInfo" + class="gl-mt-3 gl-align-self-center" + @prev="prevPage" + @next="nextPage" + /> + </div> + <gl-loading-icon v-else size="lg" class="gl-mt-5" /> + </div> +</template> diff --git a/app/assets/javascripts/time_tracking/components/timelogs_table.vue b/app/assets/javascripts/time_tracking/components/timelogs_table.vue new file mode 100644 index 00000000000..b2efb44f56f --- /dev/null +++ b/app/assets/javascripts/time_tracking/components/timelogs_table.vue @@ -0,0 +1,105 @@ +<script> +import { GlTable } from '@gitlab/ui'; +import { formatDate, formatTimeSpent } from '~/lib/utils/datetime_utility'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { s__ } from '~/locale'; +import TimelogSourceCell from './timelog_source_cell.vue'; + +const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; + +export default { + components: { + GlTable, + UserAvatarLink, + TimelogSourceCell, + }, + props: { + entries: { + type: Array, + required: true, + }, + limitToHours: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + fields: [ + { + key: 'spentAt', + label: s__('TimeTrackingReport|Spent at'), + tdClass: 'gl-md-w-30', + }, + { + key: 'source', + label: s__('TimeTrackingReport|Source'), + }, + { + key: 'user', + label: s__('TimeTrackingReport|User'), + tdClass: 'gl-md-w-20', + }, + { + key: 'timeSpent', + label: s__('TimeTrackingReport|Time spent'), + tdClass: 'gl-md-w-15', + }, + { + key: 'summary', + label: s__('TimeTrackingReport|Summary'), + }, + ], + }; + }, + methods: { + formatDate(date) { + return formatDate(date, TIME_DATE_FORMAT); + }, + formatTimeSpent(seconds) { + return formatTimeSpent(seconds, this.limitToHours); + }, + extractTimelogSummary(timelog) { + const { note, summary } = timelog; + return note?.body || summary; + }, + }, +}; +</script> + +<template> + <gl-table :items="entries" :fields="fields" stacked="md" show-empty> + <template #cell(spentAt)="{ item: { spentAt } }"> + <div data-testid="date-container" class="gl-text-left!">{{ formatDate(spentAt) }}</div> + </template> + + <template #cell(source)="{ item }"> + <timelog-source-cell :timelog="item" /> + </template> + + <template #cell(user)="{ item: { user } }"> + <user-avatar-link + class="gl-display-flex gl-text-gray-900 gl-hover-text-gray-900" + :link-href="user.webPath" + :img-src="user.avatarUrl" + :img-size="16" + :img-alt="user.name" + :tooltip-text="user.name" + :username="user.name" + /> + </template> + + <template #cell(timeSpent)="{ item: { timeSpent } }"> + <div data-testid="time-spent-container" class="gl-text-left!"> + {{ formatTimeSpent(timeSpent) }} + </div> + </template> + + <template #cell(summary)="{ item }"> + <div data-testid="summary-container" class="gl-text-left!"> + {{ extractTimelogSummary(item) }} + </div> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/time_tracking/index.js b/app/assets/javascripts/time_tracking/index.js new file mode 100644 index 00000000000..9cff01799d9 --- /dev/null +++ b/app/assets/javascripts/time_tracking/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import TimelogsApp from './components/timelogs_app.vue'; + +Vue.use(VueApollo); + +export default () => { + const el = document.getElementById('js-timelogs-app'); + if (!el) { + return false; + } + + const { limitToHours } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(TimelogsApp, { + props: { + limitToHours: parseBoolean(limitToHours), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 2593fbe6ed1..7f4c7a91b20 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -5,14 +5,11 @@ export const DEFAULT_SNOWPLOW_OPTIONS = { hostname: window.location.hostname, cookieDomain: window.location.hostname, appId: '', - userFingerprint: false, respectDoNotTrack: true, - forceSecureTracker: true, eventMethod: 'post', contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, - pageUnloadTimer: 10, formTrackingConfig: { forms: { allow: [] }, fields: { allow: [] }, diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js index 5daeaf1d85b..89d90cf89be 100644 --- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -26,7 +26,14 @@ export function dispatchSnowplowEvent( } try { - window.snowplow('trackStructEvent', category, action, label, property, value, contexts); + window.snowplow('trackStructEvent', { + category, + action, + label, + property, + value, + context: contexts, + }); return true; } catch (error) { Sentry.captureException(error); diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index d60eb37a9a2..472ce3c5bbf 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -7,7 +7,7 @@ export { Tracking as default }; /** * Tracker initialization as defined in: - * https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/. + * https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v3/tracker-setup/initialization-options/. * It also dispatches any event emitted before its execution. * * @returns {undefined} @@ -42,13 +42,19 @@ export function initDefaultTrackers() { // must be before initializing the trackers Tracking.setAnonymousUrls(); - window.snowplow('enableActivityTracking', 30, 30); + window.snowplow('enableActivityTracking', { + minimumVisitLength: 30, + heartbeatDelay: 30, + }); // must be after enableActivityTracking const standardContext = getStandardContext(); const experimentContexts = getAllExperimentContexts(); // To not expose personal identifying information, the page title is hardcoded as `GitLab` // See: https://gitlab.com/gitlab-org/gitlab/-/issues/345243 - window.snowplow('trackPageView', 'GitLab', [standardContext, ...experimentContexts]); + window.snowplow('trackPageView', { + title: 'GitLab', + context: [standardContext, ...experimentContexts], + }); window.snowplow('setDocumentTitle', 'GitLab'); if (window.snowplowOptions.formTracking) { diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js index 85f4979e752..b69b1714952 100644 --- a/app/assets/javascripts/tracking/tracker.js +++ b/app/assets/javascripts/tracking/tracker.js @@ -207,14 +207,18 @@ export const Tracker = { const mappedConfig = {}; if (config.forms) { - mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); + mappedConfig.forms = renameKey(config.forms, 'allow', 'allowlist'); } if (config.fields) { - mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); + mappedConfig.fields = renameKey(config.fields, 'allow', 'allowlist'); } - const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); + const enabler = () => + window.snowplow('enableFormTracking', { + options: mappedConfig, + context: userProvidedContexts, + }); if (document.readyState === 'complete') { enabler(); 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 2b97886e650..beff3b4c0c3 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 @@ -9,9 +9,6 @@ import { PROJECT_TABLE_LABEL_USAGE, containerRegistryId, containerRegistryPopoverId, - uploadsId, - uploadsPopoverId, - uploadsPopoverContent, } from '../constants'; import { descendingStorageUsageSort } from '../utils'; import StorageTypeIcon from './storage_type_icon.vue'; @@ -40,10 +37,6 @@ export default { popoverId: containerRegistryPopoverId, popoverContent: this.containerRegistryPopoverContent, }, - [uploadsId]: { - popoverId: uploadsPopoverId, - popoverContent: this.$options.i18n.uploadsPopoverContent, - }, }; return this.storageTypes @@ -77,9 +70,6 @@ export default { thClass: thWidthPercent(10), }, ], - i18n: { - uploadsPopoverContent, - }, }; </script> <template> @@ -100,7 +90,7 @@ export default { :aria-label="helpLinkAriaLabel(item.storageType.name)" :data-testid="`${item.storageType.id}-help-link`" > - <gl-icon name="question" :size="12" /> + <gl-icon name="question-o" :size="12" /> </gl-link> </p> <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue index bc7cd42df1e..5142c2c0915 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue @@ -16,7 +16,6 @@ export default { const storageTypeIconMap = { lfsObjectsSize: 'doc-image', snippetsSize: 'snippet', - uploadsSize: 'upload', repositorySize: 'infrastructure-registry', packagesSize: 'package', }; 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 7e001685060..e9683924ff8 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -1,17 +1,10 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PROJECT_STORAGE_TYPES } from '../constants'; import { descendingStorageUsageSort } from '../utils'; export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, mixins: [glFeatureFlagMixin()], props: { rootStorageStatistics: { @@ -35,9 +28,7 @@ export default { storageSize, wikiSize, snippetsSize, - uploadsSize, } = this.rootStorageStatistics; - const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; if (storageSize === 0) { return null; @@ -70,9 +61,15 @@ export default { }, { id: 'buildArtifactsSize', - style: this.usageStyle(this.barRatio(artifactsSize)), - class: 'gl-bg-data-viz-green-600', - size: artifactsSize, + style: this.usageStyle(this.barRatio(buildArtifactsSize)), + class: 'gl-bg-data-viz-green-500', + size: buildArtifactsSize, + }, + { + id: 'pipelineArtifactsSize', + style: this.usageStyle(this.barRatio(pipelineArtifactsSize)), + class: 'gl-bg-data-viz-green-800', + size: pipelineArtifactsSize, }, { id: 'wikiSize', @@ -86,12 +83,6 @@ export default { class: 'gl-bg-data-viz-orange-800', size: snippetsSize, }, - { - id: 'uploadsSize', - style: this.usageStyle(this.barRatio(uploadsSize)), - class: 'gl-bg-data-viz-aqua-700', - size: uploadsSize, - }, ] .filter((data) => data.size !== 0) .sort(descendingStorageUsageSort('size')) @@ -99,11 +90,10 @@ export default { const storageTypeExtraData = PROJECT_STORAGE_TYPES.find( (type) => storageType.id === type.id, ); - const { name, tooltip } = storageTypeExtraData || {}; + const name = storageTypeExtraData?.name; return { name, - tooltip, ...storageType, }; }); @@ -155,15 +145,6 @@ export default { <span class="gl-text-gray-500 gl-font-sm"> {{ formatSize(storageType.size) }} </span> - <span - v-if="storageType.tooltip" - v-gl-tooltip - :title="storageType.tooltip" - :aria-label="storageType.tooltip" - class="gl-ml-2" - > - <gl-icon name="question" :size="12" /> - </span> </div> </div> </div> diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js index bd8cd372ecf..8e3eaff4496 100644 --- a/app/assets/javascripts/usage_quotas/storage/constants.js +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -8,7 +8,7 @@ export const LEARN_MORE_LABEL = __('Learn more.'); export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); export const TOTAL_USAGE_SUBTITLE = s__( - 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', + 'UsageQuota|Includes artifacts, repositories, wiki, and other items.', ); export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.'); export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); @@ -20,11 +20,6 @@ export const projectContainerRegistryPopoverContent = s__( export const containerRegistryId = 'containerRegistrySize'; export const containerRegistryPopoverId = 'container-registry-popover'; -export const uploadsId = 'uploadsSize'; -export const uploadsPopoverId = 'uploads-popover'; -export const uploadsPopoverContent = s__( - 'NamespaceStorage|Uploads are not counted in namespace storage quotas.', -); export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type'); export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); @@ -32,45 +27,44 @@ export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); export const PROJECT_STORAGE_TYPES = [ { id: 'containerRegistrySize', - name: s__('UsageQuota|Container Registry'), + name: __('Container Registry'), description: s__( 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.', ), }, { id: 'buildArtifactsSize', - name: s__('UsageQuota|Artifacts'), - description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), - tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), + name: __('Job artifacts'), + description: s__('UsageQuota|Job artifacts created by CI/CD.'), + }, + { + id: 'pipelineArtifactsSize', + name: __('Pipeline artifacts'), + description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'), }, { id: 'lfsObjectsSize', - name: s__('UsageQuota|LFS storage'), + name: __('LFS'), description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), }, { id: 'packagesSize', - name: s__('UsageQuota|Packages'), + name: __('Packages'), description: s__('UsageQuota|Code packages and container images.'), }, { id: 'repositorySize', - name: s__('UsageQuota|Repository'), + name: __('Repository'), description: s__('UsageQuota|Git repository.'), }, { id: 'snippetsSize', - name: s__('UsageQuota|Snippets'), + name: __('Snippets'), description: s__('UsageQuota|Shared bits of code and text.'), }, { - id: 'uploadsSize', - name: s__('UsageQuota|Uploads'), - description: s__('UsageQuota|File attachments and smaller design graphics.'), - }, - { id: 'wikiSize', - name: s__('UsageQuota|Wiki'), + name: __('Wiki'), description: s__('UsageQuota|Wiki content.'), }, ]; @@ -86,6 +80,9 @@ export const projectHelpPaths = { buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', { anchor: 'when-job-artifacts-are-deleted', }), + pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', { + anchor: 'when-pipeline-artifacts-are-deleted', + }), packages: helpPagePath('user/packages/package_registry/index.md', { anchor: 'reduce-storage-usage', }), diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql index 6637e5e0865..d254f576219 100644 --- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql +++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql @@ -10,7 +10,6 @@ query getProjectStorageStatistics($fullPath: ID!) { repositorySize snippetsSize storageSize - uploadsSize wikiSize } } diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index a401a9bbf2f..66e54b59187 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -467,6 +467,8 @@ function UsersSelect(currentUser, els, options = {}) { // display:block overrides the hide-collapse rule $value.css('display', ''); } + + $('.dropdown-input-field', $block).val(''); }, multiSelect: $dropdown.hasClass('js-multiselect'), inputMeta: $dropdown.data('inputMeta'), @@ -694,17 +696,18 @@ UsersSelect.prototype.renderRow = function ( : ''; const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : ''; - const name = + const busyBadge = user?.availability && isUserBusy(user.availability) - ? sprintf(__('%{name} (Busy)'), { name: user.name }) - : user.name; + ? `<span class="badge badge-warning badge-pill gl-badge sm">${__('Busy')}</span>` + : ''; return ` <li data-user-id=${user.id} ${dataUserSuggested}> <a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}> ${this.renderRowAvatar(issuableType, user, img)} <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> <strong class="dropdown-menu-user-full-name gl-font-weight-bold"> - ${escape(name)} + ${escape(user.name)} + ${busyBadge} </strong> ${ username diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js index 77736fb6ef5..e30982985b3 100644 --- a/app/assets/javascripts/visibility_level/constants.js +++ b/app/assets/javascripts/visibility_level/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private'; export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal'; export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public'; @@ -18,3 +20,33 @@ export const VISIBILITY_LEVELS_INTEGER_TO_STRING = { [VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING, [VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING, }; + +export const GROUP_VISIBILITY_TYPE = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: __( + 'Public - The group and any public projects can be viewed without any authentication.', + ), + [VISIBILITY_LEVEL_INTERNAL_STRING]: __( + 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', + ), + [VISIBILITY_LEVEL_PRIVATE_STRING]: __( + 'Private - The group and its projects can only be viewed by members.', + ), +}; + +export const PROJECT_VISIBILITY_TYPE = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: __( + 'Public - The project can be accessed without any authentication.', + ), + [VISIBILITY_LEVEL_INTERNAL_STRING]: __( + 'Internal - The project can be accessed by any logged in user except external users.', + ), + [VISIBILITY_LEVEL_PRIVATE_STRING]: __( + 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', + ), +}; + +export const VISIBILITY_TYPE_ICON = { + [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth', + [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', + [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue index f377a185879..5090081d281 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -1,6 +1,7 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { escape } from 'lodash'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { n__, s__, sprintf } from '~/locale'; @@ -49,7 +50,7 @@ export default { }, computed: { isMerged() { - return this.state === 'merged'; + return this.state === STATUS_MERGED; }, targetBranchEscaped() { return escape(this.targetBranch); @@ -67,7 +68,7 @@ export default { ); }, message() { - if (this.state === 'closed') { + if (this.state === STATUS_CLOSED) { return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.'); } else if (this.isMerged) { return s__( diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 74922dd922c..25cf5335fb5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -1,15 +1,15 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; import { createAlert } from '~/alert'; +import { STATUS_MERGED } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; -import MrWidgetContainer from '../mr_widget_container.vue'; -import MrWidgetIcon from '../mr_widget_icon.vue'; +import StateContainer from '../state_container.vue'; import { INVALID_RULES_DOCS_PATH } from '../../constants'; import ApprovalsSummary from './approvals_summary.vue'; import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; @@ -18,14 +18,17 @@ import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; export default { name: 'MRWidgetApprovals', components: { - MrWidgetContainer, - MrWidgetIcon, ApprovalsSummary, ApprovalsSummaryOptional, + StateContainer, GlButton, GlSprintf, }, mixins: [approvalsMixin, glFeatureFlagsMixin()], + provide: { + expandDetailsTooltip: __('Expand eligible approvers'), + collapseDetailsTooltip: __('Collapse eligible approvers'), + }, props: { mr: { type: Object, @@ -55,6 +58,11 @@ export default { required: false, default: false, }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -76,13 +84,22 @@ export default { return Boolean(this.action); }, invalidRules() { - return this.approvals.approvalState?.invalidApproversRules || []; + return this.approvals.approvalState?.rules?.filter((rule) => rule.invalid) || []; + }, + invalidApprovedRules() { + return this.invalidRules.filter((rule) => rule.allowMergeWhenInvalid); + }, + invalidFailedRules() { + return this.invalidRules.filter((rule) => !rule.allowMergeWhenInvalid); }, hasInvalidRules() { return this.mr.mergeRequestApproversAvailable && this.invalidRules.length; }, - invalidRulesText() { - return this.invalidRules.length; + hasInvalidApprovedRules() { + return this.mr.mergeRequestApproversAvailable && this.invalidApprovedRules.length; + }, + hasInvalidFailedRules() { + return this.mr.mergeRequestApproversAvailable && this.invalidFailedRules.length; }, approvedBy() { return this.approvals.approvedBy?.nodes || []; @@ -99,7 +116,7 @@ export default { return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; }, showUnapprove() { - return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; + return this.userHasApproved && !this.userCanApprove && this.mr.state !== STATUS_MERGED; }, approvalText() { return this.isApproved && this.approvedBy.length > 0 @@ -125,11 +142,29 @@ export default { return null; }, - pluralizedRuleText() { - return this.invalidRules.length > 1 + pluralizedApprovedRuleText() { + return this.invalidApprovedRules.length > 1 ? this.$options.i18n.invalidRulesPlural : this.$options.i18n.invalidRuleSingular; }, + pluralizedFailedRuleText() { + return this.invalidFailedRules.length > 1 + ? this.$options.i18n.invalidFailedRulesPlural + : this.$options.i18n.invalidFailedRuleSingular; + }, + pluralizedRuleText() { + return [ + this.hasInvalidFailedRules + ? sprintf(this.pluralizedFailedRuleText, { rules: this.invalidFailedRules.length }) + : null, + this.hasInvalidApprovedRules + ? sprintf(this.pluralizedApprovedRuleText, { rules: this.invalidApprovedRules.length }) + : null, + ] + .filter((text) => Boolean(text)) + .join(', ') + .concat('.'); + }, }, methods: { approve() { @@ -197,21 +232,30 @@ export default { FETCH_LOADING, linkToInvalidRules: INVALID_RULES_DOCS_PATH, i18n: { - invalidRuleSingular: s__( - 'mrWidget|%{rules} invalid rule has been approved automatically, as no one can approve it.', + invalidRuleSingular: s__('mrWidget|%{rules} invalid rule has been approved automatically'), + invalidRulesPlural: s__('mrWidget|%{rules} invalid rules have been approved automatically'), + invalidFailedRuleSingular: s__( + "mrWidget|%{dangerStart}%{rules} rule can't be approved%{dangerEnd}", ), - invalidRulesPlural: s__( - 'mrWidget|%{rules} invalid rules have been approved automatically, as no one can approve them.', + invalidFailedRulesPlural: s__( + "mrWidget|%{dangerStart}%{rules} rules can't be approved%{dangerEnd}", ), learnMore: __('Learn more.'), }, }; </script> <template> - <mr-widget-container> - <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> - <mr-widget-icon name="approval" /> - <div v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</div> + <div class="js-mr-approvals mr-section-container mr-widget-workflow"> + <state-container + :is-loading="$apollo.queries.approvals.loading" + :mr="mr" + status="approval" + is-collapsible + collapse-on-desktop + :collapsed="collapsed" + @toggle="() => $emit('toggle')" + > + <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template> <template v-else> <div class="gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> @@ -220,7 +264,7 @@ export default { :variant="action.variant" :category="action.category" :loading="isApproving" - class="gl-mr-5" + class="gl-mr-3" data-qa-selector="approve_button" @click="action.action" > @@ -234,12 +278,15 @@ export default { <approvals-summary v-else :approval-state="approvals" + :disable-committers-approval="disableCommittersApproval" :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable" /> </div> <div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules"> <gl-sprintf :message="pluralizedRuleText"> - <template #rules>{{ invalidRulesText }}</template> + <template #danger="{ content }"> + <span class="gl-font-weight-bold text-danger">{{ content }}</span> + </template> </gl-sprintf> </div> </div> @@ -249,9 +296,7 @@ export default { :has-approval-auth-error="hasApprovalAuthError" ></slot> </template> - </div> - <template #footer> - <slot name="footer"></slot> - </template> - </mr-widget-container> + </state-container> + <slot name="footer"></slot> + </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 2af033bb80f..650fa798db6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -1,4 +1,5 @@ <script> +import { GlLink, GlPopover } from '@gitlab/ui'; import { toNounSeriesText } from '~/lib/utils/grammar'; import { n__, sprintf } from '~/locale'; import { @@ -12,6 +13,8 @@ import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/ma export default { components: { + GlLink, + GlPopover, UserAvatarList, }, props: { @@ -24,6 +27,11 @@ export default { type: Object, required: true, }, + disableCommittersApproval: { + type: Boolean, + required: false, + default: false, + }, }, computed: { approvers() { @@ -101,6 +109,13 @@ export default { (approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId, ); }, + currentUserHasCommitted() { + if (!this.currentUserId) return false; + + return this.approvalState.committers?.nodes?.some( + (user) => getIdFromGraphQLId(user.id) === this.currentUserId, + ); + }, currentUserId() { return gon.current_user_id; }, @@ -115,10 +130,18 @@ export default { <span v-if="approvalLeftMessage">{{ message }}</span> <span v-else class="gl-font-weight-bold">{{ message }}</span> <user-avatar-list - class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" + class="gl-display-inline-flex gl-vertical-align-middle" :img-size="24" :items="approvers" /> </template> + <template v-if="disableCommittersApproval && currentUserHasCommitted"> + <gl-link id="cant-approve-popover" data-testid="commit-cant-approve" class="gl-cursor-help">{{ + __("Why can't I approve?") + }}</gl-link> + <gl-popover target="cant-approve-popover"> + {{ __("You can't approve because you added one or more commits to this merge request.") }} + </gl-popover> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue index f9d0986d60d..1e5f91e12cf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -60,7 +60,7 @@ export default { > <div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto"> <div class="gl-display-flex gl-m-auto gl-translate-y-n50"> - <gl-loading-icon v-if="isLoading" size="md" inline /> + <gl-loading-icon v-if="isLoading" size="sm" inline /> <gl-icon v-else :name="$options.EXTENSION_ICON_NAMES[iconName]" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js index e3f87c08ad4..4f8f8d6cb58 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js @@ -6,86 +6,6 @@ import { TELEMETRY_WIDGET_FULL_REPORT_CLICKED, } from '../../constants'; -/* - * Additional events to send beyond the defaults for certain widget extensions - */ -const nonStandardEvents = { - codeQuality: { - uniqueUser: { - expand: ['i_testing_code_quality_widget_total'], - }, - counter: {}, - }, - terraform: { - uniqueUser: { - expand: ['i_testing_terraform_widget_total'], - }, - counter: {}, - }, - issues: { - uniqueUser: { - expand: ['i_testing_issues_widget_total'], - }, - counter: {}, - }, - testSummary: { - uniqueUser: { - expand: ['i_testing_summary_widget_total'], - }, - counter: {}, - }, - metrics: { - uniqueUser: { - expand: ['i_testing_metrics_report_widget_total'], - }, - counter: {}, - }, - browserPerformance: { - uniqueUser: { - expand: ['i_testing_web_performance_widget_total'], - }, - counter: {}, - }, - licenseCompliance: { - uniqueUser: { - expand: ['i_testing_license_compliance_widget_total'], - }, - counter: {}, - }, - loadPerformance: { - uniqueUser: { - expand: ['i_testing_load_performance_widget_total'], - }, - counter: {}, - }, - statusChecks: { - uniqueUser: { - expand: ['i_testing_status_checks_widget'], - }, - counter: {}, - }, -}; - -function combineDeepArray(path, ...objects) { - const parts = path.split('.'); - const allEntries = objects.reduce((entries, currentObject) => { - let expandedEntries = entries; - let traversed = currentObject; - - parts.forEach((part) => { - traversed = traversed?.[part]; - }); - - if (traversed) { - expandedEntries = [...entries, ...traversed]; - } - - return expandedEntries; - }, []); - - return Array.from(new Set(allEntries)); -} - function simplifyWidgetName(componentName) { const noWidget = componentName.replace(/^Widget/, ''); @@ -166,7 +86,6 @@ function defaultBehaviorEvents({ bus, config }) { function baseTelemetry(componentName) { const simpleExtensionName = simplifyWidgetName(componentName); - const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {}; /* * Telemetry config format is: * { @@ -179,7 +98,7 @@ function baseTelemetry(componentName) { * - uniqueUser is sent to RedisHLL * - counter is sent to a regular Redis counter */ - const defaultTelemetry = { + return { uniqueUser: { view: [`${baseRedisEventName(simpleExtensionName)}_view`], expand: [`${baseRedisEventName(simpleExtensionName)}_expand`], @@ -191,27 +110,6 @@ function baseTelemetry(componentName) { clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`], }, }; - - return { - uniqueUser: { - view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard), - expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard), - clickFullReport: combineDeepArray( - 'uniqueUser.clickFullReport', - defaultTelemetry, - additionalNonStandard, - ), - }, - counter: { - view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard), - expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard), - clickFullReport: combineDeepArray( - 'counter.clickFullReport', - defaultTelemetry, - additionalNonStandard, - ), - }, - }; } export function createTelemetryHub(componentName) { 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 20284c4a3d8..26527361b2e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/require-i18n-strings */ import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { escapeShellString } from '~/lib/utils/text_utility'; @@ -87,11 +86,11 @@ export default { const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`); return this.isFork - ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` - : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; + ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` // eslint-disable-line @gitlab/require-i18n-strings + : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings }, mergeInfo2() { - return `git push origin ${this.escapedSourceBranch}`; + return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings }, escapedForkBranch() { return escapeShellString(`${this.sourceProjectPath}-${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 2dec95c3fda..4e16b92fc05 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 @@ -173,7 +173,7 @@ export default { </p> </template> <template v-else-if="!hasPipeline"> - <gl-loading-icon size="md" /> + <gl-loading-icon size="sm" /> <p class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0" data-testid="monitoring-pipeline-message" @@ -187,7 +187,7 @@ export default { class="gl-display-flex gl-align-items-center gl-ml-2" > <gl-icon - name="question" + name="question-o" :aria-label="__('Link to go to GitLab pipeline documentation')" /> </gl-link> @@ -251,7 +251,7 @@ export default { </span> {{ pipelineCoverageJobNumberText }} <span ref="pipelineCoverageQuestion"> - <gl-icon name="question" :size="12" /> + <gl-icon name="question-o" :size="12" /> </span> <gl-tooltip :target="() => $refs.pipelineCoverageQuestion" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 1fd1e264c25..5d75f1d27b1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,5 +1,6 @@ <script> import { GlLink } from '@gitlab/ui'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; @@ -30,10 +31,10 @@ export default { }, computed: { closesText() { - if (this.state === 'merged') { + if (this.state === STATUS_MERGED) { return s__('mrWidget|Closed'); } - if (this.state === 'closed') { + if (this.state === STATUS_CLOSED) { return s__('mrWidget|Did not close'); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 3239285e53e..ea3f324b8f2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import StatusIcon from './extensions/status_icon.vue'; export default { @@ -14,22 +15,24 @@ export default { }, }, computed: { + isClosed() { + return this.status === STATUS_CLOSED; + }, isLoading() { return this.status === 'loading'; }, + isMerged() { + return this.status === STATUS_MERGED; + }, }, }; </script> <template> - <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3"> + <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-center gl-mr-3"> <div class="gl-display-flex gl-m-auto"> - <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" /> - <gl-icon - v-else-if="status === 'closed'" - name="merge-request-close" - :size="16" - class="gl-text-red-500" - /> + <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" /> + <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" /> + <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" /> <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 3e79c49994f..dd899701de0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import StatusIcon from './mr_widget_status_icon.vue'; import Actions from './action_buttons.vue'; @@ -13,7 +13,30 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + expandDetailsTooltip: { + default: '', + }, + collapseDetailsTooltip: { + default: '', + }, + }, props: { + isCollapsible: { + type: Boolean, + required: false, + default: false, + }, + collapseOnDesktop: { + type: Boolean, + required: false, + default: false, + }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, mr: { type: Object, required: false, @@ -35,14 +58,10 @@ export default { default: () => [], }, }, - i18n: { - expandDetailsTooltip: __('Expand merge details'), - collapseDetailsTooltip: __('Collapse merge details'), - }, computed: { wrapperClasses() { - if (this.status === 'merged') return 'gl-bg-blue-50'; - if (this.status === 'closed') return 'gl-bg-red-50'; + if (this.status === STATUS_MERGED) return 'gl-bg-blue-50'; + if (this.status === STATUS_CLOSED) return 'gl-bg-red-50'; return null; }, hasActionsSlot() { @@ -54,11 +73,11 @@ export default { <template> <div - class="mr-widget-body media gl-display-flex gl-align-items-center" + class="mr-widget-body media gl-display-flex gl-align-items-center gl-pl-5 gl-pr-4 gl-py-4" :class="wrapperClasses" v-on="$listeners" > - <div v-if="isLoading" class="gl-w-full mr-conflict-loader"> + <div v-if="isLoading" class="gl-w-full mr-state-loader"> <slot name="loading"> <div class="gl-display-flex"> <status-icon status="loading" /> @@ -94,21 +113,19 @@ export default { </div> </div> <div - v-if="mr" - class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1" + v-if="isCollapsible" + :class="{ 'gl-md-display-none': !collapseOnDesktop }" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" > <gl-button v-gl-tooltip - :title=" - mr.mergeDetailsCollapsed - ? $options.i18n.expandDetailsTooltip - : $options.i18n.collapseDetailsTooltip - " - :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" + :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'" category="tertiary" size="small" class="gl-vertical-align-top" - @click="() => mr.toggleMergeDetails()" + data-testid="widget-toggle" + @click="() => $emit('toggle')" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 6d7ec607557..61eec503951 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -43,7 +43,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> <bold-text :message="failedText" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 837f8b32637..722efe2e6d2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <bold-text :message="$options.message" /> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index bcae1a12344..6299f0fcbb8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -131,16 +131,23 @@ export default { }; </script> <template> - <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions"> + <state-container + status="scheduled" + :is-loading="loading" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> - <gl-skeleton-loader :width="334" :height="30"> - <rect x="0" y="3" width="24" height="24" rx="4" /> - <rect x="32" y="7" width="150" height="16" rx="4" /> - <rect x="190" y="7" width="144" height="16" rx="4" /> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="150" height="20" rx="4" /> + <rect x="190" y="2" width="144" height="20" rx="4" /> </gl-skeleton-loader> </template> <template v-if="!loading"> - <h4 class="gl-mr-3" data-testid="statusText"> + <h4 class="gl-mr-3 gl-flex-grow-1" data-testid="statusText"> <gl-sprintf :message="statusText" data-testid="statusText"> <template #merge_author> <mr-widget-author v-if="state.mergeUser" :author="state.mergeUser" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 448805cf8b9..db5ef6c1a0e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -54,7 +54,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="failed" :actions="actions"> + <state-container + status="failed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-font-weight-bold"> <template v-if="mergeError">{{ mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index 670bd36d61e..d4b7d60568b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -15,7 +15,12 @@ export default { }; </script> <template> - <state-container :mr="mr" status="loading"> + <state-container + status="loading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > {{ s__('mrWidget|Checking if merge request can be merged…') }} </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 6bcf88713a5..aebba67b39a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -79,7 +79,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="closed" :actions="actions"> + <state-container + status="closed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <mr-widget-author-time :action-text="s__('mrWidget|Closed by')" :author="mr.metrics.closedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 83d718f5a54..55ae390216d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -72,12 +72,18 @@ export default { }; </script> <template> - <state-container :mr="mr" status="failed" :is-loading="isLoading"> + <state-container + status="failed" + :is-loading="isLoading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> - <gl-skeleton-loader :width="334" :height="30"> - <rect x="0" y="7" width="150" height="16" rx="4" /> - <rect x="158" y="7" width="84" height="16" rx="4" /> - <rect x="250" y="7" width="84" height="16" rx="4" /> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="150" height="20" rx="4" /> + <rect x="190" y="2" width="144" height="20" rx="4" /> </gl-skeleton-loader> </template> <template v-if="!isLoading"> @@ -106,7 +112,7 @@ export default { v-if="userPermissions.canMerge" size="small" variant="confirm" - category="secondary" + category="tertiary" data-testid="merge-locally-button" class="js-check-out-modal-trigger gl-align-self-start" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index bfc2c282f4c..742f5d4de14 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -95,12 +95,25 @@ export default { }; </script> <template> - <state-container v-if="isRefreshing" :mr="mr" status="loading"> + <state-container + v-if="isRefreshing" + status="loading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-font-weight-bold"> {{ s__('mrWidget|Refreshing now') }} </span> </state-container> - <state-container v-else :mr="mr" status="failed" :actions="actions"> + <state-container + v-else + status="failed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span v-if="mr.mergeError" class="has-error-message gl-font-weight-bold" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 4e2b12799d0..4d906f29cb0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -150,7 +150,13 @@ export default { }; </script> <template> - <state-container :mr="mr" :actions="actions" status="merged"> + <state-container + :actions="actions" + status="merged" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <mr-widget-author-time :action-text="s__('mrWidget|Merged by')" :author="mr.metrics.mergedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index c94718ca756..17c51bc4e6e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,5 +1,6 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import { STATUS_MERGED } from '~/issues/constants'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; @@ -50,7 +51,7 @@ export default { .poll() .then((res) => res.data) .then((data) => { - if (data.state === 'merged') { + if (data.state === STATUS_MERGED) { // If state is merged we should update the widget and stop the polling eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('FetchActionsContent'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index fac8d37712a..415f58ea8e6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -195,11 +195,17 @@ export default { </script> <template> <div> - <state-container :mr="mr" :status="status" :is-loading="isLoading"> + <state-container + :status="status" + :is-loading="isLoading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> - <gl-skeleton-loader :width="334" :height="30"> - <rect x="0" y="3" width="24" height="24" rx="4" /> - <rect x="32" y="5" width="302" height="20" rx="4" /> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="302" height="20" rx="4" /> </gl-skeleton-loader> </template> <template v-if="!isLoading"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 9e67791afc0..a3c529de27c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -16,6 +16,7 @@ import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_ import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import { createAlert } from '~/alert'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; import { __, s__, n__ } from '~/locale'; @@ -23,6 +24,7 @@ import SmartInterval from '~/smart_interval'; import { helpPagePath } from '~/helpers/help_page_helper'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { AUTO_MERGE_STRATEGIES, WARNING, @@ -143,6 +145,7 @@ export default { ), AddedCommitMessage, RelatedLinks, + HelpPopover, }, directives: { GlTooltip: GlTooltipDirective, @@ -261,7 +264,10 @@ export default { if (this.isMergingImmediately) { return __('Merge in progress'); } - if (this.isAutoMergeAvailable) { + if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) { + return this.autoMergeTextLegacy; + } + if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) { return this.autoMergeText; } @@ -271,9 +277,24 @@ export default { return __('Merge'); }, + autoMergeLabelsEnabled() { + return window.gon?.features?.autoMergeLabelsMrWidget; + }, + showAutoMergeHelperText() { + return ( + !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) && + this.isAutoMergeAvailable + ); + }, hasPipelineMustSucceedConflict() { return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds; }, + isNotClosed() { + return this.mr.state !== STATUS_CLOSED; + }, + isNeitherClosedNorMerged() { + return this.mr.state !== STATUS_CLOSED && this.mr.state !== STATUS_MERGED; + }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -307,7 +328,7 @@ export default { ); }, sourceBranchDeletedText() { - const isPreMerge = this.mr.state !== 'merged'; + const isPreMerge = this.mr.state !== STATUS_MERGED; if (isPreMerge) { return this.mr.shouldRemoveSourceBranch @@ -325,6 +346,11 @@ export default { showMergeDetailsHeader() { return !['readyToMerge'].includes(this.mr.state); }, + autoMergeHelpPopoverOptions() { + return { + title: this.autoMergePopoverSettings.title, + }; + }, }, mounted() { eventHub.$on('ApprovalUpdated', this.updateGraphqlState); @@ -495,17 +521,19 @@ export default { <template> <div - :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }" + :class="{ 'gl-bg-gray-10': isNeitherClosedNorMerged }" data-testid="ready_to_merge_state" class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7" > <div v-if="loading" class="mr-widget-body"> <div class="gl-w-full mr-ready-to-merge-loader"> - <gl-skeleton-loader :width="418" :height="30"> - <rect x="0" y="3" width="24" height="24" rx="4" /> - <rect x="32" y="0" width="70" height="30" rx="4" /> - <rect x="110" y="7" width="150" height="16" rx="4" /> - <rect x="268" y="7" width="150" height="16" rx="4" /> + <gl-skeleton-loader :width="418" :height="86"> + <rect x="0" y="0" width="144" height="20" rx="4" /> + <rect x="0" y="26" width="100" height="16" rx="4" /> + <rect x="108" y="26" width="100" height="16" rx="4" /> + <rect x="0" y="48" width="130" height="16" rx="4" /> + <rect x="0" y="70" width="80" height="16" rx="4" /> + <rect x="88" y="70" width="90" height="16" rx="4" /> </gl-skeleton-loader> </div> </div> @@ -517,7 +545,7 @@ export default { <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> <template v-if="shouldShowMergeControls"> <div - class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-5" + class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-2" > <gl-form-checkbox v-if="canRemoveSourceBranch" @@ -587,9 +615,7 @@ export default { </li> </ul> </div> - <div - class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details" - > + <div class="gl-w-full gl-text-gray-500 gl-mb-3 mr-widget-merge-details"> <template v-if="sourceHasDivergedFromTarget"> <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> <template #link> @@ -670,7 +696,31 @@ export default { @cancel="isPipelineFailedModalVisibleNormalMerge = false" /> </gl-button-group> - <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> + <merge-train-helper-icon + v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled" + class="gl-mx-3" + /> + <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled"> + <div + class="gl-ml-4 gl-text-gray-500 gl-font-sm" + data-qa-selector="auto_merge_helper_text" + > + {{ autoMergeHelperText }} + </div> + <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions"> + <gl-sprintf :message="autoMergePopoverSettings.bodyText"> + <template #link="{ content }"> + <gl-link + :href="autoMergePopoverSettings.helpLink" + target="_blank" + class="gl-font-sm" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </help-popover> + </template> </template> <div v-else @@ -702,7 +752,7 @@ export default { /> </li> <li - v-if="mr.state !== 'closed'" + v-if="isNotClosed" class="gl-line-height-normal" data-testid="source-branch-deleted-text" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 2aa345b420e..9da754d01fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" data-qa-selector="head_mismatch_content" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 0fd5551979d..af036c01032 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import StateContainer from '../state_container.vue'; const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.'); @@ -15,6 +16,7 @@ export default { GlButton, StateContainer, }, + mixins: [glFeatureFlagsMixin()], props: { mr: { type: Object, @@ -30,7 +32,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> <bold-text :message="$options.message" /> </span> @@ -43,17 +50,17 @@ export default { category="primary" @click="jumpToFirstUnresolvedDiscussion" > - {{ s__('mrWidget|Jump to first unresolved thread') }} + {{ s__('mrWidget|Go to first unresolved thread') }} </gl-button> <gl-button - v-if="mr.createIssueToResolveDiscussionsPath" + v-if="mr.createIssueToResolveDiscussionsPath && !glFeatures.hideCreateIssueResolveAll" :href="mr.createIssueToResolveDiscussionsPath" class="js-create-issue gl-align-self-start gl-vertical-align-top" size="small" variant="confirm" category="secondary" > - {{ s__('mrWidget|Create issue to resolve all threads') }} + {{ s__('mrWidget|Resolve all with new issue') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 7163e54985e..7fc4a06cbae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -137,7 +137,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-0! gl-text-body! gl-flex-grow-1"> <bold-text :message="$options.i18n.removeDraftStatus" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue index 6d17ac98d7f..4e8098677cc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue @@ -52,7 +52,7 @@ export default { }" class="gl-relative gl-rounded-full gl-mr-3" > - <gl-loading-icon v-if="isLoading" size="md" inline /> + <gl-loading-icon v-if="isLoading" size="sm" inline /> <gl-icon v-else :name="$options.EXTENSION_ICON_NAMES[iconName]" 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 a754d4e80ea..54eb15c8ac8 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 @@ -219,6 +219,8 @@ export default { this.fetchExpandedContent(); } } + + this.$emit('toggle', { expanded: !this.isCollapsed }); }, async fetchExpandedContent() { this.isLoadingExpandedContent = true; @@ -287,7 +289,7 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="gl-px-5 gl-py-4 gl-align-items-center gl-display-flex"> + <div class="gl-px-5 gl-pr-4 gl-py-4 gl-align-items-center gl-display-flex"> <status-icon :level="1" :name="widgetName" @@ -346,6 +348,7 @@ export default { category="tertiary" data-testid="toggle-button" size="small" + data-qa-selector="expand_report_button" @click="toggleCollapsed" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index ff225afbc7b..d2f2d394a1f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import { STATUS_CLOSED } from '~/issues/constants'; import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; @@ -82,7 +83,7 @@ export default { // Icon to get rendered on the side of each row icon: { // Required: Name maps to an icon in GitLabs SVG - name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success, + name: issue.state === STATUS_CLOSED ? EXTENSION_ICONS.error : EXTENSION_ICONS.success, }, // Badges get rendered next to the text on each row // badge: issue.state === 'closed' && { diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 183f450854a..a2f088a7a58 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,7 +1,3 @@ -// This is a false violation of @gitlab/no-runtime-template-compiler, since it -// creates a new Vue instance by spreading a _valid_ Vue component definition -// into the Vue constructor. -/* eslint-disable @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; @@ -33,6 +29,10 @@ export default () => { gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; + // This is a false violation of @gitlab/no-runtime-template-compiler, since it + // creates a new Vue instance by spreading a _valid_ Vue component definition + // into the Vue constructor. + // eslint-disable-next-line @gitlab/no-runtime-template-compiler const vm = new Vue({ el: '#js-vue-mr-widget', provide: { diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js index ae9111b9504..7e658e77d37 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -16,6 +16,8 @@ export default { result({ data }) { const { mergeRequest } = data.project; + this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval; + this.mr.setApprovals(mergeRequest); }, error() { @@ -29,6 +31,7 @@ export default { return { alerts: [], approvals: {}, + disableCommittersApproval: false, }; }, methods: { diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index d964b4bacac..10a54c73273 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,3 +1,4 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); @@ -30,10 +31,25 @@ export default { pipelineMustSucceedConflictText() { return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT; }, - autoMergeText() { + autoMergeTextLegacy() { // MWPS is currently the only auto merge strategy available in CE return __('Merge when pipeline succeeds'); }, + autoMergeText() { + return __('Set to auto-merge'); + }, + autoMergeHelperText() { + return __('Merge when pipeline succeeds'); + }, + autoMergePopoverSettings() { + return { + helpLink: helpPagePath('/user/project/merge_requests/merge_when_pipeline_succeeds.html'), + bodyText: __( + 'When the pipeline for this merge request succeeds, it will %{linkStart}automatically merge%{linkEnd}.', + ), + title: __('Merge when pipeline succeeds'), + }; + }, shouldShowMergeImmediatelyDropdown() { return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds; }, 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 bbad2c13220..6e0ee1cb912 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 @@ -10,9 +10,9 @@ import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_wid import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import { createAlert } from '~/alert'; +import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; -import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -155,6 +155,10 @@ export default { }, }, mixins: [mergeRequestQueryVariablesMixin], + provide: { + expandDetailsTooltip: __('Expand merge details'), + collapseDetailsTooltip: __('Collapse merge details'), + }, props: { mrData: { type: Object, @@ -226,7 +230,7 @@ export default { return this.mr.allowCollaboration && this.mr.isOpen; }, shouldRenderMergedPipeline() { - return this.mr.state === 'merged' && !isEmpty(this.mr.mergePipeline); + return this.mr.state === STATUS_MERGED && !isEmpty(this.mr.mergePipeline); }, showMergePipelineForkWarning() { return Boolean( @@ -264,7 +268,7 @@ export default { return (this.mr.humanAccess || '').toLowerCase(); }, hasMergeError() { - return this.mr.mergeError && this.state !== 'closed'; + return this.mr.mergeError && this.state !== STATUS_CLOSED; }, hasAlerts() { return this.hasMergeError || this.showMergePipelineForkWarning; @@ -416,8 +420,8 @@ export default { ); }, setFaviconHelper() { - if (this.mr.ciStatusFaviconPath) { - return setFaviconOverlay(this.mr.ciStatusFaviconPath); + if (this.mr.faviconOverlayPath) { + return setFaviconOverlay(this.mr.faviconOverlayPath); } return Promise.resolve(); }, @@ -474,7 +478,6 @@ export default { el.innerHTML = res.data; document.body.appendChild(el); document.dispatchEvent(new CustomEvent('merged:UpdateActions')); - Project.initRefSwitcher(); } }) .catch(() => 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 13009651550..a7758191315 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,6 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { badgeState } from '~/issuable/components/status_box.vue'; +import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { machine } from '~/lib/utils/finite_state_machine'; import { @@ -121,7 +122,7 @@ export default class MergeRequestStore { this.ffOnlyEnabled = data.ff_only_enabled; this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; this.mergeRequestState = data.state; - this.isOpen = this.mergeRequestState === 'opened'; + this.isOpen = this.mergeRequestState === STATUS_OPEN; this.latestSHA = data.diff_head_sha; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; @@ -139,7 +140,7 @@ export default class MergeRequestStore { this.isPipelineActive = data.pipeline ? data.pipeline.active : false; this.isPipelineBlocked = data.only_allow_merge_if_pipeline_succeeds && pipelineStatus?.group === 'manual'; - this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + this.faviconOverlayPath = data.favicon_overlay_path; this.terraformReportsPath = data.terraform_reports_path; this.testResultsPath = data.test_reports_path; this.accessibilityReportPath = data.accessibility_report_path; @@ -236,11 +237,11 @@ export default class MergeRequestStore { this.state = getStateKey.call(this); } else { switch (this.mergeRequestState) { - case 'merged': - this.state = 'merged'; + case STATUS_MERGED: + this.state = STATUS_MERGED; break; - case 'closed': - this.state = 'closed'; + case STATUS_CLOSED: + this.state = STATUS_CLOSED; break; default: this.state = null; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue deleted file mode 100644 index 9d5006564ef..00000000000 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import * as Sentry from '@sentry/browser'; -import Vue from 'vue'; -import Vuex from 'vuex'; - -Vue.use(Vuex); - -export default { - props: { - dashboardUrl: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - metricEmbedComponent: null, - namespace: 'alertMetrics', - }; - }, - mounted() { - if (this.dashboardUrl) { - Promise.all([ - import('~/monitoring/components/embeds/metric_embed.vue'), - import('~/monitoring/stores'), - ]) - .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => { - this.$store = new Vuex.Store({ - modules: { - [this.namespace]: monitoringDashboard, - }, - }); - this.metricEmbedComponent = MetricEmbed; - }) - .catch((e) => Sentry.captureException(e)); - } - }, -}; -</script> - -<template> - <div class="gl-py-3"> - <div v-if="dashboardUrl" ref="metricsChart"> - <component - :is="metricEmbedComponent" - v-if="metricEmbedComponent" - :dashboard-url="dashboardUrl" - :namespace="namespace" - /> - </div> - <div v-else ref="emptyState"> - {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }} - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue index f2c27cf611e..0577279cdd0 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -53,11 +53,11 @@ export default { }, methods: { updateToDoCount(add) { - const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10); + const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10) || 0; const count = add ? oldCount + 1 : oldCount - 1; const headerTodoEvent = new CustomEvent('todo:toggle', { detail: { - count, + count: Math.max(count, 0), }, }); diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js index d106f545c61..4ee8d19770d 100644 --- a/app/assets/javascripts/vue_shared/alert_details/constants.js +++ b/app/assets/javascripts/vue_shared/alert_details/constants.js @@ -9,7 +9,8 @@ export const SEVERITY_LEVELS = { UNKNOWN: s__('severity|Unknown'), }; -/* eslint-disable @gitlab/require-i18n-strings */ +const category = 'Alert Management'; // eslint-disable-line @gitlab/require-i18n-strings + export const PAGE_CONFIG = { OPERATIONS: { TITLE: 'OPERATIONS', @@ -20,14 +21,14 @@ export const PAGE_CONFIG = { }, // Tracks snowplow event when user views alert details TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: { - category: 'Alert Management', + category, action: 'view_alert_details', }, // Tracks snowplow event when alert status is updated TRACK_ALERT_STATUS_UPDATE_OPTIONS: { - category: 'Alert Management', + category, action: 'update_alert_status', - label: 'Status', + label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index cb38b3e13bb..8f1f7ba0ad8 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -35,11 +35,6 @@ export default { required: false, default: NO_USER_ID, }, - addButtonClass: { - type: String, - required: false, - default: '', - }, defaultAwards: { type: Array, required: false, @@ -50,6 +45,11 @@ export default { required: false, default: 'selected', }, + boundary: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -201,6 +201,8 @@ export default { v-gl-tooltip.viewport :title="__('Add reaction')" :toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]" + :right="false" + :boundary="boundary" data-testid="emoji-picker" @click="handleAward" @shown="setIsMenuOpen(true)" 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 c89e843b660..b4751d51fcb 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,4 +1,5 @@ <script> +import { v4 as uuidv4 } from 'uuid'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { CHART_CONTAINER_HEIGHT } from './constants'; @@ -17,6 +18,15 @@ export default { required: true, }, }, + data: () => ({ + chartKey: uuidv4(), + }), + watch: { + chartData() { + // Re-render area chart when the data changes + this.chartKey = uuidv4(); + }, + }, chartContainerHeight: CHART_CONTAINER_HEIGHT, }; </script> @@ -27,6 +37,7 @@ export default { </p> <gl-area-chart v-bind="$attrs" + :key="chartKey" responsive width="auto" :height="$options.chartContainerHeight" 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 47b96934420..a30b18348ec 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 @@ -39,11 +39,10 @@ export default { </script> <template> <div> - <segmented-control-button-group - v-model="selectedChart" - :options="chartRanges" - class="gl-mb-4" - /> + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <segmented-control-button-group v-model="selectedChart" :options="chartRanges" /> + <slot name="extend-button-group"></slot> + </div> <ci-cd-analytics-area-chart v-if="chart" v-bind="$attrs" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js new file mode 100644 index 00000000000..97143d90c3f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js @@ -0,0 +1,11 @@ +import { RENAMED_DIFF_TRANSITIONS } from '~/diffs/constants'; + +export const transition = (currentState, transitionEvent) => { + const key = `${currentState}:${transitionEvent}`; + + if (RENAMED_DIFF_TRANSITIONS[key]) { + return RENAMED_DIFF_TRANSITIONS[key]; + } + + return currentState; +}; 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 b786f7752df..f7b817423de 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 @@ -10,15 +10,14 @@ import { STATE_IDLING, STATE_LOADING, STATE_ERRORED, - RENAMED_DIFF_TRANSITIONS, } from '~/diffs/constants'; import { truncateSha } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; +import { transition } from '../utils'; export default { STATE_LOADING, STATE_ERRORED, - TRANSITIONS: RENAMED_DIFF_TRANSITIONS, uiText: { showLink: __('Show file contents'), commitLink: __('View file @ %{commitSha}'), @@ -52,27 +51,23 @@ export default { }, methods: { ...mapActions('diffs', ['switchToFullDiffFromRenamedFile']), - transition(transitionEvent) { - const key = `${this.state}:${transitionEvent}`; - - if (this.$options.TRANSITIONS[key]) { - this.state = this.$options.TRANSITIONS[key]; - } - }, is(state) { return this.state === state; }, switchToFull() { - this.transition(TRANSITION_LOAD_START); + this.transitionState(TRANSITION_LOAD_START); this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile }) .then(() => { - this.transition(TRANSITION_LOAD_SUCCEED); + this.transitionState(TRANSITION_LOAD_SUCCEED); }) .catch(() => { - this.transition(TRANSITION_LOAD_ERROR); + this.transitionState(TRANSITION_LOAD_ERROR); }); }, + transitionState(transitionEvent) { + this.state = transition(this.state, transitionEvent); + }, clickLink(event) { if (this.canLoadFullDiff) { event.preventDefault(); @@ -81,7 +76,7 @@ export default { } }, dismissError() { - this.transition(TRANSITION_ACKNOWLEDGE_ERROR); + this.transitionState(TRANSITION_ACKNOWLEDGE_ERROR); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue index 1da84df022f..b920af593df 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue @@ -27,6 +27,12 @@ export default { type: Number, required: true, }, + /* enable possibility to cycle around */ + enableCycle: { + type: Boolean, + required: false, + default: false, + }, }, watch: { max() { @@ -64,15 +70,34 @@ export default { return; } - const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max)); + let nextIndex = Math.max(this.min, Math.min(this.index + val, this.max)); - // Return if the index didn't change if (nextIndex === this.index) { - return; + // Return if the index didn't change and cycle is not enabled + if (!this.enableCycle) { + return; + } + // Update nextIndex if the cycle is enabled + nextIndex = this.cycle(nextIndex, val); } this.$emit('change', nextIndex); }, + cycle(nextIndex, val) { + if (val === 1 && nextIndex === this.max) { + // if we are moving down +1 and we reached bottom (max) + // return top most index (min) + return this.min; + } + + if (val === -1 && nextIndex === this.min) { + // if we are moving up -1 and we reached top (min) + // return bottom most index (max) + return this.max; + } + + return nextIndex; + }, }, render() { return this.$scopedSlots.default?.(); diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index 45c50dce8ce..9b45e969c90 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -13,6 +13,11 @@ export default { GlCollapsibleListbox, }, props: { + block: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: true, @@ -176,6 +181,7 @@ export default { <gl-collapsible-listbox ref="listbox" v-model="selected" + :block="block" :header-text="headerText" :reset-button-label="resetButtonLabel" :toggle-text="toggleText" diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js index 1afbeda74c4..12db70d8e9c 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js @@ -20,6 +20,8 @@ export const initProjectSelects = () => { orderBy, selected: initialSelection, } = el.dataset; + const block = parseBoolean(el.dataset.block); + const withShared = parseBoolean(el.dataset.withShared); const includeSubgroups = parseBoolean(el.dataset.includeSubgroups); const membership = parseBoolean(el.dataset.membership); const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel); @@ -37,6 +39,8 @@ export const initProjectSelects = () => { groupId, userId, orderBy, + block, + withShared, includeSubgroups, membership, initialSelection, diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue index 393991d746e..7af3819f2a5 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -20,6 +20,11 @@ export default { SafeHtml, }, props: { + block: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: true, @@ -47,6 +52,11 @@ export default { required: false, default: null, }, + withShared: { + type: Boolean, + required: false, + default: true, + }, includeSubgroups: { type: Boolean, required: false, @@ -86,7 +96,7 @@ export default { if (this.groupId) { return Api.groupProjects(this.groupId, searchString, { ...commonParams, - with_shared: true, + with_shared: this.withShared, include_subgroups: this.includeSubgroups, simple: true, }); @@ -99,7 +109,7 @@ export default { this.userId, searchString, { - with_shared: true, + with_shared: this.withShared, include_subgroups: this.includeSubgroups, }, (res) => ({ data: res }), @@ -154,6 +164,7 @@ export default { :default-toggle-text="$options.i18n.searchForProject" :fetch-items="fetchProjects" :fetch-initial-selection-text="fetchProjectName" + :block="block" clearable > <template v-if="hasHtmlLabel" #label> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index b0fa3e4c27e..b5783265ffa 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -9,7 +9,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants'; +import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, @@ -100,7 +100,7 @@ export default { return this.getActiveTokenValue(this.suggestions, this.value.data); }, availableDefaultSuggestions() { - if (this.value.operator === OPERATOR_NOT) { + if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) { return this.defaultSuggestions.filter( (suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value), ); 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 2f10e068542..dea279890b1 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 @@ -137,6 +137,7 @@ export default { v-if="showCopyButton" :text="value" :title="copyButtonTitle" + data-qa-selector="clipboard_button" @click="handleCopyButtonClick" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index 989b14f8711..8b3a54a536e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -1,6 +1,7 @@ <script> import { GlCollapsibleListbox, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { updateText } from '~/lib/utils/text_markdown'; import savedRepliesQuery from './saved_replies.query.graphql'; export default { @@ -9,7 +10,7 @@ export default { query: savedRepliesQuery, update: (r) => r.currentUser?.savedReplies?.nodes, skip() { - return !this.shouldFetchSavedReplies; + return !this.shouldFetchCommentTemplates; }, }, }, @@ -22,34 +23,52 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - newSavedRepliesPath: { + newCommentTemplatePath: { type: String, required: true, }, }, data() { return { - shouldFetchSavedReplies: false, + shouldFetchCommentTemplates: false, savedReplies: [], - savedRepliesSearch: '', + commentTemplateSearch: '', loadingSavedReplies: false, }; }, computed: { filteredSavedReplies() { - const savedReplies = this.savedRepliesSearch - ? fuzzaldrinPlus.filter(this.savedReplies, this.savedRepliesSearch, { key: ['name'] }) + const savedReplies = this.commentTemplateSearch + ? fuzzaldrinPlus.filter(this.savedReplies, this.commentTemplateSearch, { key: ['name'] }) : this.savedReplies; return savedReplies.map((r) => ({ value: r.id, text: r.name, content: r.content })); }, }, methods: { - fetchSavedReplies() { - this.shouldFetchSavedReplies = true; + fetchCommentTemplates() { + this.shouldFetchCommentTemplates = true; }, - setSavedRepliesSearch(search) { - this.savedRepliesSearch = search; + setCommentTemplateSearch(search) { + this.commentTemplateSearch = search; + }, + onSelect(id) { + const savedReply = this.savedReplies.find((r) => r.id === id); + const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); + + if (savedReply && textArea) { + updateText({ + textArea, + tag: savedReply.content, + cursorOffset: 0, + wrap: false, + }); + + // Wait for text to be added into textarea + requestAnimationFrame(() => { + textArea.focus(); + }); + } }, }, }; @@ -57,36 +76,32 @@ export default { <template> <gl-collapsible-listbox - :header-text="__('Insert saved reply')" + :header-text="__('Insert comment template')" :items="filteredSavedReplies" placement="right" searchable - class="saved-replies-dropdown" + class="comment-template-dropdown" :searching="$apollo.queries.savedReplies.loading" - @shown="fetchSavedReplies" - @search="setSavedRepliesSearch" + @shown="fetchCommentTemplates" + @search="setCommentTemplateSearch" + @select="onSelect" > <template #toggle> <gl-button v-gl-tooltip - :title="__('Insert saved reply')" - :aria-label="__('Insert saved reply')" + :title="__('Insert comment template')" + :aria-label="__('Insert comment template')" category="tertiary" class="gl-px-3!" - data-testid="saved-replies-dropdown-toggle" + data-testid="comment-template-dropdown-toggle" + @keydown.prevent > <gl-icon name="symlink" class="gl-mr-0!" /> <gl-icon name="chevron-down" /> </gl-button> </template> <template #list-item="{ item }"> - <div - class="gl-display-flex js-saved-reply-content" - :data-md-tag="item.content" - data-md-cursor-offset="0" - data-md-prepend="true" - data-testid="saved-reply-dropdown-item" - > + <div class="gl-display-flex js-comment-template-content"> <div class="gl-text-truncate"> <strong>{{ item.text }}</strong ><span class="gl-ml-2">{{ item.content }}</span> @@ -98,11 +113,11 @@ export default { class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3" > <gl-button - :href="newSavedRepliesPath" + :href="newCommentTemplatePath" category="tertiary" block class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!" - >{{ __('Add a new saved reply') }}</gl-button + >{{ __('Add a new comment template') }}</gl-button > </div> </template> @@ -110,11 +125,11 @@ export default { </template> <style> -.saved-replies-dropdown .gl-new-dropdown-panel { +.comment-template-dropdown .gl-new-dropdown-panel { width: 350px; } -.saved-replies-dropdown .gl-new-dropdown-item-check-icon { +.comment-template-dropdown .gl-new-dropdown-item-check-icon { display: none; } </style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue index 9ebf782a1d9..7803d6f53e0 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue @@ -23,7 +23,7 @@ export default { return this.value === 'markdown'; }, text() { - return this.markdownEditorSelected ? __('Viewing markdown') : __('Viewing rich text'); + return this.markdownEditorSelected ? __('Editing markdown') : __('Editing rich text'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9623c51d51c..cc153747765 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -416,7 +416,7 @@ export default { <div v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading" v-safe-html:[$options.safeHtmlConfig]="referencedCommands" - class="referenced-commands" + class="referenced-commands gl-mx-n5" data-testid="referenced-commands" ></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index eeeb0fce55d..3486f231b39 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -17,7 +17,7 @@ import { s__, __ } from '~/locale'; import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ToolbarButton from './toolbar_button.vue'; import DrawioToolbarButton from './drawio_toolbar_button.vue'; -import SavedRepliesDropdown from './saved_replies_dropdown.vue'; +import CommentTemplatesDropdown from './comment_templates_dropdown.vue'; export default { components: { @@ -27,16 +27,19 @@ export default { GlTabs, GlTab, DrawioToolbarButton, - SavedRepliesDropdown, + CommentTemplatesDropdown, + AiActionsDropdown: () => + import('ee_component/vue_shared/components/markdown/ai_actions_dropdown.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], inject: { - newSavedRepliesPath: { + newCommentTemplatePath: { default: null, }, + resourceGlobalId: { default: null }, }, props: { previewMarkdown: { @@ -118,6 +121,9 @@ export default { const expandText = s__('MarkdownEditor|Click to expand'); return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n'); }, + showAiActions() { + return this.resourceGlobalId && this.glFeatures.summarizeComments; + }, }, watch: { showSuggestPopover() { @@ -269,6 +275,7 @@ export default { </gl-button> </gl-popover> </template> + <ai-actions-dropdown v-if="showAiActions" :resource-global-id="resourceGlobalId" /> <toolbar-button tag="**" :button-title=" @@ -400,9 +407,9 @@ export default { :uploads-path="uploadsPath" :markdown-preview-path="markdownPreviewPath" /> - <saved-replies-dropdown - v-if="newSavedRepliesPath && glFeatures.savedReplies" - :new-saved-replies-path="newSavedRepliesPath" + <comment-templates-dropdown + v-if="newCommentTemplatePath && glFeatures.savedReplies" + :new-comment-template-path="newCommentTemplatePath" /> <toolbar-button v-if="!restrictedToolBarItems.includes('full-screen')" 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 93583907a11..52d8aab30d5 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -20,6 +20,11 @@ export default { type: String, required: true, }, + setFacade: { + type: Function, + required: false, + default: null, + }, renderMarkdownPath: { type: String, required: true, @@ -44,6 +49,16 @@ export default { required: false, default: false, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, supportsQuickActions: { type: Boolean, required: false, @@ -54,6 +69,11 @@ export default { required: false, default: null, }, + markdownDocsPath: { + type: String, + required: false, + default: '', + }, quickActionsDocsPath: { type: String, required: false, @@ -97,9 +117,25 @@ export default { mounted() { this.autofocusTextarea(); + this.$emit('input', this.markdown); this.saveDraft(); + + this.setFacade?.({ + getValue: () => this.getValue(), + setValue: (val) => this.setValue(val), + }); }, methods: { + getValue() { + return this.markdown; + }, + setValue(value) { + this.markdown = value; + this.$emit('input', value); + + this.saveDraft(); + this.autosizeTextarea(); + }, updateMarkdownFromContentEditor({ markdown }) { this.markdown = markdown; this.$emit('input', markdown); @@ -121,6 +157,11 @@ export default { this.notifyEditingModeChange(editingMode); }, onEditingModeRestored(editingMode) { + if (editingMode === EDITING_MODE_CONTENT_EDITOR && !this.enableContentEditor) { + this.editingMode = EDITING_MODE_MARKDOWN_FIELD; + return; + } + this.editingMode = editingMode; this.$emit(editingMode); this.notifyEditingModeChange(editingMode); @@ -161,7 +202,8 @@ export default { <div> <local-storage-sync v-model="editingMode" - storage-key="gl-wiki-content-editor-enabled" + as-string + storage-key="gl-markdown-editor-mode" @input="onEditingModeRestored" /> <markdown-field @@ -173,11 +215,14 @@ export default { can-attach-file :textarea-value="markdown" :uploads-path="uploadsPath" + :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :show-content-editor-switcher="enableContentEditor" :drawio-enabled="drawioEnabled" - class="bordered-box" @enableContentEditor="onEditingModeChange('contentEditor')" + @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <template #textarea> <textarea @@ -205,6 +250,8 @@ export default { :autofocus="contentEditorAutofocused" :placeholder="formFieldProps.placeholder" :drawio-enabled="drawioEnabled" + :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" :editable="!disabled" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js new file mode 100644 index 00000000000..0f2a46f78f7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; +import MarkdownEditor from './markdown_editor.vue'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +function organizeQuery(obj, isFallbackKey = false) { + if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { + return obj; + } + + if (isFallbackKey) { + return { + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + }; + } + + return { + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH], + }; +} + +function format(searchTerm, isFallbackKey = false) { + const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true }); + const organizeQueryObject = organizeQuery(queryObject, isFallbackKey); + const formattedQuery = objectToQuery(organizeQueryObject); + + return formattedQuery; +} + +function getSearchTerm(newIssuePath) { + const { search, pathname } = document.location; + return newIssuePath === pathname ? '' : format(search); +} + +export function mountMarkdownEditor() { + const el = document.querySelector('.js-markdown-editor'); + + if (!el) { + return null; + } + + const { + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + formFieldPlaceholder, + formFieldClasses, + qaSelector, + newIssuePath, + } = el.dataset; + + const hiddenInput = el.querySelector('input[type="hidden"]'); + const formFieldName = hiddenInput.getAttribute('name'); + const formFieldId = hiddenInput.getAttribute('id'); + const formFieldValue = hiddenInput.value; + + const searchTerm = getSearchTerm(newIssuePath); + const facade = { + setValue() {}, + getValue() {}, + focus() {}, + }; + + const setFacade = (props) => Object.assign(facade, props); + + // eslint-disable-next-line no-new + new Vue({ + el, + render(h) { + return h(MarkdownEditor, { + props: { + setFacade, + enableContentEditor: Boolean(gon.features?.contentEditorOnIssues), + value: formFieldValue, + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + formFieldProps: { + placeholder: formFieldPlaceholder, + id: formFieldId, + name: formFieldName, + class: formFieldClasses, + 'data-qa-selector': qaSelector, + }, + autosaveKey: `autosave/${document.location.pathname}/${searchTerm}/description`, + enableAutocomplete: true, + autocompleteDataSources: gl.GfmAutoComplete?.dataSources, + supportsQuickActions: true, + autofocus: true, + }, + }); + }, + }); + + return facade; +} diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 49eb11f8081..6d1cadf15be 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -176,6 +176,12 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div> + <div + v-show="isRendered" + ref="container" + v-safe-html="noteHtml" + data-testid="suggestions-container" + class="md suggestions" + ></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index e091fe74717..fac32bfdb24 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -13,7 +13,9 @@ export default { <template> <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder"> - <div class="timeline-icon"></div> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + ></div> <div class="timeline-content"> <div class="note-header"></div> <div class="note-body gl-mt-4"><gl-skeleton-loader /></div> 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 1cbbdf0deb0..06ca90fa8c6 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -25,11 +25,18 @@ import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { spriteIcon } from '~/lib/utils/common_utils'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; +const MR_ICON_COLORS = { + check: 'gl-bg-green-100 gl-text-green-700', + 'merge-request-close': 'gl-bg-red-100 gl-text-red-700', + merge: 'gl-bg-blue-100 gl-text-blue-700', +}; +const ICON_COLORS = { + 'issue-close': 'gl-bg-blue-100 gl-text-blue-700', +}; export default { i18n: { @@ -63,7 +70,7 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'descriptionVersions']), + ...mapGetters(['targetNoteHash', 'descriptionVersions', 'getNoteableData']), ...mapState(['isLoadingDescriptionVersion']), noteAnchorId() { return `note_${this.note.id}`; @@ -71,9 +78,6 @@ export default { isTargetNote() { return this.targetNoteHash === this.noteAnchorId; }, - iconHtml() { - return spriteIcon(this.note.system_note_icon_name); - }, toggleIcon() { return this.expanded ? 'chevron-up' : 'chevron-down'; }, @@ -87,6 +91,19 @@ export default { descriptionVersion() { return this.descriptionVersions[this.note.description_version_id]; }, + isMergeRequest() { + return this.getNoteableData.noteableType === 'MergeRequest'; + }, + hasIconColors() { + if (!this.isMergeRequest) return true; + + return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name]; + }, + iconBgClass() { + const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS; + + return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600'; + }, }, mounted() { renderGFM(this.$refs['gfm-content']); @@ -108,9 +125,6 @@ export default { } }, }, - safeHtmlConfig: { - ADD_TAGS: ['use'], // to support icon SVGs - }, userColorSchemeClass: window.gon.user_color_scheme, }; </script> @@ -121,7 +135,24 @@ export default { :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > - <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div> + <div + :class="[ + iconBgClass, + { + 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors, + 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest, + 'mr-system-note-icon': isMergeRequest, + }, + ]" + class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon" + > + <gl-icon + v-if="note.system_note_icon_name && hasIconColors" + :name="note.system_note_icon_name" + :size="isMergeRequest ? 12 : 16" + data-testid="timeline-icon" + /> + </div> <div class="timeline-content"> <div class="note-header"> <note-header diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue index 35f9ac14681..9d5f494579b 100644 --- a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue @@ -1,3 +1,7 @@ <template> - <div class="timeline-icon"><slot></slot></div> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > + <slot></slot> + </div> </template> 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 new file mode 100644 index 00000000000..1ace1c52a68 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue @@ -0,0 +1,37 @@ +<script> +import ProjectsListItem from './projects_list_item.vue'; + +export default { + components: { ProjectsListItem }, + props: { + /** + * Expected format: + * + * { + * id: number | string; + * name: string; + * webUrl: string; + * forksCount?: number; + * avatarUrl: string | null; + * starCount: number; + * visibility: string; + * issuesAccessLevel: string; + * forkingAccessLevel: string; + * openIssuesCount: number; + * permissions: { + * projectAccess: { accessLevel: 50 }; + * }[]; + */ + projects: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <projects-list-item v-for="project in projects" :key="project.id" :project="project" /> + </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 new file mode 100644 index 00000000000..f77fd029e93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -0,0 +1,152 @@ +<script> +import { GlAvatarLabeled, GlIcon, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui'; + +import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import { FEATURABLE_ENABLED } from '~/featurable/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { __ } from '~/locale'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; + +export default { + i18n: { + stars: __('Stars'), + forks: __('Forks'), + issues: __('Issues'), + archived: __('Archived'), + }, + components: { + GlAvatarLabeled, + GlIcon, + UserAccessRoleBadge, + GlLink, + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + /** + * Expected format: + * + * { + * id: number | string; + * name: string; + * webUrl: string; + * forksCount?: number; + * avatarUrl: string | null; + * starCount: number; + * visibility: string; + * issuesAccessLevel: string; + * forkingAccessLevel: string; + * openIssuesCount: number; + * permissions: { + * projectAccess: { accessLevel: 50 }; + * }; + */ + project: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.project.visibility]; + }, + visibilityTooltip() { + return PROJECT_VISIBILITY_TYPE[this.project.visibility]; + }, + accessLevel() { + return this.project.permissions?.projectAccess?.accessLevel; + }, + accessLevelLabel() { + return ACCESS_LEVEL_LABELS[this.accessLevel]; + }, + shouldShowAccessLevel() { + return this.accessLevel !== undefined; + }, + starsHref() { + return `${this.project.webUrl}/-/starrers`; + }, + forksHref() { + return `${this.project.webUrl}/-/forks`; + }, + issuesHref() { + return `${this.project.webUrl}/-/issues`; + }, + isForkingEnabled() { + return ( + this.project.forkingAccessLevel === FEATURABLE_ENABLED && + this.project.forksCount !== undefined + ); + }, + isIssuesEnabled() { + return this.project.issuesAccessLevel === FEATURABLE_ENABLED; + }, + }, + methods: { + numberToMetricPrefix, + }, +}; +</script> + +<template> + <li class="gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b"> + <gl-avatar-labeled + class="gl-flex-grow-1" + :entity-id="project.id" + :entity-name="project.name" + :label="project.name" + :label-link="project.webUrl" + shape="rect" + :size="48" + > + <template #meta> + <gl-icon + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary gl-ml-3" + /> + <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{ + accessLevelLabel + }}</user-access-role-badge> + </template> + </gl-avatar-labeled> + <div + class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0" + > + <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> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_shared/components/registry/history_item.vue b/app/assets/javascripts/vue_shared/components/registry/history_item.vue index 384b084ce09..d7d62df78f5 100644 --- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue @@ -19,7 +19,9 @@ export default { <template> <timeline-entry-item class="system-note note-wrapper"> - <div class="timeline-icon"> + <div + class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left" + > <gl-icon :name="icon" /> </div> <div class="timeline-content"> 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 092e8ba6c15..d77061d4b31 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,6 +1,5 @@ <script> import { GlIntersectionObserver } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { getPageParamValue, getPageSearchString } from '~/blob/utils'; @@ -21,7 +20,6 @@ export default { directives: { SafeHtml, }, - mixins: [glFeatureFlagMixin()], props: { isHighlighted: { type: Boolean, @@ -69,7 +67,6 @@ export default { return this.content.split('\n'); }, pageSearchString() { - if (!this.glFeatures.fileLineBlame) return ''; const page = getPageParamValue(this.number); return getPageSearchString(this.blamePath, page); }, @@ -106,7 +103,6 @@ export default { class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" > <a - v-if="glFeatures.fileLineBlame" class="gl-user-select-none gl-shadow-none! file-line-blame" :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" ></a> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index ce6741f33b1..f121e84e1de 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,13 +1,11 @@ <script> import SafeHtml from '~/vue_shared/directives/safe_html'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getPageParamValue, getPageSearchString } from '~/blob/utils'; export default { directives: { SafeHtml, }, - mixins: [glFeatureFlagMixin()], props: { number: { type: Number, @@ -28,7 +26,6 @@ export default { }, computed: { pageSearchString() { - if (!this.glFeatures.fileLineBlame) return ''; const page = getPageParamValue(this.number); return getPageSearchString(this.blamePath, page); }, @@ -41,8 +38,7 @@ export default { class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" > <a - v-if="glFeatures.fileLineBlame" - class="gl-user-select-none gl-shadow-none! file-line-blame" + class="gl-user-select-none gl-shadow-none! file-line-blame gl-mx-n2 gl-flex-grow-1" :href="`${blamePath}${pageSearchString}#L${number}`" ></a> <a diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index dd9d2ce66cd..e09f193310b 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,5 +1,6 @@ <script> import { + GlBadge, GlPopover, GlLink, GlSkeletonLoader, @@ -35,6 +36,7 @@ export default { I18N_USER_LEARN, USER_POPOVER_DELAY, components: { + GlBadge, GlIcon, GlLink, GlPopover, @@ -226,9 +228,9 @@ export default { data-testid="user-popover-pronouns" >({{ user.pronouns }})</span > - <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1" - >({{ $options.I18N_USER_BUSY }})</span - > + <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-1"> + {{ $options.I18N_USER_BUSY }} + </gl-badge> </template> </gl-avatar-labeled> </div> @@ -269,7 +271,7 @@ export default { <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> <gl-sprintf :message="$options.I18N_USER_LEARN"> <template #name>{{ user.name }}</template> diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue index 4ef9bc07b1c..9665e188469 100644 --- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue +++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue @@ -5,7 +5,9 @@ export default { // We can't use this.vuexModule due to bug in vue-apollo when // provide is called in beforeCreate // See https://github.com/vuejs/vue-apollo/pull/1153 for details - vuexModule: this.$options.propsData.vuexModule, + + // @vue-compat does not care to normalize propsData fields + vuexModule: this.$options.propsData.vuexModule ?? this.$options.propsData['vuex-module'], }; }, props: { diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 388e7c92f03..4211b9578a2 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -27,6 +27,10 @@ export const KBD_HELP = sprintf( { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, false, ); +export const MIN_SEARCH_TERM = s__( + 'GlobalSearch|The search term must be at least 3 characters long.', +); + export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}'); export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); 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 5b303b9a314..a68c577bff6 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 @@ -2,6 +2,7 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { STATUS_CLOSED } from '~/issues/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; @@ -93,13 +94,13 @@ export default { return getTimeago().format(this.issuable.createdAt); }, timestamp() { - if (this.issuable.state === 'closed' && this.issuable.closedAt) { + if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { return this.issuable.closedAt; } return this.issuable.updatedAt; }, formattedTimestamp() { - if (this.issuable.state === 'closed' && this.issuable.closedAt) { + if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { return sprintf(__('closed %{timeago}'), { timeago: getTimeago().format(this.issuable.closedAt), }); @@ -226,7 +227,7 @@ export default { </gl-link> <span v-if="taskStatus" - class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3" + class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm" data-testid="task-status" > {{ taskStatus }} @@ -265,7 +266,7 @@ export default { :data-avatar-url="author.avatarUrl" :href="author.webUrl" data-testid="issuable-author" - class="author-link js-user-link" + class="author-link js-user-link gl-font-sm" > <span class="author">{{ author.name }}</span> </gl-link> @@ -285,8 +286,7 @@ export default { </span> <slot name="timeframe"></slot> </span> - - <span v-if="labels.length" role="group" :aria-label="__('Labels')"> + <p v-if="labels.length" role="group" :aria-label="__('Labels')" class="gl-mt-1 gl-mb-0"> <gl-label v-for="(label, index) in labels" :key="index" @@ -295,10 +295,10 @@ export default { :description="label.description" :scoped="scopedLabel(label)" :target="labelTarget(label)" - :class="{ 'gl-ml-2': index }" + class="gl-mr-2" size="sm" /> - </span> + </p> </div> </div> <div class="issuable-meta"> @@ -312,7 +312,7 @@ export default { :icon-size="16" :max-visible="4" img-css-classes="gl-mr-2!" - class="gl-align-items-center gl-display-flex gl-ml-3" + class="gl-align-items-center gl-display-flex" /> </li> <slot name="statistics"></slot> 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 5b6c5bf6e03..3ac6aaf8b86 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 @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; @@ -24,6 +24,7 @@ export default { }, components: { GlAlert, + GlBadge, GlKeysetPagination, GlSkeletonLoader, IssuableTabs, @@ -371,7 +372,9 @@ export default { <slot name="timeframe" :issuable="issuable"></slot> </template> <template #status> - <slot name="status" :issuable="issuable"></slot> + <gl-badge size="sm" variant="info"> + <slot name="status" :issuable="issuable"></slot> + </gl-badge> </template> <template #statistics> <slot name="statistics" :issuable="issuable"></slot> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index a8d5f72373c..45fde45f516 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -139,7 +139,7 @@ export default { <template> <div class="issue-details issuable-details"> - <div class="detail-page-description js-detail-page-description content-block gl-pt-2"> + <div class="detail-page-description js-detail-page-description content-block gl-pt-4"> <issuable-edit-form v-if="editFormVisible" :issuable="issuable" 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 26309a25f07..08e52442311 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -20,7 +20,9 @@ export default { }; </script> <template> - <div class="container gl-display-flex gl-flex-direction-column"> + <div + class="gl-display-flex gl-flex-direction-column gl-mt-4 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid" + > <h2 class="gl-my-7 gl-font-size-h1 gl-text-center"> {{ title }} </h2> diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 2533b3b5489..31fd9e0a0ec 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -130,17 +130,19 @@ export default { <template> <credit-card-verification v-if="shouldVerify" @verified="onVerified" /> - <div v-else-if="!activePanelName"> - <gl-breadcrumb :items="breadcrumbs" /> + <div v-else-if="!activePanelName" class="gl-mt-4"> + <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> <welcome-page :panels="panels" :title="title"> <template #footer> <slot name="welcome-footer"> </slot> </template> </welcome-page> </div> - <div v-else> - <gl-breadcrumb :items="breadcrumbs" /> - <div class="gl-display-flex gl-py-5 gl-align-items-center"> + <div v-else class="gl-pt-4"> + <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> + <div + class="gl-display-flex gl-align-items-center gl-mt-4 gl-py-5 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid" + > <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div> <div class="col"> <h4>{{ activePanel.title }}</h4> 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 3afd1f9410b..c2ff2eec9fa 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 @@ -1,6 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { parseErrorMessage } from '~/lib/utils/error_message'; import { redirectTo } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '~/locale'; import apolloProvider from '../provider'; @@ -9,6 +10,16 @@ function mutationSettingsForFeatureType(type) { return featureToMutationMap[type]; } +export const i18n = { + buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), + noSuccessPathError: s__( + 'SecurityConfiguration|%{featureName} merge request creation mutation failed', + ), + genericErrorText: s__( + `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`, + ), +}; + export default { apolloProvider, components: { @@ -55,15 +66,20 @@ export default { throw new Error(errors[0]); } + // Sending window.gon.uf_error_prefix prefixed messages should happen only in + // the backend. Hence the code below is an anti-pattern. + // The issue to refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/397714 if (!successPath) { throw new Error( - sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }), + `${window.gon.uf_error_prefix} ${sprintf(this.$options.i18n.noSuccessPathError, { + featureName: this.feature.name, + })}`, ); } redirectTo(successPath); } catch (e) { - this.$emit('error', e.message); + this.$emit('error', parseErrorMessage(e, this.$options.i18n.genericErrorText)); this.isLoading = false; } }, @@ -84,12 +100,7 @@ export default { Boolean(mutationSettingsForFeatureType(type)) ); }, - i18n: { - buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), - noSuccessPathError: s__( - 'SecurityConfiguration|%{featureName} merge request creation mutation failed', - ), - }, + i18n, }; </script> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index fafbd02634f..8b523645973 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -20,6 +20,7 @@ export const REPORT_TYPE_SAST = 'sast'; export const REPORT_TYPE_SAST_IAC = 'sast_iac'; export const REPORT_TYPE_DAST = 'dast'; export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles'; +export const REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION = 'breach_and_attack_simulation'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning'; export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning'; @@ -28,6 +29,7 @@ export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing'; export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management'; export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning'; export const REPORT_TYPE_API_FUZZING = 'api_fuzzing'; +export const REPORT_TYPE_MANUALLY_ADDED = 'generic'; /** * SecurityReportTypeEnum values for use with GraphQL. diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue index 78e5dff6f59..90a8a7aa3e6 100644 --- a/app/assets/javascripts/webhooks/components/test_dropdown.vue +++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue @@ -19,45 +19,16 @@ export default { }, }, computed: { - itemsWithAction() { - return this.items.map((item) => ({ - text: item.text, - action: () => this.testHook(item.href), + webhookTriggers() { + return this.items.map(({ text, href }) => ({ + text, + href, + extraAttrs: { + 'data-method': 'post', + }, })); }, }, - methods: { - testHook(href) { - // HACK: Trigger @rails/ujs's data-method handling. - // - // The more obvious approaches of (1) declaratively rendering the - // links using GlDisclosureDropdown's list-item slot and (2) using - // item.extraAttrs to set the data-method attributes on the links - // do not work for reasons laid out in - // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134. - // - // Sending the POST with axios also doesn't work, since the - // endpoints return 302 redirects. Since axios uses XMLHTTPRequest, - // it transparently follows redirects, meaning the Location header - // of the first response cannot be inspected/acted upon by JS. We - // could manually trigger a reload afterwards, but that would mean - // a duplicate fetch of the current page: one by the XHR, and one - // by the explicit reload. It would also mean losing the flash - // alert set by the backend, making the feature useless for the - // user. - // - // The ideal fix here would be to refactor the test endpoint to - // return a JSON response, removing the need for a redirect/page - // reload to show the result. - const a = document.createElement('a'); - a.setAttribute('hidden', ''); - a.href = href; - a.dataset.method = 'post'; - document.body.appendChild(a); - a.click(); - a.remove(); - }, - }, i18n: { test: __('Test'), }, @@ -65,5 +36,5 @@ export default { </script> <template> - <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" /> + <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="webhookTriggers" :size="size" /> </template> diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue index ab4691a4a4e..f8dfa1c7f01 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -127,7 +127,11 @@ export default { :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > - <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > + <gl-icon :name="note.systemNoteIconName" /> + </div> <div class="timeline-content"> <div class="note-header"> <note-header 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 1762344ea9e..6c27d5a87f0 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 @@ -1,9 +1,9 @@ <script> -import { GlAvatar, GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; -import { clearDraft } from '~/lib/utils/autosave'; import Tracking from '~/tracking'; import { ASC } from '~/notes/constants'; +import { __ } from '~/locale'; +import { clearDraft } from '~/lib/utils/autosave'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getWorkItemQuery } from '../../utils'; import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql'; @@ -17,8 +17,6 @@ export default { avatarUrl: window.gon.current_user_avatar_url, }, components: { - GlAvatar, - GlButton, WorkItemNoteSignedOut, WorkItemCommentLocked, WorkItemCommentForm, @@ -66,11 +64,25 @@ export default { required: false, default: ASC, }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { workItem: {}, - isEditing: false, + isEditing: this.isNewDiscussion, isSubmitting: false, isSubmittingWithKeydown: false, }; @@ -109,28 +121,9 @@ export default { property: `type_${this.workItemType}`, }; }, - markdownPreviewPath() { - return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ - this.workItemType - }`; - }, - isLockedOutOrSignedOut() { - return !this.signedIn || !this.canUpdate; - }, - lockedOutUserWarningInReplies() { - return this.addPadding && this.isLockedOutOrSignedOut; - }, - timelineEntryClass() { - return { - 'timeline-entry gl-mb-3 note note-wrapper note-comment': true, - 'gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-p-5! gl-mx-n3 gl-mb-n2!': this - .lockedOutUserWarningInReplies, - }; - }, timelineEntryInnerClass() { return { - 'timeline-entry-inner': true, - 'gl-pb-3': this.addPadding, + 'timeline-entry-inner': this.isNewDiscussion, }; }, timelineContentClass() { @@ -141,8 +134,7 @@ export default { }, parentClass() { return { - 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap': !this - .isEditing, + 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap': !this.isEditing, }; }, isProjectArchived() { @@ -151,6 +143,18 @@ export default { canUpdate() { return this.workItem?.userPermissions?.updateWorkItem; }, + workItemState() { + return this.workItem?.state; + }, + commentButtonText() { + return this.isNewDiscussion ? __('Comment') : __('Reply'); + }, + timelineEntryClass() { + return this.isNewDiscussion + ? 'timeline-entry note-form' + : // eslint-disable-next-line @gitlab/require-i18n-strings + 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix'; + }, }, watch: { autofocus: { @@ -166,7 +170,6 @@ export default { async updateWorkItem(commentText) { this.isSubmitting = true; this.$emit('replying', commentText); - try { this.track('add_work_item_comment'); @@ -180,25 +183,56 @@ export default { }, }, update(store, createNoteData) { - if (createNoteData.data?.createNote?.errors?.length) { + const numErrors = createNoteData.data?.createNote?.errors?.length; + + if (numErrors) { + const { errors } = createNoteData.data.createNote; + + // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557 + // When a note only contains quick actions, + // additional "helpful" messages are embedded in the errors field. + // For instance, a note solely composed of "/assign @foobar" would + // return a message "Commands only Assigned @root." as an error on creation + // even though the quick action successfully executed. + if ( + numErrors === 2 && + errors[0].includes('Commands only') && + errors[1].includes('Command names') + ) { + return; + } + throw new Error(createNoteData.data?.createNote?.errors[0]); } }, }); - clearDraft(this.autosaveKey); + /** + * https://gitlab.com/gitlab-org/gitlab/-/issues/388314 + * + * Once form is successfully submitted, emit replied event, + * mark isSubmitting to false and clear storage before hiding the form. + * This will restrict comment form to restore the value while textarea + * input triggered due to keyboard event meta+enter. + * + */ this.$emit('replied'); + clearDraft(this.autosaveKey); this.cancelEditing(); } catch (error) { this.$emit('error', error.message); Sentry.captureException(error); + } finally { + this.isSubmitting = false; } - - this.isSubmitting = false; }, cancelEditing() { - this.isEditing = false; + this.isEditing = this.isNewDiscussion; this.$emit('cancelEditing'); }, + showReplyForm() { + this.isEditing = true; + this.$emit('startReplying'); + }, }, }; </script> @@ -212,9 +246,6 @@ export default { :is-project-archived="isProjectArchived" /> <div v-else :class="timelineEntryInnerClass"> - <div class="timeline-avatar gl-float-left"> - <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> - </div> <div :class="timelineContentClass"> <div :class="parentClass"> <work-item-comment-form @@ -223,15 +254,27 @@ export default { :aria-label="__('Add a reply')" :is-submitting="isSubmitting" :autosave-key="autosaveKey" + :is-new-discussion="isNewDiscussion" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :work-item-state="workItemState" + :work-item-id="workItemId" + :autofocus="autofocus" + :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" /> - <gl-button + <textarea v-else - class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" - @click="isEditing = true" - >{{ __('Add a reply') }}</gl-button - > + ref="textarea" + rows="1" + class="reply-placeholder-text-field gl-font-regular!" + data-testid="note-reply-textarea" + :placeholder="__('Reply')" + :aria-label="__('Reply to comment')" + @focus="showReplyForm" + @click="showReplyForm" + ></textarea> </div> </div> </div> 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 a3ebd51f76d..f9f24366725 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,11 +1,22 @@ <script> import { GlButton } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __ } from '~/locale'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { s__, __, sprintf } 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 { 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'; export default { constantOptions: { @@ -15,8 +26,13 @@ export default { GlButton, MarkdownEditor, }, + mixins: [Tracking.mixin()], inject: ['fullPath'], props: { + workItemId: { + type: String, + required: true, + }, workItemType: { type: String, required: true, @@ -44,20 +60,44 @@ export default { required: false, default: __('Comment'), }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, + workItemState: { + type: String, + required: false, + default: STATE_OPEN, + }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { commentText: getDraft(this.autosaveKey) || this.initialValue || '', + updateInProgress: false, }; }, computed: { - markdownPreviewPath() { - return joinPaths( - '/', - gon.relative_url_root || '', - this.fullPath, - `/preview_markdown?target_type=${this.workItemType}`, - ); + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_task_status', + property: `type_${this.workItemType}`, + }; }, formFieldProps() { return { @@ -67,11 +107,30 @@ export default { name: 'work-item-add-or-edit-comment', }; }, + 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'); + }, }, methods: { setCommentText(newText) { - this.commentText = newText; - updateDraft(this.autosaveKey, this.commentText); + /** + * https://gitlab.com/gitlab-org/gitlab/-/issues/388314 + * + * While the form is saving using meta+enter, + * avoid updating the data which is cleared after form submission. + */ + if (!this.isSubmitting) { + this.commentText = newText; + updateDraft(this.autosaveKey, this.commentText); + } }, async cancelEditing() { if (this.commentText && this.commentText !== this.initialValue) { @@ -91,23 +150,68 @@ 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> <template> - <div class="timeline-discussion-body"> - <div class="note-body"> + <div class="timeline-discussion-body gl-overflow-visible!"> + <div class="note-body gl-p-0! gl-overflow-visible!"> <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> <markdown-editor :value="commentText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :autocomplete-data-sources="autocompleteDataSources" :form-field-props="formFieldProps" + :add-spacing-classes="false" data-testid="work-item-add-comment" class="gl-mb-3" - autofocus use-bottom-toolbar + supports-quick-actions + :autofocus="autofocus" @input="setCommentText" @keydown.meta.enter="$emit('submitForm', commentText)" @keydown.ctrl.enter="$emit('submitForm', commentText)" @@ -117,6 +221,7 @@ export default { category="primary" variant="confirm" data-testid="confirm-button" + :disabled="!commentText.length" :loading="isSubmitting" @click="$emit('submitForm', commentText)" >{{ commentButtonText }} @@ -125,8 +230,9 @@ export default { data-testid="cancel-button" category="primary" class="gl-ml-3" - @click="cancelEditing" - >{{ __('Cancel') }} + :loading="updateInProgress" + @click="cancelButtonAction" + >{{ cancelButtonText }} </gl-button> </form> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index 1e08fecaf3d..21fc8f99366 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -54,6 +54,25 @@ export default { required: false, default: false, }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + canSetWorkItemMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -117,8 +136,7 @@ export default { this.isExpanded = !this.isExpanded; }, threadKey(note) { - /* eslint-disable @gitlab/require-i18n-strings */ - return `${note.id}-thread`; + return `${note.id}-thread`; // eslint-disable-line @gitlab/require-i18n-strings }, onReplied() { this.isExpanded = true; @@ -142,7 +160,15 @@ export default { :has-replies="hasReplies" :work-item-type="workItemType" :is-modal="isModal" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" :class="{ 'gl-mb-4': hasReplies }" + :assignees="assignees" + :can-set-work-item-metadata="canSetWorkItemMetadata" + :work-item-id="workItemId" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', note)" @error="$emit('error', $event)" @@ -167,6 +193,14 @@ export default { :work-item-type="workItemType" :is-modal="isModal" :class="{ 'gl-mb-4': hasReplies }" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :assignees="assignees" + :work-item-id="workItemId" + :can-set-work-item-metadata="canSetWorkItemMetadata" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', note)" @error="$emit('error', $event)" @@ -186,6 +220,14 @@ export default { :note="reply" :work-item-type="workItemType" :is-modal="isModal" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :assignees="assignees" + :work-item-id="workItemId" + :can-set-work-item-metadata="canSetWorkItemMetadata" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" @startReplying="showReplyForm" @deleteNote="$emit('deleteNote', reply)" @error="$emit('error', $event)" @@ -204,6 +246,9 @@ export default { :work-item-type="workItemType" :sort-order="sortOrder" :add-padding="true" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + @startReplying="showReplyForm" @cancelEditing="hideReplyForm" @replied="onReplied" @replying="onReplying" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue index 07e25312f87..e7a80bf39fb 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue @@ -30,7 +30,9 @@ export default { <template> <li class="timeline-entry note note-wrapper discussion-filter-note"> - <div class="timeline-icon gl-display-none gl-lg-display-flex"> + <div + class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + > <gl-icon name="comment" /> </div> <div class="timeline-content gl-pl-8"> 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 dcb6557600e..b8911592f5d 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 @@ -1,27 +1,26 @@ <script> -import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +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 Tracking from '~/tracking'; import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import { renderMarkdown } from '~/notes/utils'; import { getLocationHash } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getWorkItemQuery } from '~/work_items/utils'; import EditedAt from '~/issues/show/components/edited.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; import WorkItemCommentForm from './work_item_comment_form.vue'; export default { name: 'WorkItemNoteThread', - i18n: { - moreActionsText: __('More actions'), - deleteNoteText: __('Delete comment'), - copyLinkText: __('Copy link'), - }, components: { TimelineEntryItem, NoteBody, @@ -29,15 +28,28 @@ export default { NoteActions, GlAvatar, GlAvatarLink, - GlDropdown, - GlDropdownItem, WorkItemCommentForm, EditedAt, }, - directives: { - GlTooltip: GlTooltipDirective, - }, + mixins: [Tracking.mixin()], props: { + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, + workItemId: { + type: String, + required: true, + }, note: { type: Object, required: true, @@ -61,6 +73,25 @@ export default { required: false, default: false, }, + markdownPreviewPath: { + type: String, + required: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + canSetWorkItemMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -68,6 +99,13 @@ export default { }; }, computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_note_actions', + property: `type_${this.workItemType}`, + }; + }, author() { return this.note.author; }, @@ -111,6 +149,28 @@ export default { hasAwardEmojiPermission() { return this.note.userPermissions.awardEmoji; }, + isAuthorAnAssignee() { + return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length); + }, + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, }, methods: { showReplyForm() { @@ -121,8 +181,8 @@ export default { updateDraft(this.autosaveKey, this.note.body); }, async updateNote(newText) { - this.isEditing = false; try { + this.isEditing = false; await this.$apollo.mutate({ mutation: updateWorkItemNoteMutation, variables: { @@ -149,12 +209,68 @@ export default { Sentry.captureException(error); } }, + getNewAssigneesAndWidget() { + let newAssignees = []; + if (this.isAuthorAnAssignee) { + newAssignees = this.assignees.filter(({ id }) => id !== this.author.id); + } else { + 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]; + + editedWorkItemWidgets[assigneesWidgetIndex] = { + ...editedWorkItemWidgets[assigneesWidgetIndex], + assignees: { + nodes: newAssignees, + }, + }; + + return { + newAssignees, + editedWorkItemWidgets, + }; + }, notifyCopyDone() { if (this.isModal) { navigator.clipboard.writeText(this.noteUrl); } toast(__('Link copied to clipboard.')); }, + async assignUserAction() { + const { newAssignees, editedWorkItemWidgets } = this.getNewAssigneesAndWidget(); + + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + assigneesWidget: { + assigneeIds: newAssignees.map(({ id }) => id), + }, + }, + }, + optimisticResponse: { + workItemUpdate: { + errors: [], + workItem: { + ...this.workItem, + widgets: editedWorkItemWidgets, + }, + }, + }, + }); + this.track(`${this.isAuthorAnAssignee ? 'unassigned_user' : 'assigned_user'}`); + } catch (error) { + this.$emit('error', i18n.updateError); + Sentry.captureException(error); + } + }, }, }; </script> @@ -179,6 +295,11 @@ export default { :autosave-key="autosaveKey" :initial-value="note.body" :comment-button-text="__('Save comment')" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :work-item-id="workItemId" + :autofocus="isEditing" + class="gl-pl-3 gl-mt-3" @cancelEditing="isEditing = false" @submitForm="updateNote" /> @@ -199,32 +320,15 @@ export default { :show-reply="showReply" :show-edit="hasAdminPermission" :note-id="note.id" + :is-author-an-assignee="isAuthorAnAssignee" + :show-assign-unassign="canSetWorkItemMetadata" @startReplying="showReplyForm" @startEditing="startEditing" @error="($event) => $emit('error', $event)" + @notifyCopyDone="notifyCopyDone" + @deleteNote="$emit('deleteNote')" + @assignUser="assignUserAction" /> - <gl-dropdown - v-gl-tooltip - icon="ellipsis_v" - text-sr-only - right - :text="$options.i18n.moreActionsText" - :title="$options.i18n.moreActionsText" - category="tertiary" - no-caret - > - <gl-dropdown-item :data-clipboard-text="noteUrl" @click="notifyCopyDone"> - <span>{{ $options.i18n.copyLinkText }}</span> - </gl-dropdown-item> - <gl-dropdown-item - v-if="hasAdminPermission" - variant="danger" - data-testid="delete-note-action" - @click="$emit('deleteNote')" - > - {{ $options.i18n.deleteNoteText }} - </gl-dropdown-item> - </gl-dropdown> </div> </div> <div class="timeline-discussion-body"> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue index 6bea7953698..624a532c2aa 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { __, s__ } from '~/locale'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; @@ -10,11 +10,18 @@ export default { name: 'WorkItemNoteActions', i18n: { editButtonText: __('Edit comment'), + moreActionsText: __('More actions'), + deleteNoteText: __('Delete comment'), + copyLinkText: __('Copy link'), + assignUserText: __('Assign to commenting user'), + unassignUserText: __('Unassign from commenting user'), }, components: { GlButton, GlIcon, ReplyButton, + GlDropdown, + GlDropdownItem, EmojiPicker: () => import('~/emoji/components/picker.vue'), }, directives: { @@ -39,6 +46,28 @@ export default { required: false, default: false, }, + noteUrl: { + type: String, + required: false, + default: '', + }, + isAuthorAnAssignee: { + type: Boolean, + required: false, + default: false, + }, + showAssignUnassign: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + assignUserActionText() { + return this.isAuthorAnAssignee + ? this.$options.i18n.unassignUserText + : this.$options.i18n.assignUserText; + }, }, methods: { async setAwardEmoji(name) { @@ -100,5 +129,39 @@ export default { :aria-label="$options.i18n.editButtonText" @click="$emit('startEditing')" /> + <gl-dropdown + v-gl-tooltip + data-testid="work-item-note-actions" + icon="ellipsis_v" + text-sr-only + right + :text="$options.i18n.moreActionsText" + :title="$options.i18n.moreActionsText" + category="tertiary" + no-caret + > + <gl-dropdown-item + data-testid="copy-link-action" + :data-clipboard-text="noteUrl" + @click="$emit('notifyCopyDone')" + > + <span>{{ $options.i18n.copyLinkText }}</span> + </gl-dropdown-item> + <gl-dropdown-item + v-if="showAssignUnassign" + data-testid="assign-note-action" + @click="$emit('assignUser')" + > + {{ assignUserActionText }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="showEdit" + variant="danger" + data-testid="delete-note-action" + @click="$emit('deleteNote')" + > + {{ $options.i18n.deleteNoteText }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue index db36b4e1bbe..3f5ff526e91 100644 --- a/app/assets/javascripts/work_items/components/widget_wrapper.vue +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -1,11 +1,12 @@ <script> -import { GlAlert, GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { GlAlert, GlButton, + GlLink, }, props: { error: { @@ -42,7 +43,10 @@ export default { </script> <template> - <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"> + <div + id="tasks" + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + > <div class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base" :class="{ @@ -50,9 +54,15 @@ export default { }" > <div class="gl-display-flex gl-flex-grow-1"> - <h5 class="gl-m-0 gl-line-height-24"> + <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24"> + <gl-link + id="user-content-tasks-links" + class="anchor position-absolute gl-text-decoration-none" + href="#tasks" + aria-hidden="true" + /> <slot name="header"></slot> - </h5> + </h3> <slot name="header-suffix"></slot> </div> <slot name="header-right"></slot> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 3c56b627673..0e0c6bca802 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -2,33 +2,53 @@ import { GlDropdown, GlDropdownItem, + GlDropdownForm, GlDropdownDivider, GlModal, GlModalDirective, + GlToggle, } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import toast from '~/vue_shared/plugins/global_toast'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { sprintfWorkItem, I18N_WORK_ITEM_DELETE, I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, + TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, + TEST_ID_NOTIFICATIONS_TOGGLE_FORM, + TEST_ID_DELETE_ACTION, + WIDGET_TYPE_NOTIFICATIONS, } from '../constants'; +import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql'; export default { i18n: { enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), + notifications: s__('WorkItem|Notifications'), + notificationOn: s__('WorkItem|Notifications turned on.'), + notificationOff: s__('WorkItem|Notifications turned off.'), }, components: { GlDropdown, GlDropdownItem, + GlDropdownForm, GlDropdownDivider, GlModal, + GlToggle, }, directives: { GlModal: GlModalDirective, }, mixins: [Tracking.mixin({ label: 'actions_menu' })], + isLoggedIn: isLoggedIn(), + notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, + notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM, + confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + deleteActionTestId: TEST_ID_DELETE_ACTION, props: { workItemId: { type: String, @@ -60,8 +80,17 @@ export default { required: false, default: false, }, + subscribedToNotifications: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + initialSubscribed: this.subscribedToNotifications, + }; }, - emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'], computed: { i18n() { return { @@ -70,6 +99,16 @@ export default { }; }, }, + watch: { + subscribedToNotifications() { + /** + * To toggle the value if mutation fails, assign the + * subscribedToNotifications boolean value directly + * to data prop. + */ + this.initialSubscribed = this.subscribedToNotifications; + }, + }, methods: { handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); @@ -84,6 +123,56 @@ export default { this.track('cancel_delete_work_item'); } }, + toggleNotifications(subscribed) { + const inputVariables = { + id: this.workItemId, + notificationsWidget: { + subscribed, + }, + }; + this.$apollo + .mutate({ + mutation: updateWorkItemNotificationsMutation, + variables: { + input: inputVariables, + }, + optimisticResponse: { + workItemUpdate: { + errors: [], + workItem: { + id: this.workItemId, + widgets: [ + { + type: WIDGET_TYPE_NOTIFICATIONS, + subscribed, + __typename: 'WorkItemWidgetNotifications', + }, + ], + __typename: 'WorkItem', + }, + __typename: 'WorkItemUpdatePayload', + }, + }, + }) + .then( + ({ + data: { + workItemUpdate: { errors }, + }, + }) => { + if (errors?.length) { + throw new Error(errors[0]); + } + toast( + subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff, + ); + }, + ) + .catch((error) => { + this.updateError = error.message; + this.$emit('error', error.message); + }); + }, }, }; </script> @@ -99,9 +188,27 @@ export default { no-caret right > + <template v-if="$options.isLoggedIn"> + <gl-dropdown-form + class="work-item-notifications-form" + :data-testid="$options.notificationsToggleFormTestId" + > + <div class="gl-px-5 gl-pb-2 gl-pt-1"> + <gl-toggle + v-model="initialSubscribed" + :label="$options.i18n.notifications" + :data-testid="$options.notificationsToggleTestId" + label-position="left" + label-id="notifications-toggle" + @change="toggleNotifications($event)" + /> + </div> + </gl-dropdown-form> + <gl-dropdown-divider /> + </template> <template v-if="canUpdate && !isParentConfidential"> <gl-dropdown-item - data-testid="confidentiality-toggle-action" + :data-testid="$options.confidentialityTestId" @click="handleToggleWorkItemConfidentiality" >{{ isConfidential @@ -114,7 +221,7 @@ export default { <gl-dropdown-item v-if="canDelete" v-gl-modal="'work-item-confirm-delete'" - data-testid="delete-action" + :data-testid="$options.deleteActionTestId" variant="danger" >{{ i18n.deleteWorkItem }}</gl-dropdown-item > diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index ddfaa376028..141dac9573c 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -55,7 +55,6 @@ export default { isSubmitting: false, isSubmittingWithKeydown: false, descriptionText: '', - descriptionHtml: '', conflictedDescription: '', formFieldProps: { 'aria-label': __('Description'), @@ -81,12 +80,7 @@ export default { }, result() { if (this.isEditing) { - if (this.descriptionText !== this.workItemDescription?.description) { - this.conflictedDescription = this.workItemDescription?.description; - } - } else { - this.descriptionText = this.workItemDescription?.description; - this.descriptionHtml = this.workItemDescription?.descriptionHtml; + this.checkForConflicts(); } }, error() { @@ -148,6 +142,11 @@ export default { }, }, methods: { + checkForConflicts() { + if (this.descriptionText !== this.workItemDescription?.description) { + this.conflictedDescription = this.workItemDescription?.description; + } + }, async startEditing() { this.isEditing = true; @@ -254,7 +253,7 @@ export default { :autocomplete-data-sources="autocompleteDataSources" enable-autocomplete supports-quick-actions - init-on-autofocus + autofocus @input="setDescriptionText" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" 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 ad7a54aaf16..06e8a65ecf7 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -26,6 +26,7 @@ import { i18n, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, + WIDGET_TYPE_NOTIFICATIONS, WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, @@ -39,7 +40,7 @@ import { WIDGET_TYPE_NOTES, } from '../constants'; -import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; +import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql'; import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql'; @@ -224,15 +225,18 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, + canSetWorkItemMetadata() { + return this.workItem?.userPermissions?.setWorkItemMetadata; + }, + canAssignUnassignUser() { + return this.workItemAssignees && this.canSetWorkItemMetadata; + }, confidentialTooltip() { return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); }, fullPath() { return this.workItem?.project.fullPath; }, - workItemsMvcEnabled() { - return this.glFeatures.workItemsMvc; - }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, @@ -268,6 +272,9 @@ export default { hasDescriptionWidget() { return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION); }, + workItemNotificationsSubscribed() { + return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed); + }, workItemAssignees() { return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, @@ -534,14 +541,13 @@ export default { {{ workItemBreadcrumbReference }} </li> </ul> - <work-item-type-icon - v-else-if="!error" - :work-item-icon-name="workItemIconName" - :work-item-type="workItemType && workItemType.toUpperCase()" - show-text - class="gl-font-weight-bold gl-text-secondary gl-mr-auto" - data-testid="work-item-type" - /> + <div v-else-if="!error" class="gl-mr-auto" data-testid="work-item-type"> + <work-item-type-icon + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType && workItemType.toUpperCase()" + /> + {{ workItemBreadcrumbReference }} + </div> <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> <gl-badge v-if="workItem.confidential" @@ -555,6 +561,7 @@ export default { <work-item-actions v-if="canUpdate || canDelete" :work-item-id="workItem.id" + :subscribed-to-notifications="workItemNotificationsSubscribed" :work-item-type="workItemType" :can-delete="canDelete" :can-update="canUpdate" @@ -705,12 +712,17 @@ export default { <work-item-notes v-if="workItemNotes" :work-item-id="workItem.id" + :work-item-iid="workItem.iid" :query-variables="queryVariables" :full-path="fullPath" :fetch-by-iid="fetchByIid" :work-item-type="workItemType" + :is-modal="isModal" + :assignees="workItemAssignees && workItemAssignees.assignees.nodes" + :can-set-work-item-metadata="canAssignUnassignUser" class="gl-pt-5" @error="updateError = $event" + @has-notes="updateHasNotes" /> <gl-empty-state v-if="error" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 730bdb4e7c7..51b957bb852 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -188,7 +188,7 @@ export default { :work-item-parent-id="issueGid" :work-item-id="displayedWorkItemId" :work-item-iid="displayedWorkItemIid" - class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate" + class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate" @close="hide" @deleteWorkItem="deleteWorkItem" @update-modal="updateModal" 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 d119cdc2785..d1866110fd4 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,11 +1,13 @@ <script> -import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; - +import { GlButton, GlLabel, GlLink, GlIcon, 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, @@ -24,6 +26,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLabel, GlLink, GlButton, GlIcon, @@ -68,9 +71,18 @@ export default { isExpanded: false, children: [], isLoadingChildren: false, + activeToast: null, + childrenBeforeRemoval: [], + hasChildren: false, }; }, 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; }, @@ -113,9 +125,6 @@ export default { this.childItem.iid }?iid_path=true`; }, - hasChildren() { - return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren; - }, chevronType() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, @@ -135,6 +144,17 @@ export default { return false; }, }, + watch: { + childItem: { + handler(val) { + this.hasChildren = this.getWidgetByType(val, WIDGET_TYPE_HIERARCHY)?.hasChildren; + }, + immediate: true, + }, + children(val) { + this.hasChildren = val?.length > 0; + }, + }, methods: { toggleItem() { this.isExpanded = !this.isExpanded; @@ -166,6 +186,72 @@ export default { this.isLoadingChildren = false; } }, + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + async removeChild(childId) { + this.cloneChildren(); + this.isLoadingChildren = true; + + try { + const { data } = await this.updateWorkItem(childId, null); + if (!data?.workItemUpdate?.errors?.length) { + this.filterRemovedChild(childId); + + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, childId), + }, + }); + } + } catch (error) { + this.showAlert(s__('WorkItem|Something went wrong while removing child.'), error); + Sentry.captureException(error); + this.restoreChildren(); + } finally { + this.isLoadingChildren = false; + } + }, + async undoChildRemoval(childId) { + this.isLoadingChildren = true; + try { + const { data } = await this.updateWorkItem(childId, this.childItem.id); + if (!data?.workItemUpdate?.errors?.length) { + this.activeToast?.hide(); + this.restoreChildren(); + } + } catch (error) { + this.showAlert(s__('WorkItem|Something went wrong while undoing child removal.'), error); + Sentry.captureException(error); + } finally { + this.activeToast?.hide(); + this.childrenBeforeRemoval = []; + this.isLoadingChildren = false; + } + }, + async updateWorkItem(childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + }); + }, + cloneChildren() { + this.childrenBeforeRemoval = cloneDeep(this.children); + }, + filterRemovedChild(childId) { + this.children = this.children.filter(({ id }) => id !== childId); + }, + restoreChildren() { + this.children = [...this.childrenBeforeRemoval]; + }, + showAlert(message, error) { + createAlert({ + message, + captureError: true, + error, + }); + }, }, }; </script> @@ -190,66 +276,72 @@ export default { @click="toggleItem" /> <div - class="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-rounded-base" - :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']" + 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-rounded-base" data-testid="links-child" > - <span - :id="`stateIcon-${childItem.id}`" - class="gl-cursor-help gl-mr-3 gl-line-height-32" - :class="{ 'gl-display-flex': hasMetadata }" - data-testid="item-status-icon" - > - <gl-icon - class="gl-text-secondary" - :class="iconClass" - :name="iconName" - :aria-label="stateTimestampTypeText" - /> - </span> - <div - class="gl-display-flex gl-flex-grow-1" - :class="{ - 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, - 'gl-align-items-center': !hasMetadata, - }" - > - <div class="gl-display-flex"> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" + <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" /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" + </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" /> - <gl-link - :href="childPath" - class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" - 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-mt-1" - /> </div> - <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" - > + <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" @@ -266,7 +358,7 @@ export default { :work-item-id="issuableGid" :work-item-type="workItemType" :children="children" - @removeChild="fetchChildren" + @removeChild="removeChild" @click="$emit('click', $event)" /> </div> 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/work_item_links/work_item_link_child_metadata.vue index 80802cb3858..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/work_item_links/work_item_link_child_metadata.vue @@ -1,16 +1,14 @@ <script> -import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import { isScopedLabel } from '~/lib/utils/common_utils'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; -import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants'; +import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants'; export default { components: { - GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, @@ -33,12 +31,6 @@ export default { assignees() { return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || []; }, - labels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; - }, - allowsScopedLabels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; - }, assigneesCollapsedTooltip() { if (this.assignees.length > 2) { return sprintf(s__('WorkItem|%{count} more assignees'), { @@ -56,21 +48,16 @@ export default { return ''; }, }, - methods: { - showScopedLabel(label) { - return isScopedLabel(label) && this.allowsScopedLabels; - }, - }, }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <div class="gl-display-flex gl-md-justify-content-end gl-gap-3"> <slot></slot> <item-milestone v-if="milestone" :milestone="milestone" - class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + class="gl-display-flex gl-align-items-center gl-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" /> <gl-avatars-inline v-if="assignees.length" @@ -81,7 +68,6 @@ export default { badge-tooltip-prop="name" :badge-sr-only-text="assigneesCollapsedTooltip" :class="assigneesContainerClass" - class="gl-mr-5" > <template #avatar="{ avatar }"> <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> @@ -89,18 +75,6 @@ export default { </gl-avatar-link> </template> </gl-avatars-inline> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap"> - <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> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue index fb3ed7af736..53e8eedf060 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -11,8 +11,13 @@ export default { </script> <template> - <span class="gl-ml-5"> - <gl-dropdown category="tertiary" toggle-class="btn-icon btn-sm" :right="true"> + <div class="gl-ml-5"> + <gl-dropdown + category="tertiary" + toggle-class="btn-icon btn-sm" + :right="true" + data-testid="work_items_links_menu" + > <template #button-content> <gl-icon name="ellipsis_v" :size="14" /> </template> @@ -20,5 +25,5 @@ export default { {{ s__('WorkItem|Remove') }} </gl-dropdown-item> </gl-dropdown> - </span> + </div> </template> 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 97eaf2c0422..b72de98199e 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 @@ -186,7 +186,7 @@ export default { </template> <template #body> <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> - <p class="gl-mb-3"> + <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue index e233a2219fa..ba5c0794395 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -1,9 +1,4 @@ <script> -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; - -import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; - export default { components: { WorkItemLinkChild: () => import('./work_item_link_child.vue'), @@ -32,28 +27,11 @@ export default { required: true, }, }, - methods: { - async updateWorkItem(childId) { - try { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, - }); - this.$emit('removeChild'); - } catch (error) { - createAlert({ - message: s__('Hierarchy|Something went wrong while removing a child item.'), - captureError: true, - error, - }); - } - }, - }, }; </script> <template> - <div class="gl-ml-6"> + <div class="gl-ml-6" data-testid="tree-children"> <work-item-link-child v-for="child in children" :key="child.id" @@ -62,7 +40,7 @@ export default { :issuable-gid="workItemId" :child-item="child" :work-item-type="workItemType" - @removeChild="updateWorkItem" + @removeChild="$emit('removeChild', child.id)" @click="$emit('click', Object.assign($event, { childItem: child }))" /> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 4ca8054fa5f..00cdc224320 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,6 +1,7 @@ <script> import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; @@ -14,7 +15,11 @@ import { WORK_ITEM_NOTES_FILTER_ONLY_HISTORY, } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; -import { getWorkItemNotesQuery } from '~/work_items/utils'; +import { + getWorkItemNotesQuery, + autocompleteDataSources, + markdownPreviewPath, +} from '~/work_items/utils'; import { updateCacheAfterCreatingNote, updateCacheAfterDeletingNote, @@ -48,6 +53,10 @@ export default { type: String, required: true, }, + workItemIid: { + type: String, + required: true, + }, queryVariables: { type: Object, required: true, @@ -70,6 +79,16 @@ export default { required: false, default: false, }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + canSetWorkItemMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -78,6 +97,7 @@ export default { sortOrder: ASC, noteToDelete: null, discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES, + addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`), }; }, computed: { @@ -102,6 +122,12 @@ export default { formAtTop() { return this.sortOrder === DESC; }, + markdownPreviewPath() { + return markdownPreviewPath(this.fullPath, this.workItemIid); + }, + autocompleteDataSources() { + return autocompleteDataSources(this.fullPath, this.workItemIid); + }, workItemCommentFormProps() { return { queryVariables: this.queryVariables, @@ -110,6 +136,9 @@ export default { fetchByIid: this.fetchByIid, workItemType: this.workItemType, sortOrder: this.sortOrder, + isNewDiscussion: true, + markdownPreviewPath: this.markdownPreviewPath, + autocompleteDataSources: this.autocompleteDataSources, }; }, notesArray() { @@ -252,6 +281,9 @@ export default { filterDiscussions(filterValue) { this.discussionFilter = filterValue; }, + updateKey() { + this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`); + }, async fetchMoreNotes() { this.isLoadingMore = true; // copied from discussions batch logic - every fetchMore call has a higher @@ -335,12 +367,17 @@ export default { </div> <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!"> <template v-if="!initialLoading"> - <ul class="notes main-notes-list timeline gl-clearfix!"> - <work-item-add-note - v-if="formAtTop && !commentsDisabled" - v-bind="workItemCommentFormProps" - @error="$emit('error', $event)" - /> + <div v-if="formAtTop && !commentsDisabled" class="js-comment-form"> + <ul class="notes notes-form timeline"> + <work-item-add-note + v-bind="workItemCommentFormProps" + :key="addNoteKey" + @cancelEditing="updateKey" + @error="$emit('error', $event)" + /> + </ul> + </div> + <ul class="notes main-notes-list timeline"> <template v-for="discussion in notesArray"> <system-note v-if="isSystemNote(discussion)" @@ -357,23 +394,31 @@ export default { :fetch-by-iid="fetchByIid" :work-item-type="workItemType" :is-modal="isModal" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-preview-path="markdownPreviewPath" + :assignees="assignees" + :can-set-work-item-metadata="canSetWorkItemMetadata" @deleteNote="showDeleteNoteModal($event, discussion)" @error="$emit('error', $event)" /> </template> </template> - <work-item-add-note - v-if="!formAtTop && !commentsDisabled" - v-bind="workItemCommentFormProps" - @error="$emit('error', $event)" - /> - <work-item-history-only-filter-note v-if="commentsDisabled" @changeFilter="filterDiscussions" /> </ul> + <div v-if="!formAtTop && !commentsDisabled" class="js-comment-form"> + <ul class="notes notes-form timeline"> + <work-item-add-note + v-bind="workItemCommentFormProps" + :key="addNoteKey" + @cancelEditing="updateKey" + @error="$emit('error', $event)" + /> + </ul> + </div> </template> <template v-if="showLoadingMoreSkeleton"> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index bbcf78e23aa..6af4f0fe790 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -14,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS'; export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; @@ -205,3 +206,8 @@ export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [ { key: DESC, text: __('Newest first'), testid: 'newest-first' }, { key: ASC, text: __('Oldest first') }, ]; + +export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action'; +export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action'; +export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form'; +export const TEST_ID_DELETE_ACTION = 'delete-action'; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index fda71fabe22..40fb0fbc91d 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -15,6 +15,10 @@ extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } +extend type WorkItemPermissions { + setWorkItemMetadata: Boolean +} + input LocalUserInput { id: ID! name: String 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 new file mode 100644 index 00000000000..f8952b62f28 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql @@ -0,0 +1,13 @@ +mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) { + workItemUpdate(input: $input) { + workItem { + id + widgets { + ... on WorkItemWidgetNotifications { + type + subscribed + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index ada9f737e6e..86640a6d994 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -27,6 +27,7 @@ fragment WorkItem on WorkItem { userPermissions { deleteWorkItem updateWorkItem + setWorkItemMetadata @client } widgets { ...WorkItemWidgets diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index b5d27231bef..44fda3ee894 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { } } } + + ... on WorkItemWidgetNotifications { + type + subscribed + } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index bf8eafe3211..8039ef53f98 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetNotes { type } + ... on WorkItemWidgetNotifications { + type + subscribed + } } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 95709b36594..7c47e72e170 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -18,7 +18,7 @@ export const initWorkItemsRoot = () => { hasIterationsFeature, hasOkrsFeature, hasIssuableHealthStatusFeature, - savedRepliesNewPath, + newCommentTemplatePath, } = el.dataset; return new Vue({ @@ -36,7 +36,7 @@ export const initWorkItemsRoot = () => { signInPath, hasIterationsFeature: parseBoolean(hasIterationsFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), - newSavedRepliesPath: savedRepliesNewPath, + newCommentTemplatePath, }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 1aa3baca165..ccb9d05bc90 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,5 +1,3 @@ -/* eslint-disable consistent-return */ - // Zen Mode (full screen) textarea // /*= provides zen_mode:enter */ @@ -55,6 +53,7 @@ export default class ZenMode { $(document).on('zen_mode:leave', () => { this.exit(); }); + // eslint-disable-next-line consistent-return $(document).on('keydown', (e) => { // Esc if (e.keyCode === 27) { diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 1a998f89c68..483c4dc226b 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,12 +1,10 @@ @import './pages/colors'; @import './pages/commits'; -@import './pages/detail_page'; @import './pages/events'; @import './pages/groups'; @import './pages/hierarchy'; @import './pages/issues'; @import './pages/labels'; -@import './pages/login'; @import './pages/merge_requests'; @import './pages/note_form'; @import './pages/notes'; @@ -15,4 +13,3 @@ @import './pages/projects'; @import './pages/registry'; @import './pages/settings'; -@import './pages/storage_quota'; diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss index 7736f1012a5..de8142924f9 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/components/detail_page.scss @@ -1,4 +1,5 @@ .detail-page-header { + padding-top: $gl-spacing-scale-4; color: $gl-text-color; line-height: 34px; display: flex; @@ -59,7 +60,7 @@ .detail-page-description { .title { - margin: 0 0 16px; + margin: 0 0 $gl-spacing-scale-4; color: $gl-text-color; padding: 0 0 0.3em; border-bottom: 1px solid $white-dark; diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 0b30b4c3ef0..04a7590d531 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -55,16 +55,16 @@ $item-remove-button-space: 42px; .item-weight .board-card-info-icon { min-width: $gl-padding; cursor: help; + + &:focus { + @include gl-focus; + } } .confidential-icon { color: $orange-500; } - .item-title-wrapper { - max-width: calc(100% - #{$item-remove-button-space}); - } - .item-title { flex-basis: 100%; font-size: $gl-font-size-small; diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index c1c68f64d86..35c619a2e2f 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -1,5 +1,5 @@ .whats-new-drawer { - margin-top: $header-height; + margin-top: calc(#{$header-height} + #{$calc-application-bars-height}); @include gl-shadow-none; overflow-y: hidden; width: 500px; @@ -35,18 +35,6 @@ } } -.with-performance-bar .whats-new-drawer { - margin-top: calc(#{$performance-bar-height} + #{$header-height}); -} - -.with-system-header .whats-new-drawer { - margin-top: calc(#{$system-header-height} + #{$header-height}); -} - -.with-performance-bar.with-system-header .whats-new-drawer { - margin-top: calc(#{$performance-bar-height} + #{$system-header-height} + #{$header-height}); -} - .whats-new-item-title-link { &:hover, &:focus, diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 799777977ed..cbdc55d66c1 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -29,10 +29,6 @@ border-bottom: 1px solid $white-dark; color: $gl-text-color; - &.oneline-block { - line-height: 42px; - } - &.white { background-color: $white; } @@ -89,10 +85,6 @@ padding: $gl-padding 0; border-bottom: 1px solid $white-dark; - &.oneline-block { - line-height: 36px; - } - > .controls { float: right; } diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 27e9a041145..65e378a79f3 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -20,56 +20,3 @@ } } } - -.pika-single.gitlab-theme { - .pika-label { - color: $gl-text-color-secondary; - font-size: 14px; - font-weight: $gl-font-weight-normal; - } - - th { - padding: 2px 0; - color: $note-disabled-comment-color; - font-weight: $gl-font-weight-normal; - text-transform: lowercase; - border-top: 1px solid $calendar-border-color; - } - - abbr { - cursor: default; - } - - td { - border: 1px solid $calendar-border-color; - - &:first-child { - border-left: 0; - } - - &:last-child { - border-right: 0; - } - } - - .pika-day { - border-radius: 0; - background-color: $white; - text-align: center; - } - - .is-today { - .pika-day { - color: inherit; - font-weight: $gl-font-weight-normal; - } - } - - .is-selected .pika-day, - .pika-day:hover, - .is-today .pika-day { - background: $gray-darker; - color: $gl-text-color; - box-shadow: none; - } -} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index cc7a45e1c82..d033a076832 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -1,3 +1,32 @@ +// stylelint-disable length-zero-no-unit +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} + +.with-performance-bar { + --performance-bar-height: #{$performance-bar-height}; +} + +.with-system-header { + --system-header-height: #{$system-header-height}; +} + +.with-top-bar { + --top-bar-height: #{$top-bar-height}; +} + +.with-system-footer { + --system-footer-height: #{$system-footer-height}; +} + +.review-bar-visible { + --mr-review-bar-height: #{$mr-review-bar-height}; +} + /** COLORS **/ .cgray { color: $gl-text-color; } .clgray { color: $gray-200; } @@ -253,12 +282,6 @@ li.note { } } -img.emoji { - height: 16px; - vertical-align: top; - width: 20px; -} - .chart { overflow: hidden; height: 220px; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 1e05441c731..fb9816d1402 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -229,13 +229,13 @@ // .nav-sidebar { - @include gl-fixed; - @include gl-bottom-0; - @include gl-left-0; + position: fixed; + bottom: $calc-application-footer-height; + left: 0; transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium; z-index: 600; width: $contextual-sidebar-width; - top: $header-height; + top: $calc-application-header-height; background-color: $contextual-sidebar-bg-color; border-right: 1px solid $contextual-sidebar-border-color; transform: translate3d(0, 0, 0); diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index cd0ea84cff4..ad09740583b 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -32,30 +32,12 @@ } @media (min-width: map-get($grid-breakpoints, md)) { - // The `+11` is to ensure the file header border shows when scrolled - - // the bottom of the compare-versions header and the top of the file header - --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); - --top: var(--initial-top); - - position: -webkit-sticky; position: sticky; - top: var(--top); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height}); z-index: 120; &.is-sidebar-moved { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px}); - } - - .with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height}); - } - - .with-system-header.with-performance-bar & { - --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); - } - - .with-performance-bar & { - top: calc(var(--initial-top) + #{$performance-bar-height}); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + 24px); } &::before { @@ -70,19 +52,11 @@ } &.is-commit { - top: calc(#{$header-height} + #{$commit-stat-summary-height}); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$commit-stat-summary-height} + #{$performance-bar-height}); - } + top: calc(#{$calc-application-header-height} + #{$commit-stat-summary-height}); } &.is-compare { - top: calc(#{$header-height} + #{$compare-branches-sticky-header-height}); - - .with-performance-bar & { - top: calc(#{$performance-bar-height} + #{$header-height} + #{$compare-branches-sticky-header-height}); - } + top: calc(#{$calc-application-header-height} + #{$compare-branches-sticky-header-height}); } } @@ -99,22 +73,7 @@ @media (min-width: map-get($grid-breakpoints, md)) { &.conflict .file-title, &.conflict .file-title-flex-parent { - top: $header-height; - } - - .with-performance-bar &.conflict .file-title, - .with-performance-bar &.conflict .file-title-flex-parent { - top: calc(#{$header-height} + #{$performance-bar-height}); - } - - .with-system-header &.conflict .file-title, - .with-system-header &.conflict .file-title-flex-parent { - top: calc(#{$header-height} + #{$system-header-height}); - } - - .with-system-header.with-performance-bar &.conflict .file-title, - .with-system-header.with-performance-bar &.conflict .file-title-flex-parent { - top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); + top: $calc-application-header-height; } } @@ -733,13 +692,9 @@ table.code { @include media-breakpoint-up(sm) { @include gl-sticky; - top: $header-height; + top: $calc-application-header-height; z-index: 200; - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}); - } - &.is-stuck { @include gl-py-0; border-top: 1px solid $white-dark; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index b292adf9eac..e4025eb8b8d 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -2,14 +2,6 @@ * File content holder * */ -.container-fluid.container-limited.limit-container-width { - .file-holder.readme-holder.limited-width-container .file-content { - max-width: $limited-layout-width; - margin-left: auto; - margin-right: auto; - } -} - .file-holder { border: 1px solid $border-color; border-top: 0; @@ -484,18 +476,24 @@ span.idiff { @include gl-display-none; } -.tree-list-scroll:not(.tree-list-blobs) { +.mr-tree-list:not(.tree-list-blobs) { .tree-list-parent::before { @include gl-content-empty; @include gl-absolute; @include gl-z-index-1; @include gl-pointer-events-none; - top: 28px; - left: calc(14px + (var(--level) * 16px)); - width: 1px; - height: calc(100% - 24px); - background-color: var(--gray-100, $gray-100); + top: -4px; + left: 0; + width: 100%; + bottom: -4px; + // The virtual scroller has a flat HTML structure so instead of the ::before + // element stretching over multiple rows we instead create a repeating background image + // for the line + background: repeating-linear-gradient(to right, var(--gray-100, $gray-100), var(--gray-100, $gray-100) 1px, transparent 1px, transparent 14px); + background-size: calc(var(--level) * 14px) 100%; + background-repeat: no-repeat; + background-position: 14px; } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 16c0a67f137..104cdf5544d 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -87,8 +87,8 @@ .filtered-search-term { display: flex; flex-shrink: 0; - margin-top: 4px; - margin-bottom: 4px; + margin-top: 2px; + margin-bottom: 2px; .selectable { display: flex; @@ -195,7 +195,7 @@ display: flex; width: 100%; min-width: 0; - border: 1px solid $border-color; + border: 1px solid $gray-400; background-color: $white; border-radius: $border-radius-default; @@ -206,8 +206,7 @@ &.focus, &.focus:hover { - border-color: $blue-300; - box-shadow: 0 0 4px $dropdown-input-focus-shadow; + @include gl-focus; } gl-emoji { @@ -227,7 +226,7 @@ min-width: 200px; padding-right: 25px; padding-left: 0; - height: $input-height; + height: #{$input-height - 2px}; line-height: inherit; &, @@ -261,7 +260,7 @@ flex: 1; position: relative; min-width: 0; - height: 2rem; + height: #{$input-height - 2px}; background-color: $input-bg; border-radius: $border-radius-default; } @@ -292,10 +291,11 @@ } .filtered-search-history-dropdown-toggle-button.gl-button { - border-radius: $border-radius-default 0 0 $border-radius-default; - border-right: 1px solid $border-color; - box-shadow: none; + $inner-border: #{$border-radius-default - 1px}; + border-radius: $inner-border 0 0 $inner-border; color: $gl-text-color-secondary; + margin: -1px 0 -1px -1px; + box-shadow: inset 0 0 0 1px $gray-400; flex: 1; transition: color 0.1s linear; width: auto; @@ -303,7 +303,6 @@ &:hover, &:focus { color: $gl-text-color; - border-color: $border-color; } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index b63365e8159..6b4f1478978 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -9,7 +9,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); &.sticky { position: sticky; - top: $flash-container-top; + top: $calc-application-header-height; z-index: 251; .flash-alert, @@ -114,17 +114,3 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); left: -50%; } } - -.with-system-header .flash-container.sticky { - top: $flash-container-top + $system-header-height; -} - -.with-performance-bar { - .flash-container.sticky { - top: $flash-container-top + $performance-bar-height; - } - - &.with-system-header .flash-container.sticky { - top: $flash-container-top + $performance-bar-height + $system-header-height; - } -} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index d1231da83d4..a5ff3c9c980 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -8,7 +8,7 @@ $search-input-field-x-min-width: 200px; min-height: $header-height; border: 0; position: fixed; - top: 0; + top: $calc-application-bars-height; left: 0; right: 0; border-radius: 0; @@ -312,21 +312,52 @@ $search-input-field-x-min-width: 200px; margin-top: $dropdown-vertical-offset; } -.breadcrumbs { - display: flex; - min-height: $breadcrumb-min-height; - color: $gl-text-color; +.top-bar-container { + min-height: $top-bar-height; } -.breadcrumbs-container { - display: flex; - width: 100%; - padding-top: $gl-padding / 2; - padding-bottom: $gl-padding / 2; - align-items: center; +.top-bar-fixed { + background-color: $body-bg; + left: 0; + position: fixed; + right: 0; + top: $calc-application-bars-height; + width: auto; + z-index: $top-bar-z-index; + @include gl-inset-border-b-1-gray-100; + + .breadcrumbs-list { + @include media-breakpoint-down(xs) { + flex-wrap: nowrap; + } + } + + @media (prefers-reduced-motion: no-preference) { + transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium; + } + + @include media-breakpoint-up(md) { + .right-sidebar-collapsed & { + right: $gutter-collapsed-width; + } + + .right-sidebar-expanded & { + right: $gutter-width; + } + } + + @include media-breakpoint-up(xl) { + .page-with-super-sidebar & { + left: $super-sidebar-width; + } + + .page-with-super-sidebar-collapsed & { + left: 0; + } + } } -.breadcrumbs-links { +.breadcrumbs { flex: 1; min-width: 0; align-self: center; @@ -348,16 +379,6 @@ $search-input-field-x-min-width: 200px; top: 1px; } } - - .dropdown-menu li a .identicon { - width: 17px; - height: 17px; - font-size: $gl-font-size-xs; - vertical-align: middle; - text-indent: 0; - line-height: $gl-font-size-xs + 2px; - display: inline-block; - } } .breadcrumbs-list { @@ -498,11 +519,6 @@ $search-input-field-x-min-width: 200px; visibility: visible; } -.with-performance-bar .navbar-gitlab, -.with-performance-bar .fixed-top { - top: $performance-bar-height; -} - .navbar-empty { justify-content: center; height: $header-height; @@ -558,7 +574,7 @@ $search-input-field-x-min-width: 200px; @include media-breakpoint-down(sm) { @include gl-display-block; - + .breadcrumbs-links { + + .breadcrumbs { @include gl-pl-4; @include gl-border-l-1; @include gl-border-l-solid; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index f27a36d1966..37a2264122d 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -74,7 +74,6 @@ } .user-avatar-link { - display: inline-block; text-decoration: none; } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 7a92adf7b7b..23dbe440d33 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -35,8 +35,9 @@ body { } } -.content-wrapper-margin { - margin-top: $header-height; +.layout-page { + padding-top: $calc-application-header-height; + padding-bottom: $calc-application-footer-height; } .content-wrapper { @@ -142,11 +143,6 @@ body { @include gl-overflow-hidden; } - -.with-performance-bar .layout-page { - margin-top: calc(#{$header-height} + #{$performance-bar-height}); -} - .fullscreen-layout { padding-top: 0; height: 100vh; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index c40cadafb9c..48aacc9606e 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -120,9 +120,10 @@ } .referenced-commands { + $radius: $border-radius-default - 1px; background: $blue-50; padding: $gl-padding-8 $gl-padding; - border-radius: $border-radius-default; + border-radius: 0 0 $radius $radius; p { margin: 0; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index b20ec1dc50a..aefac300839 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -223,18 +223,6 @@ } /* -* Mixin that handles the position of sticky alerts at the top. It accounts for the performance bar -*/ -// stylelint-disable-next-line length-zero-no-unit -@mixin sticky-top-positioning($extra: 0px) { - top: calc(#{$header-height} + #{$extra}); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height} + #{$extra}); - } -} - -/* * Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs) */ @mixin build-log($background: $black) { @@ -268,14 +256,8 @@ @mixin build-log-top-bar($height) { @include build-log-bar($height); - - position: -webkit-sticky; position: sticky; - top: $header-height; - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}); - } + top: $calc-application-header-height; } /* diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss index f11864f14af..84a34f12649 100644 --- a/app/assets/stylesheets/framework/page_title.scss +++ b/app/assets/stylesheets/framework/page_title.scss @@ -1,6 +1,6 @@ .page-title-holder { .page-title { - margin: $gl-padding 0; + margin: $gl-spacing-scale-4 0; color: $gl-text-color; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 7c3e346f4e6..946f2b28859 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -83,7 +83,7 @@ } .right-sidebar { - border-left: 1px solid $gray-100; + border-left: 1px solid $gray-50; &.right-sidebar-merge-requests { @include media-breakpoint-up(lg) { @@ -271,6 +271,24 @@ } } +.merge-request-approved-icon { + animation: approval-animate 350ms ease-in; +} + +@include keyframes(approval-animate) { + 0% { + transform: scale(0); + } + + 75% { + transform: scale(1.4); + } + + 100% { + transform: scale(1); + } +} + .assignee-grid, .reviewer-grid { [data-css-area='attention'] { @@ -288,21 +306,22 @@ @mixin right-sidebar { position: fixed; - top: $header-height; - // Default value for CSS var must contain a unit - // stylelint-disable-next-line length-zero-no-unit - bottom: var(--review-bar-height, 0px); + top: calc(#{$header-height} + #{$calc-application-bars-height}); + bottom: calc(#{$calc-application-footer-height} + var(--mr-review-bar-height)); right: 0; transition: width $gl-transition-duration-medium; background-color: $white; z-index: 200; overflow: hidden; - } .right-sidebar { &:not(.right-sidebar-merge-requests) { @include right-sidebar; + + @include media-breakpoint-down(sm) { + z-index: 251; + } } &.right-sidebar-merge-requests { @@ -312,10 +331,6 @@ } } - @include media-breakpoint-down(sm) { - z-index: 251; - } - a:not(.btn) { color: inherit; @@ -469,28 +484,14 @@ padding: 0; .issuable-context-form { - --initial-top: calc(#{$header-height} + 76px); - --top: var(--initial-top); - - @include gl-sticky; - @include gl-overflow-auto; + $issue-sticky-header-height: 76px; - top: var(--top); - height: calc(100vh - var(--top)); + top: calc(#{$calc-application-header-height} + #{$issue-sticky-header-height}); + height: calc(#{$calc-application-viewport-height} - #{$issue-sticky-header-height} - var(--mr-review-bar-height)); + position: sticky; + overflow: auto; padding: 0 15px; - margin-bottom: calc(var(--top) * -1); - - .with-performance-bar & { - --top: calc(var(--initial-top) + #{$performance-bar-height}); - } - - .with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height}); - } - - .with-performance-bar.with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); - } + margin-bottom: calc((#{$header-height} + $issue-sticky-header-height) * -1); } } } @@ -742,10 +743,6 @@ } } -.with-performance-bar .right-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height}); -} - .issuable-show-labels { .gl-label { margin-bottom: 5px; diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss index f9e95d16f63..91781bfe539 100644 --- a/app/assets/stylesheets/framework/sortable.scss +++ b/app/assets/stylesheets/framework/sortable.scss @@ -51,3 +51,12 @@ cursor: no-drop !important; } } + +.tree-item.is-dragging { + border-top: 0; + + .item-body { + background-color: $white; + border: 2px solid $gray-200; + } +} diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss index 046b8636f65..f1ee4c94942 100644 --- a/app/assets/stylesheets/framework/source_editor.scss +++ b/app/assets/stylesheets/framework/source_editor.scss @@ -41,6 +41,29 @@ } .monaco-editor.gl-source-editor { + // Fix unreadable headings in tooltips for syntax highlighting themes that don't match general theme + &.vs-dark .markdown-hover { + h1, + h2, + h3, + h4, + h5, + h6 { + color: $source-editor-hover-light-text-color; + } + } + + &.vs .markdown-hover { + h1, + h2, + h3, + h4, + h5, + h6 { + color: $source-editor-hover-dark-text-color; + } + } + .margin-view-overlays { .line-numbers { @include gl-display-flex; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 6b339f857cb..14eec335169 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -27,8 +27,8 @@ display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc(#{$header-height} + #{$calc-application-bars-height}); + bottom: $calc-application-footer-height; left: 0; background-color: var(--gray-10, $gray-10); border-right: 1px solid $t-gray-a-08; @@ -36,10 +36,6 @@ width: $super-sidebar-width; z-index: $super-sidebar-z-index; - &:focus { - @include gl-focus; - } - &.super-sidebar-loading { transform: translate3d(-100%, 0, 0); @@ -49,7 +45,9 @@ } &:not(.super-sidebar-loading) { - transition: transform $gl-transition-duration-medium; + @media (prefers-reduced-motion: no-preference) { + transition: transform $gl-transition-duration-medium; + } } .user-bar { @@ -78,8 +76,9 @@ } } - .counter .gl-icon { - color: var(--gray-500, $gray-500); + .counter .gl-icon, + .item-icon { + color: var(--gray-600, $gray-500); } .counter:hover, @@ -107,6 +106,9 @@ &[aria-expanded='true'] { background-color: $t-gray-a-08; } + + &:focus { + @include gl-focus($inset: true); } } .btn-with-notification { @@ -141,6 +143,29 @@ @include active-toggle; } } + + .nav-item-link { + button, + .draggable-icon { + opacity: 0; + } + + .draggable-icon { + cursor: grab; + } + + &:hover { + button, + .draggable-icon { + opacity: 1; + } + } + + &:focus button, + button:focus { + opacity: 1; + } + } } .super-sidebar-skip-to { @@ -151,9 +176,25 @@ display: none; } +.super-sidebar-peek { + @include gl-shadow; + border-right: 0; + + @media (prefers-reduced-motion: no-preference) { + transition: transform 100ms !important; + } +} + +.super-sidebar-hover-area { + z-index: $super-sidebar-z-index; +} + .page-with-super-sidebar { padding-left: 0; - transition: padding-left $gl-transition-duration-medium; + + @media (prefers-reduced-motion: no-preference) { + transition: padding-left $gl-transition-duration-medium; + } &:not(.page-with-super-sidebar-collapsed) { .super-sidebar-overlay { @@ -184,6 +225,10 @@ .page-with-super-sidebar-collapsed { .super-sidebar { transform: translate3d(-100%, 0, 0); + + &.super-sidebar-peek { + transform: translate3d(0, 0, 0); + } } @include media-breakpoint-up(xl) { @@ -195,19 +240,6 @@ } } -.container-limited .super-sidebar-toggle { - @media (min-width: $super-sidebar-toggle-position-breakpoint) { - position: absolute; - left: $gl-spacing-scale-3; - top: $gl-spacing-scale-3; - margin: 0; - } -} - -.with-performance-bar .super-sidebar { - top: $performance-bar-height; -} - .gl-dark { .super-sidebar { .gl-new-dropdown-custom-toggle { @@ -217,3 +249,38 @@ } } } + +.global-search-modal { + padding: 3rem 0.5rem 0; + + &.gl-modal .modal-dialog { + align-items: flex-start; + } + + @include gl-media-breakpoint-up(sm) { + padding: 5rem 1rem 0; + } + + // This is a temporary workaround! + // the button in GitLab UI Search components need to be updated to not be the small size + // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 + .gl-search-box-by-type-clear.btn-sm { + padding: 0.5rem !important; + } + + .is-searching { + .in-search-scope-help { + position: absolute; + top: 0.625rem; + right: 2.5rem; + } + } + + .gl-search-box-by-type-input-borderless { + @include gl-rounded-base; + } + + .global-search-results { + max-height: 30rem; + } +} diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 590a66ff28e..946a241e6dd 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -36,57 +36,8 @@ } } -// System Header -.with-system-header { - // main navigation - // login page - .navbar-gitlab, - .fixed-top { - top: $system-header-height; - } - - // left sidebar eg: project page - // right sidebar eg: MR page - .nav-sidebar, - .super-sidebar, - .right-sidebar { - top: calc(#{$system-header-height} + #{$header-height}); - } - - .content-wrapper-margin { - margin-top: calc(#{$system-header-height} + #{$header-height}); - } - - // Performance Bar - // System Header - &.with-performance-bar { - // main navigation - header.navbar-gitlab, - .fixed-top { - top: $performance-bar-height + $system-header-height; - } - - .layout-page { - margin-top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); - } - - // left sidebar eg: project page - // right sidebar eg: MR page - .nav-sidebar, - .super-sidebar, - .right-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); - } - } -} - // System Footer .with-system-footer { - // left sidebar eg: project page - // right sidebar eg: mr page - .nav-sidebar, - .super-sidebar, - .right-sidebar, // navless pages' footer eg: login page // navless pages' footer border eg: login page &.devise-layout-html body .footer-container, @@ -94,13 +45,9 @@ bottom: $system-footer-height; } - .content-wrapper-margin { - margin-bottom: 16px; - } - .boards-list, .board-swimlanes { - height: calc(100vh - (#{$header-height} + #{$breadcrumb-min-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32})); + height: calc(100vh - (#{$header-height} + #{$top-bar-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32})); } } diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index a288701595e..b28a93749d1 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -160,3 +160,7 @@ table { border-top: 0; } } + +.gl-table-no-top-border th { + border-top: 0; +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 8edf5fc834a..88f990d2320 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -632,7 +632,7 @@ body { } .page-title { - margin: 0 0 #{2 * $grid-size}; + margin: $gl-spacing-scale-4 0; line-height: 1.3; &.with-button { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0bc2e0583bb..dba9cafbd71 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -11,9 +11,9 @@ $contextual-sidebar-width: 256px; $contextual-sidebar-collapsed-width: 56px; $toggle-sidebar-height: 48px; $super-sidebar-width: 256px; -$super-sidebar-toggle-position-breakpoint: 1360px; $super-sidebar-z-index: 600; $super-sidebar-overlay-z-index: 599; +$top-bar-z-index: 210; /** 🚨 Do not use this spacing scale — it is deprecated and being removed. 🚨 @@ -390,7 +390,6 @@ $nav-active-bg: $t-gray-a-08; * Text */ $gl-font-size: 14px; -$gl-font-size-xs: 11px; $gl-font-size-small: 12px; $gl-font-size-large: 16px; $gl-font-weight-normal: 400; @@ -481,10 +480,10 @@ $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; $system-header-height: 16px; $system-footer-height: $system-header-height; +$mr-review-bar-height: calc(2rem + 13px); $flash-height: 52px; -$flash-container-top: 48px; $context-header-height: 60px; -$breadcrumb-min-height: 48px; +$top-bar-height: 48px; $home-panel-title-row-height: 64px; $home-panel-avatar-mobile-size: 24px; $issuable-title-max-width: 350px; @@ -498,6 +497,14 @@ $gl-line-height-14: 14px; $pages-group-name-color: #4c4e54; /* + * Calculated heights + */ +$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height)); +$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height} + var(--top-bar-height)); +$calc-application-footer-height: var(--system-footer-height); +$calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height}); + +/* * Common component specific colors */ $user-mention-bg: rgba($blue-500, 0.044); @@ -669,8 +676,6 @@ $note-targe3-inside: #ffffd3; /* * Calendar */ -$calendar-hover-bg: #ecf3fe; -$calendar-border-color: rgba(#000, 0.1); $calendar-user-contrib-text: #959494; /* @@ -688,7 +693,7 @@ $issue-boards-filter-height: 68px; The following heights are used in environment_logs.scss and are used for calculation of the log viewer height. */ $environment-logs-breadcrumbs-height: 63px; -$environment-logs-breadcrumbs-height-md: $breadcrumb-min-height; +$environment-logs-breadcrumbs-height-md: $top-bar-height; $environment-logs-difference-xs-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height}); $environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height-md}); @@ -734,7 +739,7 @@ $calendar-activity-colors: ( */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); $commit-message-text-area-bg: rgba(0, 0, 0, 0); -$commit-stat-summary-height: 36px; +$commit-stat-summary-height: 32px; /* * Files @@ -908,13 +913,19 @@ $mr-tabs-height: 48px; /* Compare Branches */ -$compare-branches-sticky-header-height: 68px; +$compare-branches-sticky-header-height: 32px; /* Board Swimlanes */ $board-swimlanes-headers-height: 64px; +/* +Source Editor theme overrides +*/ +$source-editor-hover-light-text-color: #ececef; +$source-editor-hover-dark-text-color: #333238; + /** Bootstrap 4.2.0 introduced new icons for validating forms. Our design system does not use those, so we are disabling them for now: diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index ccb5d96e966..969a6665634 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -192,8 +192,8 @@ pre.code, } &.hll { - --highlight-border-color: #{$orange-200}; - background-color: $orange-50; + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; } } @@ -247,8 +247,8 @@ pre.code, } &.hll { - --highlight-border-color: #{$orange-200}; - background-color: $orange-50; + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; } } @@ -269,8 +269,8 @@ pre.code, } &.hll { - --highlight-border-color: #{$orange-200}; - background-color: $orange-50; + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; } } } diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 99e7f7ae0a4..f7ab78c1bcc 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -208,15 +208,11 @@ } .boards-sidebar { - top: $header-height !important; + top: $calc-application-header-height !important; height: auto; - bottom: 0; + bottom: $calc-application-footer-height; padding-bottom: 0.5rem; - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}) !important; - } - .sidebar-collapsed-icon { @include gl-display-none; } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index d40c03b7fd1..5114f484e53 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -6,30 +6,22 @@ } .archived-job { - top: $header-height; + top: $calc-application-header-height; border-radius: 2px 2px 0 0; color: var(--orange-600, $orange-600); background-color: var(--orange-50, $orange-50); border: 1px solid var(--border-color, $border-color); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height}); - } } .top-bar { @include build-log-top-bar(50px); &.has-archived-block { - top: calc(#{$header-height} + 28px); - - .with-performance-bar & { - top: calc(#{$header-height} + #{$performance-bar-height} + 28px); - } + top: calc(#{$calc-application-header-height} + 28px); } &.affix { - top: $header-height; + top: $calc-application-header-height; // with sidebar &.sidebar-expanded { diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss index 143682e1cd7..f56eb4ae6fb 100644 --- a/app/assets/stylesheets/page_bundles/design_management.scss +++ b/app/assets/stylesheets/page_bundles/design_management.scss @@ -20,10 +20,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); .design-detail { background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity); - - .with-performance-bar & { - top: 35px; - } + bottom: $calc-application-footer-height; .comment-indicator { border-radius: 50%; diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 79595fa3a98..e0fb95a1359 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -66,7 +66,7 @@ .title { padding: 0; - margin-bottom: $gl-padding; + margin-bottom: $gl-spacing-scale-4; border-bottom: 0; word-wrap: break-word; overflow-wrap: break-word; diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss index b08e129a805..1ca0c5e7ce6 100644 --- a/app/assets/stylesheets/page_bundles/issuable_list.scss +++ b/app/assets/stylesheets/page_bundles/issuable_list.scss @@ -18,6 +18,11 @@ } } + .issuable-info, + .issuable-meta { + font-size: $gl-font-size-sm; + } + .issuable-meta { display: flex; flex-direction: column; diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/page_bundles/login.scss index 360ea20733d..495b7d58788 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/page_bundles/login.scss @@ -1,4 +1,5 @@ -@import 'framework/variables'; +@import 'mixins_and_variables_and_functions'; + /* Login Page */ .login-page { .container { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 396c590d912..97df87458ab 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-review-bar-height: calc(2rem + 13px); $mr-widget-margin-left: 40px; $mr-widget-min-height: 69px; $tabs-holder-z-index: 250; @@ -181,6 +180,10 @@ $tabs-holder-z-index: 250; .content + .content { @include gl-border-t; } + + .notes-content { + border: 0; + } } &.inline-diff-view { @@ -242,18 +245,6 @@ $tabs-holder-z-index: 250; } } -.with-system-header { - --system-header-height: #{$system-header-height}; -} - -.with-performance-bar { - --performance-bar-height: #{$performance-bar-height}; -} - -.review-bar-visible { - --review-bar-height: #{$mr-review-bar-height}; -} - .diff-tree-list { // This 11px value should match the additional value found in // /assets/stylesheets/framework/diffs.scss @@ -264,21 +255,19 @@ $tabs-holder-z-index: 250; // If they don't match, the file tree and the diff files stick // to the top at different heights, which is a bad-looking defect $diff-file-header-top: 11px; - --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); - --top-pos: var(--initial-pos); - position: -webkit-sticky; position: sticky; - top: calc(var(--top-pos) + var(--performance-bar-height, 0px)); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); min-height: 300px; - height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); + height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top})); .drag-handle { bottom: 16px; } &.is-sidebar-moved { - --top-pos: calc(var(--initial-pos) + 26px); + height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top} + 26px)); + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top} + 26px); } } @@ -915,15 +904,6 @@ $tabs-holder-z-index: 250; &:not(:first-child) { margin-top: $gl-padding; } - - &:not(:last-child)::before { - content: ''; - border-left: 2px solid var(--border-color, $border-color); - position: absolute; - bottom: -17px; - left: 26px; - height: 16px; - } } .mr-version-controls { @@ -992,8 +972,8 @@ $tabs-holder-z-index: 250; .merge-request-overview { @include media-breakpoint-up(lg) { display: grid; - grid-template-columns: calc(95% - 285px) auto; - grid-gap: 5%; + grid-template-columns: calc(97% - 285px) auto; + grid-gap: 3%; } } @@ -1156,7 +1136,7 @@ $tabs-holder-z-index: 250; .review-bar-component { position: fixed; - bottom: 0; + bottom: $calc-application-footer-height; left: 0; z-index: $zindex-dropdown-menu; display: flex; @@ -1241,3 +1221,70 @@ $tabs-holder-z-index: 250; } } } + +.mr-state-loader { + svg { + vertical-align: middle; + } + + .gl-skeleton-loader { + max-width: 334px; + } +} + +.mr-system-note-icon { + width: 20px; + height: 20px; + margin-left: 6px; + + &.gl-bg-green-100 { + --bg-color: var(--green-100, #{$green-100}); + } + + &.gl-bg-red-100 { + --bg-color: var(--red-100, #{$red-100}); + } + + &.gl-bg-blue-100 { + --bg-color: var(--blue-100, #{$blue-100}); + } +} + +.mr-system-note-icon:not(.mr-system-note-empty)::before { + content: ''; + display: block; + position: absolute; + left: calc(50% - 1px); + bottom: 100%; + width: 2px; + height: 20px; + background: linear-gradient(to bottom, transparent, var(--bg-color)); + + .system-note:first-child & { + display: none; + } +} + +.mr-system-note-icon:not(.mr-system-note-empty)::after { + content: ''; + display: block; + position: absolute; + left: calc(50% - 1px); + top: 100%; + width: 2px; + height: 20px; + background: linear-gradient(to bottom, var(--bg-color), transparent); + + .system-note:last-child & { + display: none; + } +} + +.mr-system-note-empty { + width: 8px; + height: 8px; + margin-top: 6px; + margin-left: 12px; + margin-right: 8px; + border: 2px solid var(--gray-50, $gray-50); +} diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index 708d1a2895e..8dc07715989 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -131,42 +131,6 @@ } } -.milestone-page-header { - display: flex; - flex-flow: row; - align-items: center; - flex-wrap: wrap; - - .milestone-buttons { - margin-left: auto; - order: 2; - - .verbose { - display: none; - } - } - - .header-text-content { - order: 3; - width: 100%; - } - - @include media-breakpoint-up(xs) { - .milestone-buttons .verbose { - display: inline; - } - - .header-text-content { - order: 2; - width: auto; - } - - .milestone-buttons { - order: 3; - } - } -} - .issuable-row { background-color: var(--white, $white); } diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index f08d6e3ca95..51bffd99dd0 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -57,10 +57,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-h-full; @include gl-w-full; @include gl-overflow-x-auto; - @include gl-border-gray-100; - @include gl-border-1; - @include gl-border-solid; - @include gl-rounded-base; } .timeline-section { @@ -68,15 +64,12 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-top-0; z-index: 20; - .timeline-header-blank, + .timeline-header-label, .timeline-header-item { @include gl-float-left; - height: $header-item-height; - border-bottom: $border-style; - background-color: var(--white, $white); } - .timeline-header-blank { + .timeline-header-label { @include gl-sticky; @include gl-top-0; @include gl-left-0; @@ -85,13 +78,8 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi } .timeline-header-item { - &:last-of-type .item-label { - @include gl-border-r-0; - } - - .item-label, .item-sublabel .sublabel-value { - color: var(--gray-400, $gray-400); + color: var(--gray-700, $gray-700); @include gl-font-weight-normal; &.label-dark { @@ -103,11 +91,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi } } - .item-label { - border-right: $border-style; - border-bottom: $border-style; - } - .item-sublabel { @include gl-relative; @include gl-display-flex; @@ -118,7 +101,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi text-align: center; @include gl-font-base; - padding: 2px 0; } } @@ -131,10 +113,15 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-rounded-full; transform: translate(-50%, 50%); } + + &:first-of-type { + .week-item-sublabel .sublabel-value:nth-of-type(7) { + @include gl-border-r; + } + } } } -.timeline-section .timeline-header-blank, .list-section .details-cell { &::after { @include gl-h-full; @@ -159,7 +146,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-left-0; width: $details-cell-width; @include gl-font-base; - background-color: var(--white, $white); z-index: 10; } @@ -182,3 +168,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi transform: translateX(-50%); } } + +.rotation-asignee-container { + overflow-x: clip; +} diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss index 347bd1316c0..8f2cbc402c9 100644 --- a/app/assets/stylesheets/pages/storage_quota.scss +++ b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .storage-type-usage { &:first-child { @include gl-rounded-top-left-base; @@ -12,6 +14,6 @@ &:not(:last-child) { @include gl-border-r-2; @include gl-border-r-solid; - @include gl-border-white; + border-right-color: var(--white, $white); } } diff --git a/app/assets/stylesheets/page_bundles/releases.scss b/app/assets/stylesheets/page_bundles/releases.scss index 24ffbf9b90c..c011ec3fe4c 100644 --- a/app/assets/stylesheets/page_bundles/releases.scss +++ b/app/assets/stylesheets/page_bundles/releases.scss @@ -10,3 +10,17 @@ min-height: 46px; } } + +.release-tag-selector { + .popover-body { + padding-left: 0; + padding-right: 0; + padding-bottom: 0; + min-width: $gl-dropdown-width; + max-width: $gl-dropdown-width; + } + + .release-tag-list { + max-height: $dropdown-max-height; + } +} diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index cde570cfb0f..d37e87b5cd5 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -21,18 +21,18 @@ $border-radius-medium: 3px; } } +.language-filter-checkbox { + .custom-control-label { + flex-grow: 1; + } +} + .search-sidebar { @include media-breakpoint-up(md) { min-width: $search-sidebar-min-width; max-width: $search-sidebar-max-width; } - .language-filter-checkbox { - .custom-control-label { - flex-grow: 1; - } - } - .language-filter-max-height { max-height: $language-filter-max-height; } diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index d7d454bde45..69a3ec94fda 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -102,24 +102,42 @@ } } - .active > a { - color: var(--black, $black); - } - .active > .wiki-list { a, .wiki-list-expand-button, .wiki-list-collapse-button { - color: var(--black, $black); + color: $black; + } + } + + .wiki-list { + height: $gl-spacing-scale-8; + + &:hover { + background: $gray-10; + + .wiki-list-create-child-button { + display: block; + box-shadow: none; + + &:focus { + box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400; + } + + &:active { + background: $gray-100 !important; + box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400; + } + } } } .wiki-list-expand-button, .wiki-list-collapse-button { - color: var(--gray-400, $gray-400); + color: $gray-400; &:hover { - color: var(--black, $black); + color: $black; } } @@ -130,10 +148,6 @@ margin: 0; } - ul.wiki-pages li { - margin: 5px 0 10px; - } - ul.wiki-pages ul { padding-left: 20px; } @@ -172,6 +186,10 @@ ul.wiki-pages-list.content-list { } .wiki-list { + .wiki-list-create-child-button { + display: none; + } + .wiki-list-expand-button, .wiki-list-collapse-button { left: -$gl-spacing-scale-5; @@ -198,17 +216,12 @@ ul.wiki-pages-list.content-list { .drawio-editor { position: fixed; - top: calc(var(--header-height, 48px)); + top: 0; left: 0; bottom: 0; - width: 100%; - height: calc(100% - var(--header-height, 48px)); + width: 100vw; + height: 100vh; border: 0; z-index: 1100; visibility: hidden; } - -.with-performance-bar .drawio-editor { - top: calc(var(--header-height, 48px) + 35px); - height: calc(100% - var(--header-height, 48px) - 35px); -} diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 00c86c46ac8..ecbb872e1df 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -87,22 +87,20 @@ } } -.work-item-link-child { - @include gl-border-1; - @include gl-border-solid; - @include gl-border-transparent; - @include gl-rounded-base; - - &:hover, - &:focus-within { - @include gl-bg-white; - @include gl-border-gray-50; - } -} - // sticky error placement for errors in modals , by default it is 83px for full view #work-item-detail-modal { .flash-container.flash-container-page.sticky { top: -8px; } } + + +.work-item-notifications-form { + .gl-toggle { + @include gl-ml-auto; + } + + .gl-toggle-label { + @include gl-font-weight-normal; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 225c32c1989..83f51588f43 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -332,3 +332,18 @@ height: 100%; } } + +.add-review-item-modal { + .modal-content { + position: absolute; + top: 5%; + } + + .title-hint-text { + color: $gl-text-color-secondary; + } + + .gl-filtered-search-suggestion-list.dropdown-menu { + width: $gl-max-dropdown-max-height; + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 0151446321a..9b6a3362e71 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -77,8 +77,7 @@ ul.related-merge-requests > li gl-emoji { .issue { &.closed, &.merged { - background: $gray-light; - border-color: $border-color; + background: $gray-10; } } @@ -243,6 +242,8 @@ ul.related-merge-requests > li gl-emoji { } &:hover > a.anchor::after { + position: relative; + top: -3px; visibility: visible; } } @@ -253,7 +254,7 @@ ul.related-merge-requests > li gl-emoji { @include gl-left-0; width: var(--width); - top: $header-height; + top: $calc-application-header-height; // collapsed right sidebar @include media-breakpoint-up(sm) { @@ -267,10 +268,6 @@ ul.related-merge-requests > li gl-emoji { } } -.with-performance-bar .issue-sticky-header { - top: calc(#{$header-height} + #{$performance-bar-height}); -} - @include media-breakpoint-up(md) { // collapsed left sidebar + collapsed right sidebar .page-with-contextual-sidebar .issue-sticky-header { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 5b8b850ba35..0a17b2c47a4 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -209,19 +209,11 @@ $comparison-empty-state-height: 62px; } .merge-request-tabs-holder { - top: $header-height; + top: $calc-application-header-height; z-index: $tabs-holder-z-index; background-color: $body-bg; border-bottom: 1px solid $border-color; - .with-system-header & { - top: calc(#{$header-height} + #{$system-header-height}); - } - - .with-system-header.with-performance-bar & { - top: calc(#{$header-height} + #{$system-header-height} + #{$performance-bar-height}); - } - @include media-breakpoint-up(md) { position: sticky; } @@ -240,12 +232,6 @@ $comparison-empty-state-height: 62px; } } -.with-performance-bar { - .merge-request-tabs-holder { - top: calc(#{$header-height} + #{$performance-bar-height}); - } -} - .limit-container-width { .merge-request-tabs-container { max-width: $limited-layout-width; @@ -336,11 +322,7 @@ $comparison-empty-state-height: 62px; .mr-compare { .diff-file .file-title-flex-parent { - top: calc(#{$header-height} + #{$mr-tabs-height}); - - .with-performance-bar & { - top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height}); - } + top: calc(#{$calc-application-header-height} + #{$mr-tabs-height}); } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 68a5176ad4b..b31ee069236 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -7,7 +7,7 @@ $system-note-icon-size: 1.5rem; $system-note-svg-size: 1rem; $icon-size-diff: $avatar-icon-size - $system-note-icon-size; -$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 0.1rem; +$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 1.3rem; $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; @mixin vertical-line($left) { @@ -15,10 +15,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; content: ''; border-left: 2px solid var(--gray-50, $gray-50); position: absolute; - top: $gl-padding-6; + top: 16px; bottom: 0; left: calc(#{$left} - 1px); - height: calc(100% + 1.5rem); + height: calc(100% + 20px); } } @@ -30,8 +30,30 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .issuable-discussion:not(.incident-timeline-events), .limited-width-notes { - .main-notes-list > li.timeline-entry:not(:last-of-type) { - @include vertical-line(1rem); + .main-notes-list::before, + .timeline-entry:last-child::before { + content: ''; + position: absolute; + width: 2px; + left: 15px; + top: 15px; + height: calc(100% - 15px); + } + + .main-notes-list::before { + background: var(--gray-50, $gray-50); + } + + .timeline-entry:last-child::before { + background: var(--white); + + .gl-dark & { + background: var(--gray-10); + } + + &.note-comment { + top: 30px; + } } } @@ -63,6 +85,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; height: 2rem; } + .gl-avatar { + border-color: var(--gray-50, $gray-50); + } + &.note-comment, &.note-skeleton, .draft-note { @@ -265,7 +291,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; &.being-posted { pointer-events: none; - opacity: 0.5; + + .timeline-entry-inner { + opacity: 0.5; + } .dummy-avatar { background-color: $gray-100; @@ -343,6 +372,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .note-header-info { padding-bottom: 0; + padding-top: 0; } &.timeline-entry::after { @@ -369,9 +399,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .timeline-content { - @include notes-media('min', map-get($grid-breakpoints, sm)) { - margin-left: 30px; - } + margin-left: 30px; } .note-header { @@ -450,40 +478,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } } - - .timeline-icon { - float: left; - } - - .system-note, - .discussion-filter-note { - .timeline-icon { - display: flex; - align-items: center; - background-color: $gray-50; - width: $system-note-icon-size; - height: $system-note-icon-size; - border: 1px solid $gray-50; - border-radius: $system-note-icon-size; - margin: -$gl-spacing-scale-1 0 0 $gl-spacing-scale-2; - - svg { - width: $system-note-svg-size; - height: $system-note-svg-size; - fill: $gray-600; - display: block; - margin: 0 auto; - } - } - } - - .discussion-filter-note { - .timeline-icon { - width: $system-note-icon-size; - height: $system-note-icon-size; - margin-top: -8px; - } - } } .card .notes { @@ -493,7 +487,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .timeline-icon { - margin: 8px 0 0 14px; + margin: 20px 0 0 28px; } } @@ -506,18 +500,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; border-radius: 0; margin-left: 2.5rem; - @media (min-width: map-get($grid-breakpoints, md)) { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); - - &.is-sidebar-moved { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px}); - } - - .with-performance-bar & { - --top: 123px; - } - } - &:hover { background-color: $gray-light; } @@ -603,15 +585,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .system-note { background-color: transparent; padding: 0; - - .timeline-icon { - margin-top: -2px; - } - - .timeline-entry-inner .timeline-icon { - margin-top: $system-note-icon-m-top; - margin-left: $system-note-icon-m-left; - } } } @@ -643,10 +616,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; padding: 0; vertical-align: top; white-space: normal; - - // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-foss/issues/53973 - // background-color is needed for dark code preference - padding-bottom: 1px; background-color: $white; &.parallel { @@ -673,6 +642,14 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } } + + .diff-grid-comments:last-child { + .notes-content { + border-bottom-width: 0; + border-bottom-left-radius: #{$border-radius-default - 1px}; + border-bottom-right-radius: #{$border-radius-default - 1px}; + } + } } .diffs { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index ee91d955019..e6c7e265cdb 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -291,9 +291,9 @@ .project-cell { @include gl-display-table-cell; - @include gl-border-b; @include gl-vertical-align-top; @include gl-py-4; + border-bottom: 1px solid $gray-50; } .project-row:last-of-type { diff --git a/app/assets/stylesheets/pages/registry.scss b/app/assets/stylesheets/pages/registry.scss index 31c6dbd2970..36b86771295 100644 --- a/app/assets/stylesheets/pages/registry.scss +++ b/app/assets/stylesheets/pages/registry.scss @@ -2,7 +2,7 @@ // until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 // // See app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue when this is changed. -.breadcrumbs-container .gl-breadcrumbs { +.breadcrumbs .gl-breadcrumbs { padding: 0; box-shadow: none; } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 2d78ab82b7d..012ae4bb86a 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -1,23 +1,4 @@ -.integration-settings-form { - .card.card-body, - .info-well { - padding: $gl-padding / 2; - box-shadow: none; - } - - .svg-container { - max-width: 150px; - } -} - .visibility-level-setting { - .option-title { - font-weight: $gl-font-weight-normal; - display: inline-block; - color: var(--gl-text-color, $gl-text-color); - vertical-align: top; - } - .option-description, .option-disabled-reason { color: var(--gray-700, $gray-700); @@ -69,30 +50,16 @@ } } -.push-pull-table { - margin-top: 1em; -} - .ci-variable-table, .deploy-freeze-table, .ci-secure-files-table { table { - thead { - border-bottom: 1px solid var(--gray-50, $gray-50); - } - tr { td, th { padding-left: 0; } - th { - background-color: transparent; - font-weight: $gl-font-weight-bold; - border: 0; - } - // When tables are "stacked", restore td padding @media(max-width: map-get($grid-breakpoints, lg)) { td { @@ -109,8 +76,8 @@ } } -.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap { +.gl-md-flex-nowrap.gl-md-flex-nowrap { @include gl-media-breakpoint-up(md) { - @include gl-flex-wrap-nowrap; + @include gl-flex-nowrap; } } diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 5024b082b99..cb153122767 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -128,6 +128,3 @@ color: $black; } -html.with-performance-bar .nav-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height}); -} diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 265f27f21fa..84181a00f34 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -63,9 +63,5 @@ a[href]::after { } .with-performance-bar .layout-page { - margin-top: 0; -} - -.content-wrapper-margin { - margin-top: 0; + padding-top: 0; } diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index b4e896325d6..ef75c650853 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -140,18 +140,6 @@ kbd kbd { background-color: #24232a; opacity: 1; } -.form-inline { - display: flex; - flex-flow: row wrap; - align-items: center; -} -@media (min-width: 576px) { - .form-inline .form-control { - display: inline-block; - width: auto; - vertical-align: middle; - } -} .btn { display: inline-block; font-weight: 400; @@ -562,17 +550,13 @@ strong { svg { vertical-align: baseline; } -.form-control, -.search form { +.form-control { font-size: 0.875rem; } .hidden { display: none !important; visibility: hidden !important; } -.hide { - display: none; -} .badge:not(.gl-badge) { padding: 4px 5px; font-size: 12px; @@ -593,6 +577,14 @@ svg { html { overflow-y: scroll; } +.layout-page { + padding-top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); + padding-bottom: var(--system-footer-height); +} @media (min-width: 576px) { .logged-out-marketing-header { --header-height: 72px; @@ -632,43 +624,22 @@ html { color: #bfbfc3; vertical-align: baseline; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} +.with-top-bar { + --top-bar-height: 48px; +} .gl-font-sm { font-size: 12px; } .dropdown { position: relative; } -.dropdown-menu-toggle:active { - box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; - outline: none; -} -.search-input-container .dropdown-menu { - margin-top: 11px; -} -.dropdown-menu-toggle { - padding: 6px 8px 6px 10px; - background-color: #333238; - color: #ececef; - font-size: 14px; - text-align: left; - border: 1px solid #535158; - border-radius: 0.25rem; - white-space: nowrap; -} -.dropdown-menu-toggle.no-outline { - outline: 0; -} -.dropdown-menu-toggle.dropdown-menu-toggle { - justify-content: flex-start; - overflow: hidden; - padding-top: 7px; - padding-bottom: 7px; - padding-right: 25px; - position: relative; - text-overflow: ellipsis; - line-height: 16px; - width: 160px; -} .dropdown-menu { display: none; position: absolute; @@ -750,11 +721,6 @@ html { min-width: 100%; } } -@media (max-width: 767.98px) { - .dropdown-menu-toggle.dropdown-menu-toggle { - width: 100%; - } -} input { border-radius: 0.25rem; color: #ececef; @@ -793,7 +759,7 @@ kbd { min-height: var(--header-height, 48px); border: 0; position: fixed; - top: 0; + top: calc(var(--system-header-height) + var(--performance-bar-height)); left: 0; right: 0; border-radius: 0; @@ -1075,11 +1041,15 @@ kbd { } .nav-sidebar { position: fixed; - bottom: 0; + bottom: var(--system-footer-height); left: 0; z-index: 600; width: 256px; - top: var(--header-height, 48px); + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); background-color: #1f1e24; border-right: 1px solid #e9e9e9; transform: translate3d(0, 0, 0); @@ -1496,8 +1466,11 @@ kbd { display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + ); + bottom: var(--system-footer-height); left: 0; background-color: var(--gray-10, #1f1e24); border-right: 1px solid rgba(251, 250, 253, 0.08); @@ -1513,9 +1486,13 @@ kbd { transform: translate3d(0, 0, 0); } } +@media (prefers-reduced-motion: no-preference) { +} .page-with-super-sidebar { padding-left: 0; } +@media (prefers-reduced-motion: no-preference) { +} @media (min-width: 1200px) { .page-with-super-sidebar { padding-left: 256px; @@ -1667,7 +1644,6 @@ svg.s16 { --gray-200: #535158; --gray-700: #bfbfc3; --gray-900: #ececef; - --gl-text-color: #ececef; --border-color: #434248; --white: #333238; --black: #fff; @@ -1779,16 +1755,6 @@ body.gl-dark .header-search .keyboard-shortcut-helper { color: #ececef; background-color: rgba(236, 236, 239, 0.2); } -body.gl-dark .search form { - background-color: rgba(236, 236, 239, 0.2); -} -body.gl-dark .search .search-input::placeholder { - color: rgba(236, 236, 239, 0.8); -} -body.gl-dark .search .search-input-wrap .search-icon, -body.gl-dark .search .search-input-wrap .clear-icon { - fill: rgba(236, 236, 239, 0.8); -} body.gl-dark .nav-sidebar li.active > a { color: #ececef; } @@ -1817,17 +1783,6 @@ body.gl-dark .navbar-gitlab .header-search:active { background-color: var(--gray-100) !important; box-shadow: inset 0 0 0 1px var(--blue-200) !important; } -body.gl-dark .navbar-gitlab .search form { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--border-color); -} -body.gl-dark .navbar-gitlab .search form:active { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--blue-200); -} -body.gl-dark .navbar-gitlab .search form .search-input { - color: var(--gl-text-color); -} .tab-width-8 { tab-size: 8; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 0a0fa83ff67..0dfc6be356f 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -140,18 +140,6 @@ kbd kbd { background-color: #fbfafd; opacity: 1; } -.form-inline { - display: flex; - flex-flow: row wrap; - align-items: center; -} -@media (min-width: 576px) { - .form-inline .form-control { - display: inline-block; - width: auto; - vertical-align: middle; - } -} .btn { display: inline-block; font-weight: 400; @@ -562,17 +550,13 @@ strong { svg { vertical-align: baseline; } -.form-control, -.search form { +.form-control { font-size: 0.875rem; } .hidden { display: none !important; visibility: hidden !important; } -.hide { - display: none; -} .badge:not(.gl-badge) { padding: 4px 5px; font-size: 12px; @@ -593,6 +577,14 @@ svg { html { overflow-y: scroll; } +.layout-page { + padding-top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); + padding-bottom: var(--system-footer-height); +} @media (min-width: 576px) { .logged-out-marketing-header { --header-height: 72px; @@ -632,43 +624,22 @@ html { color: #535158; vertical-align: baseline; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} +.with-top-bar { + --top-bar-height: 48px; +} .gl-font-sm { font-size: 12px; } .dropdown { position: relative; } -.dropdown-menu-toggle:active { - box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; -} -.search-input-container .dropdown-menu { - margin-top: 11px; -} -.dropdown-menu-toggle { - padding: 6px 8px 6px 10px; - background-color: #fff; - color: #333238; - font-size: 14px; - text-align: left; - border: 1px solid #bfbfc3; - border-radius: 0.25rem; - white-space: nowrap; -} -.dropdown-menu-toggle.no-outline { - outline: 0; -} -.dropdown-menu-toggle.dropdown-menu-toggle { - justify-content: flex-start; - overflow: hidden; - padding-top: 7px; - padding-bottom: 7px; - padding-right: 25px; - position: relative; - text-overflow: ellipsis; - line-height: 16px; - width: 160px; -} .dropdown-menu { display: none; position: absolute; @@ -750,11 +721,6 @@ html { min-width: 100%; } } -@media (max-width: 767.98px) { - .dropdown-menu-toggle.dropdown-menu-toggle { - width: 100%; - } -} input { border-radius: 0.25rem; color: #333238; @@ -793,7 +759,7 @@ kbd { min-height: var(--header-height, 48px); border: 0; position: fixed; - top: 0; + top: calc(var(--system-header-height) + var(--performance-bar-height)); left: 0; right: 0; border-radius: 0; @@ -1075,11 +1041,15 @@ kbd { } .nav-sidebar { position: fixed; - bottom: 0; + bottom: var(--system-footer-height); left: 0; z-index: 600; width: 256px; - top: var(--header-height, 48px); + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); background-color: #fbfafd; border-right: 1px solid #e9e9e9; transform: translate3d(0, 0, 0); @@ -1496,8 +1466,11 @@ kbd { display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + ); + bottom: var(--system-footer-height); left: 0; background-color: var(--gray-10, #fbfafd); border-right: 1px solid rgba(31, 30, 36, 0.08); @@ -1513,9 +1486,13 @@ kbd { transform: translate3d(0, 0, 0); } } +@media (prefers-reduced-motion: no-preference) { +} .page-with-super-sidebar { padding-left: 0; } +@media (prefers-reduced-motion: no-preference) { +} @media (min-width: 1200px) { .page-with-super-sidebar { padding-left: 256px; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 57f61508178..cd768c3bbc0 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -430,8 +430,13 @@ input.btn-block[type="button"] { cursor: not-allowed; color: #89888d; } +.gl-form-checkbox.custom-control { + padding-left: 1rem; +} .gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label { cursor: pointer; + padding-left: 0.5rem; + margin-bottom: 0.5rem; } .gl-form-checkbox.custom-control .custom-control-input @@ -440,6 +445,7 @@ input.btn-block[type="button"] { .custom-control-input ~ .custom-control-label::after { top: 0; + left: -1rem; } .gl-form-checkbox.custom-control .custom-control-input @@ -663,6 +669,13 @@ body.navless { .btn-block.btn { padding: 6px 0; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --top-bar-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} .tab-content { overflow: visible; } @@ -690,7 +703,11 @@ hr { } .flash-container.sticky { position: sticky; - top: 48px; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + + var(--top-bar-height) + ); z-index: 251; } .flash-container.flash-container-page { @@ -756,223 +773,10 @@ input:-ms-input-placeholder { svg { fill: currentColor; } -.login-page .container { - max-width: 960px; -} -.login-page .navbar-gitlab .container { - max-width: none; -} -.login-page .flash-container { - margin-bottom: 16px; - position: relative; - top: 8px; -} -.login-page .brand-holder { - font-size: 18px; - line-height: 1.5; -} -.login-page .brand-holder p { - font-size: 16px; - color: #888; -} -.login-page .brand-holder h3 { - font-size: 22px; -} -.login-page .brand-holder img { - max-width: 100%; - margin-bottom: 30px; -} -.login-page .brand-holder a { - font-weight: 600; -} -.login-page p { - font-size: 13px; -} -.login-page .signin-text p { - margin-bottom: 0; - line-height: 1.5; -} -.login-page .borderless .login-box, -.login-page .borderless .omniauth-container { - box-shadow: none; -} -.login-page .borderless .g-recaptcha > div { - margin-left: auto; - margin-right: auto; -} -.login-page .login-box, -.login-page .omniauth-container { - box-shadow: 0 0 0 1px #dcdcde; - border-radius: 0.25rem; -} -.login-page .login-box .login-heading h3, -.login-page .omniauth-container .login-heading h3 { - font-weight: 400; - line-height: 1.5; - margin: 0 0 10px; -} -.login-page .login-box .login-footer, -.login-page .omniauth-container .login-footer { - margin-top: 10px; -} -.login-page .login-box .login-footer p:last-child, -.login-page .omniauth-container .login-footer p:last-child { - margin-bottom: 0; -} -.login-page .login-box a.forgot, -.login-page .omniauth-container a.forgot { - float: right; - padding-top: 6px; -} -.login-page .login-box .nav .active a, -.login-page .omniauth-container .nav .active a { - background: transparent; -} -.login-page .login-box .login-body, -.login-page .omniauth-container .login-body { - font-size: 13px; -} -.login-page .login-box .login-body input + p, -.login-page .login-box .login-body input ~ p.field-validation, -.login-page .omniauth-container .login-body input + p, -.login-page .omniauth-container .login-body input ~ p.field-validation { - margin-top: 5px; -} -.login-page .login-box .login-body .username .validation-success, -.login-page .omniauth-container .login-body .username .validation-success { - color: #217645; -} -.login-page .login-box .login-body .username .validation-error, -.login-page .omniauth-container .login-body .username .validation-error { - color: #dd2b0e; -} -.login-page .omniauth-container { - border-radius: 0.25rem; - font-size: 13px; -} -.login-page .omniauth-container p { - margin: 0; -} -.login-page .omniauth-container form { - padding: 0; - border: 0; - background: none; -} -.login-page .new-session-tabs { - display: flex; - box-shadow: 0 0 0 1px #dcdcde; - border-top-right-radius: 4px; - border-top-left-radius: 4px; -} -.login-page .new-session-tabs.nav-links-unboxed { - border-color: transparent; - box-shadow: none; -} -.login-page .new-session-tabs.nav-links-unboxed .nav-item { - border-left: 0; - border-right: 0; - border-bottom: 1px solid #dcdcde; - background-color: transparent; -} -.login-page .new-session-tabs.custom-provider-tabs { - flex-wrap: wrap; -} -.login-page .new-session-tabs.custom-provider-tabs li { - min-width: 85px; - flex-basis: auto; -} -.login-page .new-session-tabs.custom-provider-tabs li:nth-child(n + 5) { - border-top: 1px solid #dcdcde; -} -.login-page .new-session-tabs.custom-provider-tabs a { - font-size: 16px; -} -.login-page .new-session-tabs li { - flex: 1; - text-align: center; - border-left: 1px solid #dcdcde; -} -.login-page .new-session-tabs li:first-of-type { - border-left: 0; - border-top-left-radius: 4px; -} -.login-page .new-session-tabs li:last-of-type { - border-top-right-radius: 4px; -} -.login-page .new-session-tabs li:not(.active) { - background-color: #fbfafd; -} -.login-page .new-session-tabs li a { - width: 100%; - font-size: 18px; -} -.login-page .new-session-tabs li.active > a { - cursor: default; -} -.login-page .form-control:active, -.login-page .form-control:focus { - background-color: #fff; -} -.login-page .submit-container { - margin-top: 16px; -} -.login-page input[type="submit"] { - margin-bottom: 0; - display: block; - width: 100%; -} -.login-page .devise-errors h2 { - margin-top: 0; - font-size: 14px; - color: #ae1800; -} -@media (max-width: 575.98px) { - .login-page .col-md-5.float-right { - float: none !important; - margin-bottom: 45px; - } -} -.devise-layout-html { - margin: 0; - padding: 0; - height: 100%; -} -.devise-layout-html body { - height: calc(100% - 51px); - margin: 0; - padding: 0; -} -.devise-layout-html body.navless { - height: calc(100% - 11px); -} -.devise-layout-html body .page-wrap { - min-height: 100%; - position: relative; -} -.devise-layout-html body .footer-container, -.devise-layout-html body hr.footer-fixed { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 40px; - background: #fff; -} -.devise-layout-html body .login-page-broadcast { - margin-top: 40px; -} -.devise-layout-html body .navless-container { - padding: 0 15px 65px; -} -.devise-layout-html body .flash-container { - padding-bottom: 65px; -} -@media (max-width: 575.98px) { - .devise-layout-html body .flash-container { - padding-bottom: 0; - } -} +.fixed-top { + top: calc(var(--system-header-height) + var(--performance-bar-height)); +} .gl-display-flex { display: flex; } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index f37b426cd91..6e46100dbb3 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -159,6 +159,22 @@ background-color: $search-and-nav-links-a30 !important; } + &.is-focused { + input { + background-color: $white; + color: $gl-text-color !important; + box-shadow: inset 0 0 0 1px $gray-900; + + &:focus { + box-shadow: inset 0 0 0 1px $gray-900, 0 0 0 1px $white, 0 0 0 3px $blue-400; + } + + &::placeholder { + color: $gray-400; + } + } + } + svg.gl-search-box-by-type-search-icon { color: $search-and-nav-links-a80; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 11f73b592fc..e5f99879166 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -39,6 +39,12 @@ .border-radius-small { border-radius: $border-radius-small; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } +// Override Bootstrap class with offset for system-header and +// performance bar when present +.fixed-top { + top: $calc-application-bars-height; +} + .gl-children-ml-sm-3 > * { @include media-breakpoint-up(sm) { @include gl-ml-3; @@ -71,58 +77,11 @@ // https://gitlab.com/groups/gitlab-org/-/epics/2882 .gl-h-200\! { height: px-to-rem($grid-size * 25) !important; } -.gl-bg-purple-light { background-color: $purple-light; } - -// move this to GitLab UI once onboarding experiment is considered a success -.gl-py-8 { - padding-top: $gl-spacing-scale-8; - padding-bottom: $gl-spacing-scale-8; -} - -.gl-transition-property-stroke-opacity { - transition-property: stroke-opacity; -} - -.gl-transition-property-stroke { - transition-property: stroke; -} - -.gl-top-66vh { - top: 66vh; -} - -.gl-shadow-x0-y0-b3-s1-blue-500 { - box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500; -} - // This utility is used to force the z-index to match that of dropdown menu's .gl-z-dropdown-menu\! { z-index: $zindex-dropdown-menu !important; } -.gl-flex-basis-quarter { - flex-basis: 25%; -} - -// Will be moved to @gitlab/ui (without the !important) in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1462 -// We only need the bang (!) version until the non-bang version is added to -// @gitlab/ui utitlities.scss. Once there, it will get loaded in the correct -// order to properly override `.gl-mt-6` which is used for narrower screen -// widths (currently that style gets added to the application.css stylesheet -// after this one, so it takes precedence). -.gl-md-mt-11\! { - @media (min-width: $breakpoint-md) { - margin-top: $gl-spacing-scale-11 !important; - } -} - -// Same as above (also without the !important) but for overriding `.gl-pt-6` -.gl-md-pt-11\! { - @media (min-width: $breakpoint-md) { - padding-top: $gl-spacing-scale-11 !important; - } -} - // This is used to help prevent issues with margin collapsing. // See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing. .gl-force-block-formatting-context::after { @@ -130,34 +89,6 @@ display: flex; } -.gl-sm-mr-3 { - @include media-breakpoint-up(sm) { - margin-right: $gl-spacing-scale-3; - } -} - -.gl-xl-ml-3 { - @include media-breakpoint-up(lg) { - margin-left: $gl-spacing-scale-3; - } -} - -.gl-mr-n2 { - margin-right: -$gl-spacing-scale-2; -} - -.gl-w-grid-size-30 { - width: $grid-size * 30; -} - -.gl-w-grid-size-40 { - width: $grid-size * 40; -} - -.gl-max-w-50p { - max-width: 50%; -} - /** Note: ::-webkit-scrollbar is a non-standard rule only supported by webkit browsers. @@ -181,59 +112,6 @@ @include gl-focus($gl-border-size-1, $gray-900, true); } -/* -All of the following (up until the "End gitlab-ui#1709" comment) will be moved -to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 -*/ -.gl-md-grid-template-columns-3 { - @include media-breakpoint-up(md) { - grid-template-columns: repeat(3, 1fr); - } -} - -.gl-lg-grid-template-columns-4 { - @include media-breakpoint-up(lg) { - grid-template-columns: repeat(4, 1fr); - } -} - -.gl-max-w-48 { - max-width: $gl-spacing-scale-48; -} - -.gl-max-w-75 { - max-width: $gl-spacing-scale-75; -} - -.gl-md-pt-11 { - @include media-breakpoint-up(md) { - padding-top: $gl-spacing-scale-11 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence - } -} - -.gl-md-mb-6 { - @include media-breakpoint-up(md) { - margin-bottom: $gl-spacing-scale-6 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence - } -} - -.gl-md-mb-12 { - @include media-breakpoint-up(md) { - margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence - } -} - -.gl-mt-n5 { - margin-top: -$gl-spacing-scale-5; -} - -// Utils below are very specific so cannot be part of GitLab UI -.gl-md-mt-5 { - @include gl-media-breakpoint-up(md) { - margin-top: $gl-spacing-scale-5; - } -} - .gl-sm-mr-0\! { @include gl-media-breakpoint-down(md) { margin-right: 0 !important; @@ -246,66 +124,20 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } -.gl-md-mb-3\! { +.gl-md-w-15 { @include gl-media-breakpoint-up(md) { - margin-bottom: $gl-spacing-scale-3 !important; + width: $gl-spacing-scale-15; } } -.gl-font-xs { - font-size: px-to-rem(10px); -} - -.gl-line-height-12 { - line-height: px-to-rem(12px); -} - -.gl-letter-spacing-06em { - letter-spacing: 0.06em; -} - -.gl-flex-flow-row-wrap { - flex-flow: row wrap; -} - -.gl-isolate { - isolation: isolate; -} - -.gl-text-transform-uppercase { - text-transform: uppercase; -} - -/* - * The below style will be moved to @gitlab/ui by - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2177 - */ -.gl-gap-2 { - gap: $gl-spacing-scale-2; -} - -.gl-bg-t-gray-a-08 { - background-color: $t-gray-a-08; -} - -.gl-hover-bg-t-gray-a-08:hover { - background-color: $t-gray-a-08; -} - -.gl-inset-border-1-gray-a-08 { - box-shadow: inset 0 0 0 $gl-border-size-1 $t-gray-a-08; -} - -.gl-line-height-1 { - line-height: 1; -} - -.gl-focus:focus { - @include gl-focus; +.gl-md-w-20 { + @include gl-media-breakpoint-up(md) { + width: $gl-spacing-scale-20; + } } -.gl-md-justify-content-space-between { +.gl-md-w-30 { @include gl-media-breakpoint-up(md) { - justify-content: space-between; + width: $gl-spacing-scale-30; } } |