diff options
Diffstat (limited to 'app/assets')
692 files changed, 10410 insertions, 6382 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue index 461b2dad479..57a237c3e84 100644 --- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -45,16 +45,34 @@ export default { 'initialActiveAccessTokens', 'noActiveTokensMessage', 'showRole', + 'information', ], data() { return { - activeAccessTokens: this.initialActiveAccessTokens, + activeAccessTokens: convertObjectPropsToCamelCase(this.initialActiveAccessTokens, { + deep: true, + }), currentPage: INITIAL_PAGE, }; }, computed: { filteredFields() { - return this.showRole ? FIELDS : FIELDS.filter((field) => field.key !== 'role'); + const ignoredFields = []; + + // Show 'action' column only when there are no active tokens or when some of them have a revokePath + const showAction = + this.activeAccessTokens.length === 0 || + this.activeAccessTokens.some((token) => token.revokePath); + + if (!showAction) { + ignoredFields.push('action'); + } + + if (!this.showRole) { + ignoredFields.push('role'); + } + + return FIELDS.filter(({ key }) => !ignoredFields.includes(key)); }, header() { return sprintf(this.$options.i18n.header, { @@ -100,6 +118,10 @@ export default { <hr /> <h5>{{ header }}</h5> + <p v-if="information" data-testid="information-section"> + {{ information }} + </p> + <gl-table data-testid="active-tokens" :empty-text="noActiveTokensMessage" diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue index 6b52bd84656..ce5342ad1ea 100644 --- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue +++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue @@ -2,10 +2,13 @@ import { GlAlert } from '@gitlab/ui'; import { createAlert, VARIANT_INFO } from '~/flash'; import { __, n__, sprintf } from '~/locale'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; import { EVENT_ERROR, EVENT_SUCCESS, FORM_SELECTOR } from './constants'; +const convertEventDetail = (event) => convertObjectPropsToCamelCase(event.detail, { deep: true }); + export default { EVENT_ERROR, EVENT_SUCCESS, @@ -54,8 +57,8 @@ export default { /** @type {HTMLFormElement} */ this.form = document.querySelector(FORM_SELECTOR); - /** @type {HTMLInputElement} */ - this.submitButton = this.form.querySelector('input[type=submit]'); + /** @type {HTMLButtonElement} */ + this.submitButton = this.form.querySelector('[type=submit]'); }, methods: { beforeDisplayResults() { @@ -68,20 +71,21 @@ export default { onError(event) { this.beforeDisplayResults(); - const [{ errors }] = event.detail; + const [{ errors }] = convertEventDetail(event); this.errors = errors; this.submitButton.classList.remove('disabled'); + this.submitButton.removeAttribute('disabled'); }, onSuccess(event) { this.beforeDisplayResults(); - const [{ new_token: newToken }] = event.detail; + const [{ newToken }] = convertEventDetail(event); this.newToken = newToken; this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO }); - // Selectively reset all input fields except for the date picker and submit. + // Selectively reset all input fields except for the date picker. // The form token creation is not controlled by Vue. this.form.querySelectorAll('input[type=text]:not([id$=expires_at])').forEach((el) => { el.value = ''; diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue index 10d4d62d803..1f72f5e19e2 100644 --- a/app/assets/javascripts/access_tokens/components/tokens_app.vue +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -79,7 +79,7 @@ export default { </script> <template> - <div> + <div class="js-search-settings-section"> <token v-for="(tokenData, tokenType) in enabledTokenTypes" :key="tokenType" diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index f0c1b415157..510f118bbb5 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -20,6 +20,7 @@ export const initAccessTokenTableApp = () => { const { accessTokenType, accessTokenTypePlural, + information, initialActiveAccessTokens: initialActiveAccessTokensJson, noActiveTokensMessage: noTokensMessage, } = el.dataset; @@ -30,12 +31,7 @@ export const initAccessTokenTableApp = () => { sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }); const showRole = 'showRole' in el.dataset; - const initialActiveAccessTokens = convertObjectPropsToCamelCase( - JSON.parse(initialActiveAccessTokensJson), - { - deep: true, - }, - ); + const initialActiveAccessTokens = JSON.parse(initialActiveAccessTokensJson); return new Vue({ el, @@ -43,6 +39,7 @@ export const initAccessTokenTableApp = () => { provide: { accessTokenType, accessTokenTypePlural, + information, initialActiveAccessTokens, noActiveTokensMessage, showRole, @@ -103,7 +100,7 @@ export const initNewAccessTokenApp = () => { export const initTokensApp = () => { const el = document.getElementById('js-tokens-app'); - if (!el) return false; + if (!el) return null; const tokensData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.tokensData), { deep: true, 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 8ad218ab97b..a41ff42df20 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 @@ -2,7 +2,7 @@ import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -193,7 +193,7 @@ export default { window.location.reload(); } if (!values[0] && !values[1]) { - createFlash({ + createAlert({ message: s__( 'ContextCommits|Failed to create/remove context commits. Please try again.', ), 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 4e5a2c7b371..d4c9db2fa33 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/actions.js +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -1,6 +1,6 @@ import _ from 'lodash'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -71,7 +71,7 @@ export const createContextCommits = ({ state }, { commits, forceReload = false } }) .catch(() => { if (forceReload) { - createFlash({ + createAlert({ message: s__('ContextCommits|Failed to create context commits. Please try again.'), }); } @@ -113,7 +113,7 @@ export const removeContextCommits = ({ state }, forceReload = false) => }) .catch(() => { if (forceReload) { - createFlash({ + createAlert({ message: s__('ContextCommits|Failed to delete context commits. Please try again.'), }); } diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue index 7f6e5dc4f35..80c216024a0 100644 --- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -44,7 +44,7 @@ export default { :items="databases" right :toggle-text="selectedDatabase" - aria-labelledby="label" + toggle-aria-labelled-by="label" @select="selectDatabase" /> </div> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue new file mode 100644 index 00000000000..b7bafe46327 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue @@ -0,0 +1,112 @@ +<script> +import { GlPagination } from '@gitlab/ui'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; +import { createAlert, VARIANT_DANGER } from '~/flash'; +import { s__ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import MessagesTable from './messages_table.vue'; + +const PER_PAGE = 20; + +export default { + name: 'BroadcastMessagesBase', + components: { + GlPagination, + MessagesTable, + }, + + props: { + page: { + type: Number, + required: true, + }, + messagesCount: { + type: Number, + required: true, + }, + messages: { + type: Array, + required: true, + }, + }, + + i18n: { + deleteError: s__( + 'BroadcastMessages|There was an issue deleting this message, please try again later.', + ), + }, + + data() { + return { + currentPage: this.page, + totalMessages: this.messagesCount, + visibleMessages: this.messages.map((message) => ({ + ...message, + disable_delete: false, + })), + }; + }, + + computed: { + hasVisibleMessages() { + return this.visibleMessages.length > 0; + }, + }, + + watch: { + totalMessages(newVal, oldVal) { + // Pagination controls disappear when there is only + // one page worth of messages. Since we're relying on static data, + // this could hide messages on the next page, or leave the user + // stranded on page 2 when deleting the last message. + // Force a page reload to avoid this edge case. + if (newVal === PER_PAGE && oldVal === PER_PAGE + 1) { + redirectTo(this.buildPageUrl(1)); + } + }, + }, + + methods: { + buildPageUrl(newPage) { + return buildUrlWithCurrentLocation(`?page=${newPage}`); + }, + + async deleteMessage(messageId) { + const index = this.visibleMessages.findIndex((m) => m.id === messageId); + if (!index === -1) return; + + const message = this.visibleMessages[index]; + this.$set(this.visibleMessages, index, { ...message, disable_delete: true }); + + try { + await axios.delete(message.delete_path); + } catch (e) { + this.$set(this.visibleMessages, index, { ...message, disable_delete: false }); + createAlert({ message: this.$options.i18n.deleteError, variant: VARIANT_DANGER }); + return; + } + + // Remove the message from the table + this.visibleMessages = this.visibleMessages.filter((m) => m.id !== messageId); + this.totalMessages -= 1; + }, + }, +}; +</script> + +<template> + <div> + <messages-table + v-if="hasVisibleMessages" + :messages="visibleMessages" + @delete-message="deleteMessage" + /> + <gl-pagination + v-model="currentPage" + :total-items="totalMessages" + :link-gen="buildPageUrl" + align="center" + /> + </div> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue new file mode 100644 index 00000000000..1408312d3e4 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue @@ -0,0 +1,113 @@ +<script> +import { GlButton, GlTableLite, GlSafeHtmlDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!'; + +export default { + name: 'MessagesTable', + components: { + GlButton, + GlTableLite, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + mixins: [glFeatureFlagsMixin()], + i18n: { + edit: __('Edit'), + delete: __('Delete'), + }, + props: { + messages: { + type: Array, + required: true, + }, + }, + computed: { + fields() { + if (this.glFeatures.roleTargetedBroadcastMessages) return this.$options.allFields; + return this.$options.allFields.filter((f) => f.key !== 'target_roles'); + }, + }, + allFields: [ + { + key: 'status', + label: __('Status'), + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'preview', + label: __('Preview'), + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'starts_at', + label: __('Starts'), + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'ends_at', + label: __('Ends'), + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'target_roles', + label: __('Target roles'), + tdClass: DEFAULT_TD_CLASSES, + thAttr: { 'data-testid': 'target-roles-th' }, + }, + { + key: 'target_path', + label: __('Target Path'), + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'type', + label: __('Type'), + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'buttons', + label: '', + tdClass: `${DEFAULT_TD_CLASSES} gl-white-space-nowrap`, + }, + ], + safeHtmlConfig: { + ADD_TAGS: ['use'], + }, +}; +</script> +<template> + <gl-table-lite + :items="messages" + :fields="fields" + :tbody-tr-attr="{ 'data-testid': 'message-row' }" + stacked="md" + > + <template #cell(preview)="{ item: { preview } }"> + <div v-safe-html:[$options.safeHtmlConfig]="preview"></div> + </template> + + <template #cell(buttons)="{ item: { id, edit_path, disable_delete } }"> + <gl-button + icon="pencil" + :aria-label="$options.i18n.edit" + :href="edit_path" + data-testid="edit-message" + /> + + <gl-button + class="gl-ml-3" + icon="remove" + variant="danger" + :aria-label="$options.i18n.delete" + rel="nofollow" + :disabled="disable_delete" + :data-testid="`delete-message-${id}`" + @click="$emit('delete-message', id)" + /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/index.js b/app/assets/javascripts/admin/broadcast_messages/index.js new file mode 100644 index 00000000000..81952d2033e --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import BroadcastMessagesBase from './components/base.vue'; + +export default () => { + const el = document.querySelector('#js-broadcast-messages'); + const { page, messagesCount, messages } = el.dataset; + + return new Vue({ + el, + name: 'BroadcastMessages', + render(createElement) { + return createElement(BroadcastMessagesBase, { + props: { + page: Number(page), + messagesCount: Number(messagesCount), + messages: JSON.parse(messages), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue index 6b140590938..be85ee43891 100644 --- a/app/assets/javascripts/admin/deploy_keys/components/table.vue +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -5,7 +5,7 @@ import { __ } from '~/locale'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import csrf from '~/lib/utils/csrf'; export default { @@ -151,7 +151,7 @@ export default { }), ); } catch (error) { - createFlash({ + createAlert({ message: this.$options.i18n.apiErrorMessage, captureError: true, error, diff --git a/app/assets/javascripts/admin/statistics_panel/store/actions.js b/app/assets/javascripts/admin/statistics_panel/store/actions.js index 77782cdc187..4f952698d7a 100644 --- a/app/assets/javascripts/admin/statistics_panel/store/actions.js +++ b/app/assets/javascripts/admin/statistics_panel/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -21,7 +21,7 @@ export const receiveStatisticsSuccess = ({ commit }, statistics) => export const receiveStatisticsError = ({ commit }, error) => { commit(types.RECEIVE_STATISTICS_ERROR, error); - createFlash({ + createAlert({ message: s__('AdminDashboard|Error loading the statistics. Please try again'), }); }; diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index b4b84594276..f569cda0a4b 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -1,6 +1,6 @@ <script> import { GlSkeletonLoader, GlTable } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; import { thWidthPercent } from '~/lib/utils/table_utility'; import { s__, __ } from '~/locale'; @@ -50,7 +50,7 @@ export default { }, {}); }, error(error) { - createFlash({ + createAlert({ message: this.$options.i18n.groupCountFetchError, captureError: true, error, 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 37a6ea16018..c0cac958a42 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -216,8 +216,11 @@ export default { this.pagination = initialPaginationState; this.sort = sortObjectToString({ sortBy, sortDesc }); }, + showAlertLink({ iid }) { + return joinPaths(window.location.pathname, iid, 'details'); + }, navigateToAlertDetails({ iid }, index, { metaKey }) { - return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey); + return visitUrl(this.showAlertLink({ iid }), metaKey); }, hasAssignees(assignees) { return Boolean(assignees.nodes?.length); @@ -357,7 +360,7 @@ export default { :title="`${item.iid} - ${item.title}`" data-testid="idField" > - #{{ item.iid }} {{ item.title }} + <gl-link :href="showAlertLink(item)"> #{{ item.iid }} {{ item.title }} </gl-link> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 1f970ef1846..bf456b6adaa 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -2,7 +2,7 @@ import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import httpStatusCodes from '~/lib/utils/http_status'; import { typeSet, i18n, tabIndices } from '../constants'; @@ -75,7 +75,7 @@ export default { return nodes; }, error(err) { - createFlash({ message: err }); + createAlert({ message: err }); }, }, currentIntegration: { @@ -125,7 +125,7 @@ export default { .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0]; if (error) { - createFlash({ message: error }); + createAlert({ message: error }); return; } @@ -140,7 +140,7 @@ export default { } }) .catch(() => { - createFlash({ message: ADD_INTEGRATION_ERROR }); + createAlert({ message: ADD_INTEGRATION_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -161,7 +161,7 @@ export default { .then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => { const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0]; if (error) { - createFlash({ message: error }); + createAlert({ message: error }); return; } @@ -174,13 +174,13 @@ export default { this.clearCurrentIntegration({ type }); } - createFlash({ + createAlert({ message: this.$options.i18n.changesSaved, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); }) .catch(() => { - createFlash({ message: UPDATE_INTEGRATION_ERROR }); + createAlert({ message: UPDATE_INTEGRATION_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -198,7 +198,7 @@ export default { const [error] = httpIntegrationResetToken?.errors || prometheusIntegrationResetToken.errors; if (error) { - return createFlash({ message: error }); + return createAlert({ message: error }); } const integration = @@ -212,14 +212,14 @@ export default { variables: integration, }); - return createFlash({ + return createAlert({ message: this.$options.i18n.changesSaved, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); }, ) .catch(() => { - createFlash({ message: RESET_INTEGRATION_TOKEN_ERROR }); + createAlert({ message: RESET_INTEGRATION_TOKEN_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -252,7 +252,7 @@ export default { ); }, error() { - createFlash({ message: DEFAULT_ERROR }); + createAlert({ message: DEFAULT_ERROR }); }, }); } else { @@ -272,7 +272,7 @@ export default { this.tabIndex = tabIndex; }) .catch(() => { - createFlash({ message: DEFAULT_ERROR }); + createAlert({ message: DEFAULT_ERROR }); }); }, deleteIntegration({ id, type }) { @@ -290,16 +290,16 @@ export default { .then(({ data: { httpIntegrationDestroy } = {} } = {}) => { const error = httpIntegrationDestroy?.errors[0]; if (error) { - return createFlash({ message: error }); + return createAlert({ message: error }); } this.clearCurrentIntegration({ type }); - return createFlash({ + return createAlert({ message: this.$options.i18n.integrationRemoved, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); }) .catch(() => { - createFlash({ message: DELETE_INTEGRATION_ERROR }); + createAlert({ message: DELETE_INTEGRATION_ERROR }); }) .finally(() => { this.isUpdating = false; @@ -320,9 +320,9 @@ export default { return service .updateTestAlert(payload) .then(() => { - return createFlash({ + return createAlert({ message: this.$options.i18n.alertSent, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); }) .catch((error) => { @@ -330,7 +330,7 @@ export default { if (error.response?.status === httpStatusCodes.FORBIDDEN) { message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR; } - createFlash({ message }); + createAlert({ message }); }); }, saveAndTestAlertPayload(integration, payload) { diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js index a50b6515afa..2e64312b0e0 100644 --- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js +++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js @@ -1,5 +1,5 @@ import produce from 'immer'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages'; @@ -59,7 +59,7 @@ const addIntegrationToStore = ( }; const onError = (data, message) => { - createFlash({ message }); + createAlert({ message }); throw new Error(data.errors); }; diff --git a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue index 567e534d9cf..ffb61230661 100644 --- a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue +++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue @@ -1,7 +1,7 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; import { flatten, isEqual, keyBy } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { sprintf, s__ } from '~/locale'; import { METRICS_POPOVER_CONTENT } from '../constants'; import { removeFlash, prepareTimeMetricsData } from '../utils'; @@ -17,7 +17,7 @@ const requestData = ({ request, endpoint, path, params, name }) => { ), { requestTypeName: name }, ); - createFlash({ message }); + createAlert({ message }); }); }; diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index 457a52d3807..5651789e2c7 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -1,7 +1,7 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { number } from '~/lib/utils/unit_format'; import { __, s__ } from '~/locale'; import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql'; @@ -35,7 +35,7 @@ export default { }); }, error(error) { - createFlash({ + createAlert({ message: this.$options.i18n.loadCountsError, captureError: true, error, diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index 667aa878261..5b5abbdf50b 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -5,6 +5,7 @@ import { buildApiUrl } from './api_utils'; const PROJECTS_PATH = '/api/:version/projects.json'; const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id'; const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size'; +const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations'; export function getProjects(query, options, callback = () => {}) { const url = buildApiUrl(PROJECTS_PATH); @@ -42,3 +43,10 @@ export function updateRepositorySize(projectPath) { ); return axios.post(url); } + +export const getTransferLocations = (projectId, params = {}) => { + const url = buildApiUrl(PROJECT_TRANSFER_LOCATIONS_PATH).replace(':id', projectId); + const defaultParams = { per_page: DEFAULT_PER_PAGE }; + + return axios.get(url, { params: { ...defaultParams, ...params } }); +}; diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index c743b18d572..369abe95d49 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -1,5 +1,5 @@ import { DEFAULT_PER_PAGE } from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; @@ -55,7 +55,7 @@ export function getUserProjects(userId, query, options, callback) { }) .then(({ data }) => callback(data)) .catch(() => - createFlash({ + createAlert({ message: __('Something went wrong while fetching projects'), }), ); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index a3ffb4df7b7..9ab1d6bfd80 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -7,7 +7,7 @@ import { getEmojiScoreWithIntent } from '~/emoji/utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; import { dispose, fixTitle } from '~/tooltips'; -import createFlash from './flash'; +import { createAlert } from '~/flash'; import axios from './lib/utils/axios_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; @@ -491,7 +491,7 @@ export class AwardsHandler { } }) .catch(() => - createFlash({ + createAlert({ message: __('Something went wrong on our end.'), }), ); diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index d1570e16639..f68666f8a0c 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -2,7 +2,7 @@ import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; import { escape, debounce } from 'lodash'; import { mapActions, mapState } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { s__, sprintf } from '~/locale'; import createEmptyBadge from '../empty_badge'; import Badge from './badge.vue'; @@ -136,14 +136,14 @@ export default { if (this.isEditing) { return this.saveBadge() .then(() => { - createFlash({ + createAlert({ message: s__('Badges|Badge saved.'), - type: 'notice', + variant: VARIANT_INFO, }); this.wasValidated = false; }) .catch((error) => { - createFlash({ + createAlert({ message: s__( 'Badges|Saving the badge failed, please check the entered URLs and try again.', ), @@ -154,14 +154,14 @@ export default { return this.addBadge() .then(() => { - createFlash({ + createAlert({ message: s__('Badges|New badge added.'), - type: 'notice', + variant: VARIANT_INFO, }); this.wasValidated = false; }) .catch((error) => { - createFlash({ + createAlert({ message: s__( 'Badges|Adding the badge failed, please check the entered URLs and try again.', ), diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 0303930de5d..a7a21d65475 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlModal } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { __, s__ } from '~/locale'; import Badge from './badge.vue'; import BadgeForm from './badge_form.vue'; @@ -40,13 +40,13 @@ export default { onSubmitModal() { this.deleteBadge(this.badgeInModal) .then(() => { - createFlash({ + createAlert({ message: s__('Badges|The badge was deleted.'), - type: 'notice', + variant: VARIANT_INFO, }); }) .catch((error) => { - createFlash({ + createAlert({ message: s__('Badges|Deleting the badge failed, please try again.'), }); throw error; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index 2b0aaa74e83..feac6f10b1e 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -1,5 +1,5 @@ import { isEmpty } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { scrollToElement } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; @@ -18,7 +18,7 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => return res; }) .catch(() => { - createFlash({ + createAlert({ message: __('An error occurred adding a draft to the thread.'), }); }); @@ -32,7 +32,7 @@ export const createNewDraft = ({ commit }, { endpoint, data }) => return res; }) .catch(() => { - createFlash({ + createAlert({ message: __('An error occurred adding a new draft.'), }); }); @@ -44,7 +44,7 @@ export const deleteDraft = ({ commit, getters }, draft) => commit(types.DELETE_DRAFT, draft.id); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while deleting the comment'), }), ); @@ -62,7 +62,7 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) => }); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while fetching pending comments'), }), ); @@ -122,7 +122,7 @@ export const updateDraft = ( .then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data)) .then(callback) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while updating the comment'), }), ); diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js index df5214ea7ab..75e4ae63c18 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js @@ -22,7 +22,11 @@ export const draftsPerFileHashAndLine = (state) => acc[draft.file_hash] = {}; } - acc[draft.file_hash][draft.line_code] = draft; + if (!acc[draft.file_hash][draft.line_code]) { + acc[draft.file_hash][draft.line_code] = []; + } + + acc[draft.file_hash][draft.line_code].push(draft); } return acc; @@ -61,18 +65,15 @@ export const shouldRenderDraftRowInDiscussion = (state, getters) => (discussionI export const draftForDiscussion = (state, getters) => (discussionId) => getters.draftsPerDiscussionId[discussionId] || {}; -export const draftForLine = (state, getters) => (diffFileSha, line, side = null) => { +export const draftsForLine = (state, getters) => (diffFileSha, line, side = null) => { const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; - const key = side !== null ? parallelLineKey(line, side) : line.line_code; + const showDraftsForThisSide = showDraftOnSide(line, side); - if (draftsForFile) { - const draft = draftsForFile[key]; - if (draft && showDraftOnSide(line, side)) { - return draft; - } + if (showDraftsForThisSide && draftsForFile?.[key]) { + return draftsForFile[key]; } - return {}; + return []; }; export const draftsForFile = (state) => (diffFileSha) => diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 679940d1317..68f5180cc03 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,7 +1,7 @@ /* eslint-disable func-names */ import $ from 'jquery'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -80,7 +80,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { success(data); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while fetching Markdown preview'), }), ); diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index 23b66405844..3239375bf7c 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -145,6 +145,20 @@ export const LINK_TEXT = { customizable: false, }; +export const INDENT_LINE = { + id: 'editing.indentLine', + description: __('Indent line'), + defaultKeys: ['mod+]'], // eslint-disable-line @gitlab/require-i18n-strings + customizable: false, +}; + +export const OUTDENT_LINE = { + id: 'editing.outdentLine', + description: __('Outdent line'), + defaultKeys: ['mod+['], // eslint-disable-line @gitlab/require-i18n-strings + customizable: false, +}; + export const TOGGLE_MARKDOWN_PREVIEW = { id: 'editing.toggleMarkdownPreview', description: __('Toggle Markdown preview'), diff --git a/app/assets/javascripts/blame/blame_redirect.js b/app/assets/javascripts/blame/blame_redirect.js new file mode 100644 index 00000000000..155e2a3a2cd --- /dev/null +++ b/app/assets/javascripts/blame/blame_redirect.js @@ -0,0 +1,23 @@ +import { setUrlParams } from '~/lib/utils/url_utility'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; + +export default function redirectToCorrectBlamePage() { + const { hash } = window.location; + const linesPerPage = parseInt(document.querySelector('.js-per-page').dataset.perPage, 10); + const params = new URLSearchParams(window.location.search); + const currentPage = parseInt(params.get('page'), 10); + const isPaginationDisabled = params.get('no_pagination'); + if (hash && linesPerPage && !isPaginationDisabled) { + const lineNumber = parseInt(hash.split('#L')[1], 10); + const pageToRedirect = Math.ceil(lineNumber / linesPerPage); + const isRedirectNeeded = + (pageToRedirect > 1 && pageToRedirect !== currentPage) || pageToRedirect < currentPage; + if (isRedirectNeeded) { + createAlert({ + message: __('Please wait a few moments while we load the file history for this line.'), + }); + window.location.href = setUrlParams({ page: pageToRedirect }); + } + } +} diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js index 2831c37838b..4fbc9044cf0 100644 --- a/app/assets/javascripts/blob/3d_viewer/index.js +++ b/app/assets/javascripts/blob/3d_viewer/index.js @@ -1,6 +1,6 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'; -import * as THREE from 'three/build/three.module'; +import * as THREE from 'three'; import MeshObject from './mesh_object'; export default class Renderer { diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js index 5322dc00e86..6c816b2d07f 100644 --- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js +++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js @@ -1,4 +1,4 @@ -import { Matrix4, MeshLambertMaterial, Mesh } from 'three/build/three.module'; +import { Matrix4, MeshLambertMaterial, Mesh } from 'three'; const defaultColor = 0xe24329; const materials = { diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js index 0a5bcf326a1..df38c5400e2 100644 --- a/app/assets/javascripts/blob/blob_line_permalink_updater.js +++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js @@ -22,6 +22,7 @@ const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => { }; function BlobLinePermalinkUpdater(blobContentHolder, lineNumberSelector, elementsToUpdate) { + if (!blobContentHolder) return; const updateBlameAndBlobPermalinkCb = () => { // Wait for the hash to update from the LineHighlighter callback setTimeout(() => { diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue index 07da262ec9a..b3410b94b98 100644 --- a/app/assets/javascripts/blob/components/table_contents.vue +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -26,6 +26,8 @@ export default { } else if (blobViewerAttr('data-loaded') === 'true') { this.isHidden = false; this.generateHeaders(); + + this.observer.disconnect(); } }); @@ -47,13 +49,11 @@ export default { if (headers.length) { const firstHeader = getHeaderNumber(headers[0]); - headers.forEach((el) => { - this.items.push({ - text: el.textContent.trim(), - anchor: el.querySelector('a').getAttribute('id'), - spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0), - }); - }); + this.items = headers.map((el) => ({ + text: el.textContent.trim(), + anchor: el.querySelector('a').getAttribute('id'), + spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0), + })); } }, }, diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 991f98c89e7..adc2649e5df 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import Api from '~/api'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; @@ -155,7 +155,7 @@ export default class FileTemplateMediator { } }) .catch((err) => - createFlash({ + createAlert({ message: __(`An error occurred while fetching the template: ${err}`), }), ); diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 4c497db9842..44b75cc3e68 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -1,5 +1,5 @@ import { SwaggerUIBundle } from 'swagger-ui-dist'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; export default () => { @@ -15,7 +15,7 @@ export default () => { }); }) .catch((error) => { - createFlash({ + createAlert({ message: __('Something went wrong while initializing the OpenAPI viewer'), }); throw error; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 5ca3f131d99..8d323c335d3 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { REPO_BLOB_LOAD_VIEWER_START, @@ -69,7 +69,7 @@ export const handleBlobRichViewer = (viewer, type) => { loadRichBlobViewer(type) .then((module) => module?.default(viewer)) .catch((error) => { - createFlash({ + createAlert({ message: __('Error loading file viewer.'), }); throw error; @@ -221,7 +221,7 @@ export class BlobViewer { }); }) .catch(() => - createFlash({ + createAlert({ message: __('Error loading viewer'), }), ); diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index d73e1cc43b0..1c9c99dcc2f 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; import BlobFileDropzone from '../blob/blob_file_dropzone'; @@ -79,7 +79,7 @@ export default () => { initPopovers(); }) .catch((e) => - createFlash({ + createAlert({ message: e, }), ); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 2ee2e199358..78e3f934183 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -3,7 +3,7 @@ import { SourceEditorExtension } from '~/editor/extensions/source_editor_extensi import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; import SourceEditor from '~/editor/source_editor'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { insertFinalNewline } from '~/lib/utils/text_utility'; @@ -44,7 +44,7 @@ export default class EditBlob { }, ]); } catch (e) { - createFlash({ + createAlert({ message: `${BLOB_EDITOR_ERROR}: ${e}`, }); } @@ -130,7 +130,7 @@ export default class EditBlob { currentPane.renderGFM(); }) .catch(() => - createFlash({ + createAlert({ message: BLOB_PREVIEW_ERROR, }), ); diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 92a623d65d4..3a2b11a649d 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -17,9 +17,9 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/toolt import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; import { ListType } from '../constants'; import eventHub from '../eventhub'; -import BoardBlockedIcon from './board_blocked_icon.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; @@ -34,7 +34,7 @@ export default { IssueDueDate, IssueTimeEstimate, IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), - BoardBlockedIcon, + IssuableBlockedIcon, GlSprintf, BoardCardMoveToPosition, WorkItemTypeIcon, @@ -218,7 +218,7 @@ export default { <div> <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word"> - <board-blocked-icon + <issuable-blocked-icon v-if="item.blocked" :item="item" :unique-id="`${item.id}${list.id}`" @@ -250,7 +250,8 @@ export default { >{{ item.title }}</a > </h4> - <board-card-move-to-position :item="item" :list="list" :index="index" /> + <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved --> + <board-card-move-to-position v-if="!isEpicBoard" :item="item" :list="list" :index="index" /> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> <template v-for="label in orderedLabels"> @@ -397,7 +398,6 @@ export default { :img-size="avatarSize" class="js-no-trigger user-avatar-link" tooltip-placement="bottom" - :enforce-gl-avatar="true" > <span class="js-assignee-tooltip"> <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index fa0c798ca9d..11a5d89cc8c 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -8,6 +8,7 @@ import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, FILTER_ANY, + TOKEN_TYPE_HEALTH, } 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'; @@ -55,6 +56,7 @@ export default { myReactionEmoji, releaseTag, confidential, + healthStatus, } = this.filterParams; const filteredSearchValue = []; @@ -154,6 +156,13 @@ export default { }); } + if (healthStatus) { + filteredSearchValue.push({ + type: TOKEN_TYPE_HEALTH, + value: { data: healthStatus, operator: '=' }, + }); + } + if (this.filterParams['not[authorUsername]']) { filteredSearchValue.push({ type: 'author', @@ -248,6 +257,7 @@ export default { iterationCadenceId, releaseTag, confidential, + healthStatus, } = this.filterParams; let iteration = iterationId; let cadence = iterationCadenceId; @@ -292,6 +302,7 @@ export default { my_reaction_emoji: myReactionEmoji, release_tag: releaseTag, confidential, + [TOKEN_TYPE_HEALTH]: healthStatus, }, (value) => { if (value || value === false) { @@ -390,6 +401,9 @@ export default { case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); break; + case TOKEN_TYPE_HEALTH: + filterParams.healthStatus = filter.value.data; + break; default: break; } diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 9f359a25234..eb889344c1e 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -4,7 +4,6 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { formType } from '../constants'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; @@ -45,7 +44,6 @@ export default { BoardConfigurationOptions, GlAlert, }, - mixins: [glFeatureFlagMixin()], inject: { fullPath: { default: '', @@ -233,9 +231,8 @@ export default { } }, setIteration(iteration) { - if (this.glFeatures.iterationCadences) { - this.board.iterationCadenceId = iteration.iterationCadenceId; - } + this.board.iterationCadenceId = iteration.iterationCadenceId; + this.$set(this.board, 'iteration', { id: iteration.id, }); diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index ed22a375271..91b7f5004ad 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -2,8 +2,6 @@ import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql import { __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; -import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql'; -import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; import updateBoardListMutation from './graphql/board_list_update.mutation.graphql'; @@ -67,15 +65,6 @@ export const listsQuery = { }, }; -export const blockingIssuablesQueries = { - [issuableTypes.issue]: { - query: boardBlockingIssuesQuery, - }, - [issuableTypes.epic]: { - query: boardBlockingEpicsQuery, - }, -}; - export const updateListQueries = { [issuableTypes.issue]: { mutation: updateBoardListMutation, diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 854717ed4c4..a7003edba47 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -86,6 +86,7 @@ function mountBoardApp(el) { milestoneListsAvailable: parseBoolean(el.dataset.milestoneListsAvailable), assigneeListsAvailable: parseBoolean(el.dataset.assigneeListsAvailable), iterationListsAvailable: parseBoolean(el.dataset.iterationListsAvailable), + healthStatusFeatureAvailable: parseBoolean(el.dataset.healthStatusFeatureAvailable), allowScopedLabels: parseBoolean(el.dataset.scopedLabels), swimlanesFeatureAvailable: gon.licensed_features?.swimlanes, multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleBoardsAvailable), diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index 17fd3939441..d05b53f1a50 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import DivergenceGraph from './components/divergence_graph.vue'; @@ -51,7 +51,7 @@ export default (endpoint, defaultBranch) => { }); }) .catch(() => - createFlash({ + createAlert({ message: __('Error fetching diverging counts for branches. Please try again.'), }), ); diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index fea4b56153f..1f8096da94d 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -1,5 +1,13 @@ <script> -import { GlTable, GlButton, GlBadge, GlTooltipDirective, GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { + GlAlert, + GlAvatar, + GlAvatarLink, + GlBadge, + GlButton, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -15,14 +23,15 @@ export default { ), }, components: { - GlTable, - GlButton, - GlBadge, ClipboardButton, - TooltipOnTruncate, - GlAvatarLink, + GlAlert, GlAvatar, + GlAvatarLink, + GlBadge, + GlButton, + GlTable, TimeAgoTooltip, + TooltipOnTruncate, }, directives: { GlTooltip: GlTooltipDirective, @@ -138,13 +147,15 @@ export default { /> </template> </gl-table> - <div + <gl-alert v-else + variant="warning" + :dismissible="false" + :show-icon="false" data-testid="no_triggers_content" data-qa-selector="no_triggers_content" - class="settings-message gl-text-center gl-mb-3" > {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} - </div> + </gl-alert> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue index 59ddf4b19d8..8d891ff1746 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue @@ -1,5 +1,7 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import { reportMessageToSentry } from '../utils'; import getAdminVariables from '../graphql/queries/variables.query.graphql'; import { ADD_MUTATION_ACTION, @@ -21,7 +23,11 @@ export default { data() { return { adminVariables: [], + hasNextPage: false, isInitialLoading: true, + isLoadingMoreItems: false, + loadingCounter: 0, + pageInfo: {}, }; }, apollo: { @@ -30,8 +36,29 @@ export default { update(data) { return data?.ciVariables?.nodes || []; }, + result({ data }) { + this.pageInfo = data?.ciVariables?.pageInfo || this.pageInfo; + this.hasNextPage = this.pageInfo?.hasNextPage || false; + + // Because graphQL has a limit of 100 items, + // we batch load all the variables by making successive queries + // to keep the same UX. As a safeguard, we make sure that we cannot go over + // 20 consecutive API calls, which means 2000 variables loaded maximum. + if (!this.hasNextPage) { + this.isLoadingMoreItems = false; + } else if (this.loadingCounter < 20) { + this.hasNextPage = false; + this.fetchMoreVariables(); + this.loadingCounter += 1; + } else { + createAlert({ message: this.$options.tooManyCallsError }); + reportMessageToSentry(this.$options.componentName, this.$options.tooManyCallsError, {}); + } + }, error() { - createFlash({ message: variableFetchErrorText }); + this.isLoadingMoreItems = false; + this.hasNextPage = false; + createAlert({ message: variableFetchErrorText }); }, watchLoading(flag) { if (!flag) { @@ -42,7 +69,10 @@ export default { }, computed: { isLoading() { - return this.$apollo.queries.adminVariables.loading && this.isInitialLoading; + return ( + (this.$apollo.queries.adminVariables.loading && this.isInitialLoading) || + this.isLoadingMoreItems + ); }, }, methods: { @@ -52,6 +82,15 @@ export default { deleteVariable(variable) { this.variableMutation(DELETE_MUTATION_ACTION, variable); }, + fetchMoreVariables() { + this.isLoadingMoreItems = true; + + this.$apollo.queries.adminVariables.fetchMore({ + variables: { + after: this.pageInfo.endCursor, + }, + }); + }, updateVariable(variable) { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, @@ -66,10 +105,9 @@ export default { }, }); - const { errors } = data[currentMutation.name]; - - if (errors.length > 0) { - createFlash({ message: errors[0] }); + if (data[currentMutation.name]?.errors?.length) { + const { errors } = data[currentMutation.name]; + createAlert({ message: errors[0] }); } else { // The writing to cache for admin variable is not working // because there is no ID in the cache at the top level. @@ -77,10 +115,14 @@ export default { this.$apollo.queries.adminVariables.refetch(); } } catch { - createFlash({ message: genericMutationErrorText }); + createAlert({ message: genericMutationErrorText }); } }, }, + componentName: 'InstanceVariables', + i18n: { + tooManyCallsError: __('Maximum number of variables loaded (2000)'), + }, mutationData: { [ADD_MUTATION_ACTION]: { action: addAdminVariable, name: 'addAdminVariable' }, [UPDATE_MUTATION_ACTION]: { action: updateAdminVariable, name: 'updateAdminVariable' }, diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue index 3522243e3e7..4af696b8dab 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue @@ -1,7 +1,9 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { reportMessageToSentry } from '../utils'; import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; import { ADD_MUTATION_ACTION, @@ -25,6 +27,10 @@ export default { data() { return { groupVariables: [], + hasNextPage: false, + isLoadingMoreItems: false, + loadingCounter: 0, + pageInfo: {}, }; }, apollo: { @@ -38,8 +44,28 @@ export default { update(data) { return data?.group?.ciVariables?.nodes || []; }, + result({ data }) { + this.pageInfo = data?.group?.ciVariables?.pageInfo || this.pageInfo; + this.hasNextPage = this.pageInfo?.hasNextPage || false; + // Because graphQL has a limit of 100 items, + // we batch load all the variables by making successive queries + // to keep the same UX. As a safeguard, we make sure that we cannot go over + // 20 consecutive API calls, which means 2000 variables loaded maximum. + if (!this.hasNextPage) { + this.isLoadingMoreItems = false; + } else if (this.loadingCounter < 20) { + this.hasNextPage = false; + this.fetchMoreVariables(); + this.loadingCounter += 1; + } else { + createAlert({ message: this.$options.tooManyCallsError }); + reportMessageToSentry(this.$options.componentName, this.$options.tooManyCallsError, {}); + } + }, error() { - createFlash({ message: variableFetchErrorText }); + this.isLoadingMoreItems = false; + this.hasNextPage = false; + createAlert({ message: variableFetchErrorText }); }, }, }, @@ -48,7 +74,7 @@ export default { return this.glFeatures.groupScopedCiVariables; }, isLoading() { - return this.$apollo.queries.groupVariables.loading; + return this.$apollo.queries.groupVariables.loading || this.isLoadingMoreItems; }, }, methods: { @@ -58,6 +84,16 @@ export default { deleteVariable(variable) { this.variableMutation(DELETE_MUTATION_ACTION, variable); }, + fetchMoreVariables() { + this.isLoadingMoreItems = true; + + this.$apollo.queries.groupVariables.fetchMore({ + variables: { + fullPath: this.groupPath, + after: this.pageInfo.endCursor, + }, + }); + }, updateVariable(variable) { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, @@ -74,16 +110,19 @@ export default { }, }); - const { errors } = data[currentMutation.name]; - - if (errors.length > 0) { - createFlash({ message: errors[0] }); + if (data[currentMutation.name]?.errors?.length) { + const { errors } = data[currentMutation.name]; + createAlert({ message: errors[0] }); } } catch { - createFlash({ message: genericMutationErrorText }); + createAlert({ message: genericMutationErrorText }); } }, }, + componentName: 'GroupVariables', + i18n: { + tooManyCallsError: __('Maximum number of variables loaded (2000)'), + }, mutationData: { [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' }, [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' }, diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue index 29db02a3c59..6bd549817f8 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue @@ -1,9 +1,10 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql'; import getProjectVariables from '../graphql/queries/project_variables.query.graphql'; -import { mapEnvironmentNames } from '../utils'; +import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, @@ -25,6 +26,10 @@ export default { inject: ['endpoint', 'projectFullPath', 'projectId'], data() { return { + hasNextPage: false, + isLoadingMoreItems: false, + loadingCounter: 0, + pageInfo: {}, projectEnvironments: [], projectVariables: [], }; @@ -41,21 +46,42 @@ export default { return mapEnvironmentNames(data?.project?.environments?.nodes); }, error() { - createFlash({ message: environmentFetchErrorText }); + createAlert({ message: environmentFetchErrorText }); }, }, projectVariables: { query: getProjectVariables, variables() { return { + after: null, fullPath: this.projectFullPath, }; }, update(data) { return data?.project?.ciVariables?.nodes || []; }, + result({ data }) { + this.pageInfo = data?.project?.ciVariables?.pageInfo || this.pageInfo; + this.hasNextPage = this.pageInfo?.hasNextPage || false; + // Because graphQL has a limit of 100 items, + // we batch load all the variables by making successive queries + // to keep the same UX. As a safeguard, we make sure that we cannot go over + // 20 consecutive API calls, which means 2000 variables loaded maximum. + if (!this.hasNextPage) { + this.isLoadingMoreItems = false; + } else if (this.loadingCounter < 20) { + this.hasNextPage = false; + this.fetchMoreVariables(); + this.loadingCounter += 1; + } else { + createAlert({ message: this.$options.tooManyCallsError }); + reportMessageToSentry(this.$options.componentName, this.$options.tooManyCallsError, {}); + } + }, error() { - createFlash({ message: variableFetchErrorText }); + this.isLoadingMoreItems = false; + this.hasNextPage = false; + createAlert({ message: variableFetchErrorText }); }, }, }, @@ -63,7 +89,8 @@ export default { isLoading() { return ( this.$apollo.queries.projectVariables.loading || - this.$apollo.queries.projectEnvironments.loading + this.$apollo.queries.projectEnvironments.loading || + this.isLoadingMoreItems ); }, }, @@ -74,6 +101,16 @@ export default { deleteVariable(variable) { this.variableMutation(DELETE_MUTATION_ACTION, variable); }, + fetchMoreVariables() { + this.isLoadingMoreItems = true; + + this.$apollo.queries.projectVariables.fetchMore({ + variables: { + fullPath: this.projectFullPath, + after: this.pageInfo.endCursor, + }, + }); + }, updateVariable(variable) { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, @@ -89,16 +126,19 @@ export default { variable, }, }); - - const { errors } = data[currentMutation.name]; - if (errors.length > 0) { - createFlash({ message: errors[0] }); + if (data[currentMutation.name]?.errors?.length) { + const { errors } = data[currentMutation.name]; + createAlert({ message: errors[0] }); } - } catch (e) { - createFlash({ message: genericMutationErrorText }); + } catch { + createAlert({ message: genericMutationErrorText }); } }, }, + componentName: 'ProjectVariables', + i18n: { + tooManyCallsError: __('Maximum number of variables loaded (2000)'), + }, mutationData: { [ADD_MUTATION_ACTION]: { action: addProjectVariable, name: 'addProjectVariable' }, [UPDATE_MUTATION_ACTION]: { action: updateProjectVariable, name: 'updateProjectVariable' }, diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 1bb94080694..959ef6864fb 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -75,8 +75,7 @@ export default { props: { isLoading: { type: Boolean, - required: false, - default: false, + required: true, }, variables: { type: Array, diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue index 9acc9fbffb6..f1fe188348d 100644 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue @@ -9,10 +9,10 @@ export default { LegacyCiVariableTable, }, computed: { - ...mapState(['isGroup']), + ...mapState(['isGroup', 'isProject']), }, mounted() { - if (!this.isGroup) { + if (this.isProject) { this.fetchEnvironments(); } }, diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index e2dd28cdaa1..ccad08ef8b6 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -52,7 +52,7 @@ export const groupString = 'Group'; // eslint-disable-next-line @gitlab/require-i18n-strings export const instanceString = 'Instance'; // eslint-disable-next-line @gitlab/require-i18n-strings -export const projectString = 'Instance'; +export const projectString = 'Project'; export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed'; export const AWS_TIP_MESSAGE = __( diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql index c6dd6d4faaf..b5555fe4401 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -1,9 +1,13 @@ #import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" -query getGroupVariables($fullPath: ID!) { +query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { group(fullPath: $fullPath) { id - ciVariables { + ciVariables(after: $after, first: $first) { + pageInfo { + ...PageInfo + } nodes { ...BaseCiVariable ... on CiGroupVariable { diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql index a60a50e4bc4..08b5bf7af16 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql @@ -1,9 +1,13 @@ #import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" -query getProjectVariables($fullPath: ID!) { +query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { project(fullPath: $fullPath) { id - ciVariables { + ciVariables(after: $after, first: $first) { + pageInfo { + ...PageInfo + } nodes { ...BaseCiVariable environmentScope diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql index 95056842b49..2667d6606fe 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql @@ -1,7 +1,11 @@ #import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" -query getVariables { - ciVariables { +query getVariables($after: String, $first: Int = 100) { + ciVariables(after: $after, first: $first) { + pageInfo { + ...PageInfo + } nodes { ...BaseCiVariable ... on CiInstanceVariable { diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/settings.js index c041531ae30..ecdc4f220bd 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js +++ b/app/assets/javascripts/ci_variable_list/graphql/settings.js @@ -3,7 +3,7 @@ import { convertObjectPropsToCamelCase, convertObjectPropsToSnakeCase, } from '../../lib/utils/common_utils'; -import { getIdFromGraphQLId } from '../../graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '../../graphql_shared/utils'; import { GRAPHQL_GROUP_TYPE, GRAPHQL_PROJECT_TYPE, @@ -30,6 +30,7 @@ const mapVariableTypes = (variables = [], kind) => { return { __typename: `Ci${kind}Variable`, ...convertObjectPropsToCamelCase(ciVar), + id: convertToGraphQLId('Ci::Variable', ciVar.id), variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType, }; }); @@ -40,9 +41,16 @@ const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => { errors, project: { __typename: GRAPHQL_PROJECT_TYPE, - id: projectId, + id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, projectId), ciVariables: { - __typename: 'CiVariableConnection', + __typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, nodes: mapVariableTypes(data.variables, projectString), }, }, @@ -54,9 +62,16 @@ const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => { errors, group: { __typename: GRAPHQL_GROUP_TYPE, - id: groupId, + id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, groupId), ciVariables: { - __typename: 'CiVariableConnection', + __typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, nodes: mapVariableTypes(data.variables, groupString), }, }, @@ -68,24 +83,42 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => { errors, ciVariables: { __typename: `Ci${instanceString}VariableConnection`, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, nodes: mapVariableTypes(data.variables, instanceString), }, }; }; -const callProjectEndpoint = async ({ +async function callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache, destroy = false, -}) => { +}) { try { const { data } = await axios.patch(endpoint, { variables_attributes: [prepareVariableForApi({ variable, destroy })], }); - return prepareProjectGraphQLResponse({ data, projectId }); + + const graphqlData = prepareProjectGraphQLResponse({ data, projectId }); + + cache.writeQuery({ + query: getProjectVariables, + variables: { + fullPath, + after: null, + }, + data: graphqlData, + }); + return graphqlData; } catch (e) { return prepareProjectGraphQLResponse({ data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }), @@ -93,7 +126,7 @@ const callProjectEndpoint = async ({ errors: [...e.response.data], }); } -}; +} const callGroupEndpoint = async ({ endpoint, @@ -107,7 +140,15 @@ const callGroupEndpoint = async ({ const { data } = await axios.patch(endpoint, { variables_attributes: [prepareVariableForApi({ variable, destroy })], }); - return prepareGroupGraphQLResponse({ data, groupId }); + + const graphqlData = prepareGroupGraphQLResponse({ data, groupId }); + + cache.writeQuery({ + query: getGroupVariables, + data: graphqlData, + }); + + return graphqlData; } catch (e) { return prepareGroupGraphQLResponse({ data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }), @@ -123,7 +164,14 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) variables_attributes: [prepareVariableForApi({ variable, destroy })], }); - return prepareAdminGraphQLResponse({ data }); + const graphqlData = prepareAdminGraphQLResponse({ data }); + + cache.writeQuery({ + query: getAdminVariables, + data: graphqlData, + }); + + return graphqlData; } catch (e) { return prepareAdminGraphQLResponse({ data: cache.readQuery({ query: getAdminVariables }), @@ -163,3 +211,46 @@ export const resolvers = { }, }, }; + +export const mergeVariables = (existing, incoming, { args }) => { + if (!existing || !args?.after) { + return incoming; + } + + const { nodes, ...rest } = incoming; + const result = rest; + result.nodes = [...existing.nodes, ...nodes]; + + return result; +}; + +export const cacheConfig = { + cacheConfig: { + typePolicies: { + Query: { + fields: { + ciVariables: { + keyArgs: false, + merge: mergeVariables, + }, + }, + }, + Project: { + fields: { + ciVariables: { + keyArgs: ['fullPath', 'endpoint', 'projectId'], + merge: mergeVariables, + }, + }, + }, + Group: { + fields: { + ciVariables: { + keyArgs: ['fullPath'], + merge: mergeVariables, + }, + }, + }, + }, + }, +}; diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index f5bdd4c7b1e..1b69da9e086 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -6,7 +6,7 @@ import CiAdminVariables from './components/ci_admin_variables.vue'; import CiGroupVariables from './components/ci_group_variables.vue'; import CiProjectVariables from './components/ci_project_variables.vue'; import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue'; -import { resolvers } from './graphql/resolvers'; +import { cacheConfig, resolvers } from './graphql/settings'; import createStore from './store'; const mountCiVariableListApp = (containerEl) => { @@ -45,7 +45,7 @@ const mountCiVariableListApp = (containerEl) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: createDefaultClient(resolvers, cacheConfig), }); return new Vue({ @@ -81,6 +81,7 @@ const mountLegacyCiVariableListApp = (containerEl) => { endpoint, projectId, isGroup, + isProject, maskableRegex, protectedByDefault, awsLogoSvgPath, @@ -92,6 +93,8 @@ const mountLegacyCiVariableListApp = (containerEl) => { maskedEnvironmentVariablesLink, environmentScopeLink, } = containerEl.dataset; + + const parsedIsProject = parseBoolean(isProject); const parsedIsGroup = parseBoolean(isGroup); const isProtectedByDefault = parseBoolean(protectedByDefault); @@ -99,6 +102,7 @@ const mountLegacyCiVariableListApp = (containerEl) => { endpoint, projectId, isGroup: parsedIsGroup, + isProject: parsedIsProject, maskableRegex, isProtectedByDefault, awsLogoSvgPath, diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index 8a182737e7b..ac31e845b0d 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -48,7 +48,7 @@ export const addVariable = ({ state, dispatch }) => { dispatch('fetchVariables'); }) .catch((error) => { - createFlash({ + createAlert({ message: error.response.data[0], }); dispatch('receiveAddVariableError', error); @@ -80,7 +80,7 @@ export const updateVariable = ({ state, dispatch }) => { dispatch('fetchVariables'); }) .catch((error) => { - createFlash({ + createAlert({ message: error.response.data[0], }); dispatch('receiveUpdateVariableError', error); @@ -109,7 +109,7 @@ export const fetchVariables = ({ dispatch, state }) => { dispatch('receiveVariablesSuccess', prepareDataForDisplay(data.variables)); }) .catch(() => { - createFlash({ + createAlert({ message: __('There was an error fetching the variables.'), }); }); @@ -139,7 +139,7 @@ export const deleteVariable = ({ dispatch, state }) => { dispatch('fetchVariables'); }) .catch((error) => { - createFlash({ + createAlert({ message: error.response.data[0], }); dispatch('receiveDeleteVariableError', error); @@ -162,7 +162,7 @@ export const fetchEnvironments = ({ dispatch, state }) => { dispatch('receiveEnvironmentsSuccess', prepareEnvironments(res.data)); }) .catch(() => { - createFlash({ + createAlert({ message: __('There was an error fetching the environments information.'), }); }); diff --git a/app/assets/javascripts/ci_variable_list/utils.js b/app/assets/javascripts/ci_variable_list/utils.js index 1faa97a5f73..eeca69274ce 100644 --- a/app/assets/javascripts/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci_variable_list/utils.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import { uniq } from 'lodash'; import { allEnvironments } from './constants'; @@ -48,3 +49,12 @@ export const convertEnvironmentScope = (environmentScope = '') => { export const mapEnvironmentNames = (nodes = []) => { return nodes.map((env) => env.name); }; + +export const reportMessageToSentry = (component, message, context) => { + Sentry.withScope((scope) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + scope.setContext('Vue data', context); + scope.setTag('component', component); + Sentry.captureMessage(message); + }); +}; diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue index 18c6503bfb2..ca65665b9ed 100644 --- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue +++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue @@ -132,11 +132,7 @@ export default { :key="key" class="agent-activity-list issuable-discussion" > - <h4 - class="gl-pb-4 gl-ml-5" - :class="$options.borderClasses" - data-testid="activity-section-title" - > + <h4 class="gl-pb-4" :class="$options.borderClasses" data-testid="activity-section-title"> {{ key }} </h4> diff --git a/app/assets/javascripts/clusters/agents/components/activity_history_item.vue b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue index 7792d89a575..ed11fe1130d 100644 --- a/app/assets/javascripts/clusters/agents/components/activity_history_item.vue +++ b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue @@ -45,8 +45,8 @@ export default { }; </script> <template> - <history-item :icon="eventDetails.eventTypeIcon" class="gl-my-0! gl-pr-0!"> - <strong> + <history-item :icon="eventDetails.eventTypeIcon" class="gl-my-0! gl-px-0! gl-pb-0!"> + <strong class="gl-pl-3 gl-font-lg"> <gl-sprintf :message="eventDetails.title" ><template v-if="eventDetails.titleIcon" #titleIcon ><gl-icon @@ -61,15 +61,15 @@ export default { </strong> <template #body> - <p class="gl-mt-2 gl-mb-0 gl-pb-2" :class="bodyClass"> + <p class="gl-mt-2 gl-mb-0 gl-ml-3 gl-pb-3 gl-text-secondary" :class="bodyClass"> <gl-sprintf :message="eventDetails.body"> <template #userName> - <span class="gl-font-weight-bold">{{ eventDetails.user.name }}</span> + <span class="gl-font-weight-bold gl-text-body">{{ eventDetails.user.name }}</span> <gl-link :href="eventDetails.user.webUrl">@{{ eventDetails.user.username }}</gl-link> </template> <template #strong="{ content }"> - <span class="gl-font-weight-bold"> {{ content }} </span> + <span class="gl-font-weight-bold gl-text-body"> {{ content }} </span> </template> </gl-sprintf> <time-ago-tooltip :time="eventDetails.recordedAt" /> diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index a8fef372637..21524c5b29e 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,7 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; import initProjectSelectDropdown from '~/project_select'; import Poll from '~/lib/utils/poll'; @@ -196,7 +196,7 @@ export default class Clusters { } static handleError() { - createFlash({ + createAlert({ message: s__('ClusterIntegration|Something went wrong on our end.'), }); } diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index d70b36e63bc..77d6d5eb009 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; @@ -70,7 +70,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => { commit(types.SET_LOADING_CLUSTERS, false); commit(types.SET_LOADING_NODES, false); - createFlash({ + createAlert({ message: s__('Clusters|An error occurred while loading clusters'), }); diff --git a/app/assets/javascripts/code_navigation/utils/dom_utils.js b/app/assets/javascripts/code_navigation/utils/dom_utils.js index 90af31b715c..66770cca8a2 100644 --- a/app/assets/javascripts/code_navigation/utils/dom_utils.js +++ b/app/assets/javascripts/code_navigation/utils/dom_utils.js @@ -6,26 +6,28 @@ const isBlank = (str) => !str || /^\s*$/.test(str); const isMatch = (s1, s2) => !isBlank(s1) && s1.trim() === s2.trim(); -const createSpan = (content) => { +const createSpan = (content, classList) => { const span = document.createElement('span'); span.innerText = content; + span.classList = classList || ''; return span; }; -const wrapSpacesWithSpans = (text) => text.replace(/ /g, createSpan(' ').outerHTML); +const wrapSpacesWithSpans = (text) => + text.replace(/ /g, createSpan(' ').outerHTML).replace(/\t/g, createSpan(' ').outerHTML); -const wrapTextWithSpan = (el, text) => { +const wrapTextWithSpan = (el, text, classList) => { if (isTextNode(el) && isMatch(el.textContent, text)) { - const newEl = createSpan(text.trim()); + const newEl = createSpan(text.trim(), classList); el.replaceWith(newEl); } }; -const wrapNodes = (text) => { +const wrapNodes = (text, classList) => { const wrapper = createSpan(); // eslint-disable-next-line no-unsanitized/property wrapper.innerHTML = wrapSpacesWithSpans(text); - wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text)); + wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text, classList)); return wrapper.childNodes; }; diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js index 46038df2f86..7a5fa9f4a35 100644 --- a/app/assets/javascripts/code_navigation/utils/index.js +++ b/app/assets/javascripts/code_navigation/utils/index.js @@ -17,11 +17,9 @@ export const addInteractionClass = ({ path, d, wrapTextNodes }) => { if (wrapTextNodes) { line.childNodes.forEach((elm) => { - if (isTextNode(elm)) { - // Highlight.js does not wrap all text nodes by default - // We need all text nodes to be wrapped in order to append code nav attributes - elm.replaceWith(...wrapNodes(elm.textContent)); - } + // Highlight.js does not wrap all text nodes by default + // We need all text nodes to be wrapped in order to append code nav attributes + elm.replaceWith(...wrapNodes(elm.textContent, elm.classList)); }); } diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 6890d7f6f44..b0a1c46e619 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -205,6 +205,12 @@ export default { mrPipelinesDocsPath: helpPagePath('ci/pipelines/merge_request_pipelines.md', { anchor: 'prerequisites', }), + runPipelinesInTheParentProjectHelpPath: helpPagePath( + '/ci/pipelines/merge_request_pipelines.html', + { + anchor: 'run-pipelines-in-the-parent-project', + }, + ), }; </script> <template> @@ -321,10 +327,7 @@ export default { s__('Pipelines|If you are unsure, please ask a project maintainer to review it for you.') }} </p> - <gl-link - href="/help/ci/pipelines/merge_request_pipelines.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" - target="_blank" - > + <gl-link :href="$options.runPipelinesInTheParentProjectHelpPath" target="_blank"> {{ s__('Pipelines|More Information') }} </gl-link> </gl-modal> diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js index f973bf51b57..d40cbe589c0 100644 --- a/app/assets/javascripts/commit_merge_requests.js +++ b/app/assets/javascripts/commit_merge_requests.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import createFlash from './flash'; +import { createAlert } from '~/flash'; import axios from './lib/utils/axios_utils'; import { n__, s__ } from './locale'; @@ -71,7 +71,7 @@ export function fetchCommitMergeRequests() { $container.html($content); }) .catch(() => - createFlash({ + createAlert({ message: s__('Commits|An error occurred while fetching merge requests data.'), }), ); diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index af049738016..e95424eef4d 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Api from '~/api'; import { __ } from '~/locale'; import state from '../state'; @@ -80,7 +80,7 @@ export default { this.selectProject(this.projects[0]); }) .catch((e) => { - createFlash({ + createAlert({ message: __('Error fetching forked projects. Please try again.'), }); throw e; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 659c447e861..22381377389 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -3,7 +3,7 @@ import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/flash'; import { createContentEditor } from '../services/create_content_editor'; -import { ALERT_EVENT } from '../constants'; +import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; @@ -51,6 +51,12 @@ export default { required: false, default: '', }, + autofocus: { + type: [String, Boolean], + required: false, + default: false, + validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus), + }, }, data() { return { @@ -67,7 +73,7 @@ export default { }, }, created() { - const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this; + const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this; // This is a non-reactive attribute intentionally since this is a complex object. this.contentEditor = createContentEditor({ @@ -75,6 +81,9 @@ export default { uploadsPath, extensions, serializerConfig, + tiptapOptions: { + autofocus, + }, }); }, mounted() { @@ -141,7 +150,12 @@ export default { <template> <content-editor-provider :content-editor="contentEditor"> <div> - <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> + <editor-state-observer + @docUpdate="notifyChange" + @focus="focus" + @blur="blur" + @keydown="$emit('keydown', $event)" + /> <content-editor-alert /> <div data-testid="content-editor" 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 41c3771bf41..ccb46e3b593 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -1,6 +1,6 @@ <script> import { debounce } from 'lodash'; -import { ALERT_EVENT } from '../constants'; +import { ALERT_EVENT, KEYDOWN_EVENT } from '../constants'; export const tiptapToComponentMap = { update: 'docUpdate', @@ -10,7 +10,7 @@ export const tiptapToComponentMap = { blur: 'blur', }; -export const eventHubEvents = [ALERT_EVENT]; +export const eventHubEvents = [ALERT_EVENT, KEYDOWN_EVENT]; const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue new file mode 100644 index 00000000000..987b7044272 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -0,0 +1,264 @@ +<script> +import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui'; + +export default { + components: { + GlDropdownItem, + GlAvatarLabeled, + }, + + props: { + char: { + type: String, + required: true, + }, + + nodeType: { + type: String, + required: true, + }, + + nodeProps: { + type: Object, + required: true, + }, + + items: { + type: Array, + required: true, + }, + + command: { + type: Function, + required: true, + }, + }, + + data() { + return { + selectedIndex: 0, + }; + }, + + computed: { + isReference() { + return this.nodeType.startsWith('reference'); + }, + + isCommand() { + return this.isReference && this.nodeProps.referenceType === 'command'; + }, + + isUser() { + return this.isReference && this.nodeProps.referenceType === 'user'; + }, + + isIssue() { + return this.isReference && this.nodeProps.referenceType === 'issue'; + }, + + isLabel() { + return this.isReference && this.nodeProps.referenceType === 'label'; + }, + + isEpic() { + return this.isReference && this.nodeProps.referenceType === 'epic'; + }, + + isSnippet() { + return this.isReference && this.nodeProps.referenceType === 'snippet'; + }, + + isVulnerability() { + return this.isReference && this.nodeProps.referenceType === 'vulnerability'; + }, + + isMergeRequest() { + return this.isReference && this.nodeProps.referenceType === 'merge_request'; + }, + + isMilestone() { + return this.isReference && this.nodeProps.referenceType === 'milestone'; + }, + + isEmoji() { + return this.nodeType === 'emoji'; + }, + }, + + watch: { + items() { + this.selectedIndex = 0; + }, + }, + + methods: { + getText(item) { + if (this.isEmoji) return item.e; + + switch (this.isReference && this.nodeProps.referenceType) { + case 'user': + return `${this.char}${item.username}`; + case 'issue': + case 'merge_request': + return `${this.char}${item.iid}`; + case 'snippet': + return `${this.char}${item.id}`; + case 'milestone': + return `${this.char}${item.title}`; + case 'label': + return item.title; + case 'command': + return `${this.char}${item.name}`; + case 'epic': + return item.reference; + case 'vulnerability': + return `[vulnerability:${item.id}]`; + default: + return ''; + } + }, + + getProps(item) { + const props = {}; + + if (this.isEmoji) { + Object.assign(props, { + name: item.name, + unicodeVersion: item.u, + title: item.d, + moji: item.e, + }); + } + + if (this.isLabel || this.isMilestone) { + Object.assign(props, { + originalText: `${this.char}${ + /\W/.test(item.title) ? JSON.stringify(item.title) : item.title + }`, + }); + } + + if (this.isLabel) { + Object.assign(props, { + text: item.title, + color: item.color, + }); + } + + Object.assign(props, this.nodeProps); + + return props; + }, + + onKeyDown({ event }) { + if (event.key === 'ArrowUp') { + this.upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + this.downHandler(); + return true; + } + + if (event.key === 'Enter') { + this.enterHandler(); + return true; + } + + return false; + }, + + upHandler() { + this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; + }, + + downHandler() { + this.selectedIndex = (this.selectedIndex + 1) % this.items.length; + }, + + enterHandler() { + this.selectItem(this.selectedIndex); + }, + + selectItem(index) { + const item = this.items[index]; + + if (item) { + this.command({ + text: this.getText(item), + ...this.getProps(item), + }); + } + }, + + avatarSubLabel(item) { + return item.count ? `${item.name} (${item.count})` : item.name; + }, + }, +}; +</script> + +<template> + <ul + :class="{ show: items.length > 0 }" + class="gl-new-dropdown dropdown-menu gl-relative" + data-testid="content-editor-suggestions-dropdown" + > + <div class="gl-new-dropdown-inner gl-overflow-y-auto"> + <gl-dropdown-item + v-for="(item, index) in items" + :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> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/label.vue new file mode 100644 index 00000000000..4206c866032 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/label.vue @@ -0,0 +1,34 @@ +<script> +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { GlLabel } from '@gitlab/ui'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + name: 'DetailsWrapper', + components: { + NodeViewWrapper, + GlLabel, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + computed: { + isScopedLabel() { + return isScopedLabel({ title: this.node.attrs.originalText }); + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <gl-label + size="sm" + :scoped="isScopedLabel" + :background-color="node.attrs.color" + :title="node.attrs.text" + /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index 564cca23afa..14862727811 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -42,10 +42,8 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [ }, ]; -export const LOADING_CONTENT_EVENT = 'loading'; -export const LOADING_SUCCESS_EVENT = 'loadingSuccess'; -export const LOADING_ERROR_EVENT = 'loadingError'; export const ALERT_EVENT = 'alert'; +export const KEYDOWN_EVENT = 'keydown'; export const PARSE_HTML_PRIORITY_LOWEST = 1; export const PARSE_HTML_PRIORITY_DEFAULT = 50; @@ -66,3 +64,5 @@ export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv']; export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav']; export const DIAGRAM_LANGUAGES = ['plantuml', 'mermaid']; + +export const TIPTAP_AUTOFOCUS_OPTIONS = [true, false, 'start', 'end', 'all']; diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index d9983b8c1c5..7c4a56468eb 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -1,5 +1,6 @@ import { lowlight } from 'lowlight/lib/core'; import { textblockTypeInputRule } from '@tiptap/core'; +import { base64DecodeUnicode } from '~/lib/utils/text_utility'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import languageLoader from '../services/code_block_language_loader'; import CodeBlockHighlight from './code_block_highlight'; @@ -45,7 +46,9 @@ export default CodeBlockHighlight.extend({ priority: PARSE_HTML_PRIORITY_HIGHEST, tag: '[data-diagram]', getContent(element, schema) { - const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', '')); + const source = base64DecodeUnicode( + element.dataset.diagramSrc.replace('data:text/plain;base64,', ''), + ); const node = schema.node('paragraph', {}, [schema.text(source)]); return node.content; }, diff --git a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js new file mode 100644 index 00000000000..e940614083e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js @@ -0,0 +1,38 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { KEYDOWN_EVENT } from '../constants'; + +/** + * This extension bubbles up the keydown event, captured by ProseMirror in the + * contenteditale element, to the presentation layer implemented in vue. + * + * The purpose of this mechanism is allowing clients of the + * content editor to attach keyboard shortcuts for behavior outside + * of the Content Editor’s boundaries, i.e. submitting a form to save changes. + */ +export default Extension.create({ + name: 'keyboardShortcut', + addOptions() { + return { + eventHub: null, + }; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('keyboardShortcut'), + props: { + handleKeyDown: (_, event) => { + const { + options: { eventHub }, + } = this; + + eventHub.$emit(KEYDOWN_EVENT, event); + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js index 48303cdeca4..41903162ba5 100644 --- a/app/assets/javascripts/content_editor/extensions/heading.js +++ b/app/assets/javascripts/content_editor/extensions/heading.js @@ -1 +1,15 @@ -export { Heading as default } from '@tiptap/extension-heading'; +import { Heading } from '@tiptap/extension-heading'; +import { textblockTypeInputRule } from '@tiptap/core'; + +export default Heading.extend({ + addInputRules() { + return this.options.levels.map((level) => { + return textblockTypeInputRule({ + // make sure heading regex doesn't conflict with issue references + find: new RegExp(`^(#{1,${level}})[ \t]$`), + type: this.type, + getAttributes: { level }, + }); + }); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5e459e65de2..707beaf1231 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -46,22 +46,10 @@ export default Node.create({ tag: 'a.gfm:not([data-link=true])', priority: PARSE_HTML_PRIORITY_HIGHEST, }, - { - tag: 'span.gl-label', - }, ]; }, renderHTML({ node }) { - return [ - 'a', - { - class: node.attrs.className, - href: node.attrs.href, - 'data-reference-type': node.attrs.referenceType, - 'data-original': node.attrs.originalText, - }, - node.attrs.text, - ]; + return ['a', { href: '#' }, node.attrs.text]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js new file mode 100644 index 00000000000..716e191c3d5 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -0,0 +1,35 @@ +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import LabelWrapper from '../components/wrappers/label.vue'; +import Reference from './reference'; + +export default Reference.extend({ + name: 'reference_label', + + addAttributes() { + return { + ...this.parent(), + text: { + default: null, + parseHTML: (element) => { + const text = element.querySelector('.gl-label-text').textContent; + const scopedText = element.querySelector('.gl-label-text-scoped')?.textContent; + if (!scopedText) return text; + return `${text}${SCOPED_LABEL_DELIMITER}${scopedText}`; + }, + }, + color: { + default: null, + parseHTML: (element) => element.querySelector('.gl-label-text').style.backgroundColor, + }, + }; + }, + + parseHTML() { + return [{ tag: 'span.gl-label' }]; + }, + + addNodeView() { + return new VueNodeViewRenderer(LabelWrapper); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js new file mode 100644 index 00000000000..8976b9cafee --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -0,0 +1,227 @@ +import { Node } from '@tiptap/core'; +import { VueRenderer } from '@tiptap/vue-2'; +import tippy from 'tippy.js'; +import Suggestion from '@tiptap/suggestion'; +import { PluginKey } from 'prosemirror-state'; +import { isFunction, uniqueId, memoize } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, getAllEmoji } from '~/emoji'; +import SuggestionsDropdown from '../components/suggestions_dropdown.vue'; + +function find(haystack, needle) { + return String(haystack).toLocaleLowerCase().includes(String(needle).toLocaleLowerCase()); +} + +function createSuggestionPlugin({ + editor, + char, + dataSource, + search, + limit = Infinity, + nodeType, + nodeProps = {}, +}) { + const fetchData = memoize( + isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data, + ); + + return Suggestion({ + editor, + char, + pluginKey: new PluginKey(uniqueId('suggestions')), + + command: ({ editor: tiptapEditor, range, props }) => { + tiptapEditor + .chain() + .focus() + .insertContentAt(range, [ + { type: nodeType, attrs: props }, + { type: 'text', text: ' ' }, + ]) + .run(); + }, + + async items({ query }) { + if (!dataSource) return []; + + try { + const items = await fetchData(); + + return items.filter(search(query)).slice(0, limit); + } catch { + return []; + } + }, + + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new VueRenderer(SuggestionsDropdown, { + propsData: { + ...props, + char, + nodeType, + nodeProps, + }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component?.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0].hide(); + + return true; + } + + return component?.ref?.onKeyDown(props); + }, + + onExit() { + popup?.[0].destroy(); + component?.destroy(); + }, + }; + }, + }); +} + +export default Node.create({ + name: 'suggestions', + + addProseMirrorPlugins() { + return [ + createSuggestionPlugin({ + editor: this.editor, + char: '@', + dataSource: gl.GfmAutoComplete?.dataSources.members, + nodeType: 'reference', + nodeProps: { + referenceType: 'user', + }, + search: (query) => ({ name, username }) => find(name, query) || find(username, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '#', + dataSource: gl.GfmAutoComplete?.dataSources.issues, + nodeType: 'reference', + nodeProps: { + referenceType: 'issue', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '$', + dataSource: gl.GfmAutoComplete?.dataSources.snippets, + nodeType: 'reference', + nodeProps: { + referenceType: 'snippet', + }, + search: (query) => ({ id, title }) => find(id, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '~', + dataSource: gl.GfmAutoComplete?.dataSources.labels, + nodeType: 'reference_label', + nodeProps: { + referenceType: 'label', + }, + search: (query) => ({ title }) => find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '&', + dataSource: gl.GfmAutoComplete?.dataSources.epics, + nodeType: 'reference', + nodeProps: { + referenceType: 'epic', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '[vulnerability:', + dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities, + nodeType: 'reference', + nodeProps: { + referenceType: 'vulnerability', + }, + search: (query) => ({ id, title }) => find(id, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '!', + dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + nodeType: 'reference', + nodeProps: { + referenceType: 'merge_request', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '%', + dataSource: gl.GfmAutoComplete?.dataSources.milestones, + nodeType: 'reference', + nodeProps: { + referenceType: 'milestone', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '/', + dataSource: gl.GfmAutoComplete?.dataSources.commands, + nodeType: 'reference', + nodeProps: { + referenceType: 'command', + }, + search: (query) => ({ name }) => find(name, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: ':', + dataSource: () => Object.values(getAllEmoji()), + nodeType: 'emoji', + search: (query) => ({ d, name }) => find(d, query) || find(name, query), + limit: 10, + }), + ]; + }, + + onCreate() { + initEmojiMap(); + }, +}); 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 5ed7f3dc23d..0d78390e769 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -18,6 +18,7 @@ import Diagram from '../extensions/diagram'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; +import ExternalKeydownHandler from '../extensions/external_keydown_handler'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; import FootnoteDefinition from '../extensions/footnote_definition'; @@ -42,10 +43,12 @@ import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; +import ReferenceLabel from '../extensions/reference_label'; import ReferenceDefinition from '../extensions/reference_definition'; import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; +import Suggestions from '../extensions/suggestions'; import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; @@ -121,6 +124,7 @@ export const createContentEditor = ({ Image, InlineDiff, Italic, + ExternalKeydownHandler.configure({ eventHub }), Link, ListItem, Loading, @@ -129,10 +133,12 @@ export const createContentEditor = ({ Paragraph, PasteMarkdown.configure({ eventHub, renderMarkdown }), Reference, + ReferenceLabel, ReferenceDefinition, Sourcemap, Strike, Subscript, + Suggestions, Superscript, TableCell, TableHeader, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index ba0cad6c91c..c990f6cf0b3 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; +import ReferenceLabel from '../extensions/reference_label'; import ReferenceDefinition from '../extensions/reference_definition'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; @@ -61,6 +62,7 @@ import { renderHTMLNode, renderContent, renderBulletList, + renderReference, preserveUnchanged, bold, italic, @@ -184,9 +186,8 @@ const defaultSerializerConfig = { [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), - [Reference.name]: (state, node) => { - state.write(node.attrs.originalText || node.attrs.text); - }, + [Reference.name]: renderReference, + [ReferenceLabel.name]: renderReference, [ReferenceDefinition.name]: preserveUnchanged({ render: (state, node, parent, index, same, sourceMarkdown) => { const nextSibling = parent.maybeChild(index + 1); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 41114571df7..5c0cb21075a 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -280,6 +280,7 @@ export function renderTableRow(state, node) { } export function renderTable(state, node) { + state.flushClose(); setIsInBlockTable(node, shouldRenderHTMLTable(node)); if (isInBlockTable(node)) renderTagOpen(state, 'table'); @@ -422,6 +423,10 @@ export function renderOrderedList(state, node) { }); } +export function renderReference(state, node) { + state.write(node.attrs.originalText || node.attrs.text); +} + const generateBoldTags = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 512f060e2ea..4e4c21328ca 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -196,7 +196,7 @@ export default { <template> <div> - <div v-if="loading" class="contributors-loader text-center"> + <div v-if="loading" class="gl-text-center gl-pt-13"> <gl-loading-icon :inline="true" size="xl" /> </div> diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js index 4cc0a6a6509..3a6f4191031 100644 --- a/app/assets/javascripts/contributors/stores/actions.js +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import service from '../services/contributors_service'; import * as types from './mutation_types'; @@ -14,7 +14,7 @@ export const fetchChartData = ({ commit }, endpoint) => { commit(types.SET_LOADING_STATE, false); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while loading chart data'), }), ); diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue index 562363ff88e..2be17d1f80f 100644 --- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue +++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue @@ -86,7 +86,7 @@ export default { }, methods: { errorAlertDismissed() { - this.error = true; + this.error = false; }, extractContacts(data) { const contacts = data?.group?.contacts?.nodes || []; @@ -146,7 +146,7 @@ export default { editButtonLabel: __('Edit'), title: s__('Crm|Customer relations contacts'), newContact: s__('Crm|New contact'), - errorText: __('Something went wrong. Please try again.'), + errorMsg: __('Something went wrong. Please try again.'), }, EDIT_ROUTE_NAME, NEW_ROUTE_NAME, @@ -176,7 +176,7 @@ export default { <div> <paginated-table-with-search-and-tabs :show-items="true" - :show-error-msg="false" + :show-error-msg="error" :i18n="$options.i18n" :items="contacts.list" :page-info="contacts.pageInfo" @@ -243,10 +243,7 @@ export default { </template> <template #empty> - <span v-if="error"> - {{ $options.i18n.errorText }} - </span> - <span v-else> + <span> {{ $options.i18n.emptyText }} </span> </template> diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue index 155c8f00537..28f0b34f031 100644 --- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue +++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue @@ -137,7 +137,7 @@ export default { editButtonLabel: __('Edit'), title: s__('Crm|Customer relations organizations'), newOrganization: s__('Crm|New organization'), - errorText: __('Something went wrong. Please try again.'), + errorMsg: __('Something went wrong. Please try again.'), }, EDIT_ROUTE_NAME, NEW_ROUTE_NAME, @@ -167,7 +167,7 @@ export default { <div> <paginated-table-with-search-and-tabs :show-items="true" - :show-error-msg="false" + :show-error-msg="error" :i18n="$options.i18n" :items="organizations.list" :page-info="organizations.pageInfo" @@ -238,10 +238,7 @@ export default { </template> <template #empty> - <span v-if="error"> - {{ $options.i18n.errorText }} - </span> - <span v-else> + <span> {{ $options.i18n.emptyText }} </span> </template> diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index 5c2e29bfa74..4a201e00582 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -6,7 +6,7 @@ import { getValueStreamStageCounts, } from '~/api/analytics_api'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants'; import * as types from './mutation_types'; @@ -97,7 +97,7 @@ export const fetchStageMedians = ({ .then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data)) .catch((error) => { commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error); - createFlash({ message: I18N_VSA_ERROR_STAGE_MEDIAN }); + createAlert({ message: I18N_VSA_ERROR_STAGE_MEDIAN }); }); }; @@ -126,7 +126,7 @@ export const fetchStageCountValues = ({ .then((data) => commit(types.RECEIVE_STAGE_COUNTS_SUCCESS, data)) .catch((error) => { commit(types.RECEIVE_STAGE_COUNTS_ERROR, error); - createFlash({ + createAlert({ message: __('There was an error fetching stage total counts'), }); }); diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index 814a4b672a2..c67b544eacd 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -3,7 +3,7 @@ import { GlFormGroup, GlFormInput, GlModal, GlSprintf, GlLink } from '@gitlab/ui import { isValidCron } from 'cron-validator'; import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; -import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import { mapComputed } from '~/vuex_shared/bindings'; export default { diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js index 1ac6781a0e3..76a4eaaff3f 100644 --- a/app/assets/javascripts/deploy_freeze/store/actions.js +++ b/app/assets/javascripts/deploy_freeze/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -27,7 +27,7 @@ const receiveFreezePeriod = (store, request) => { dispatch('fetchFreezePeriods'); }) .catch((error) => { - createFlash({ + createAlert({ message: __('Error: Unable to create deploy freeze'), }); dispatch('receiveFreezePeriodError', error); @@ -59,7 +59,7 @@ export const deleteFreezePeriod = ({ state, commit }, { id }) => { return Api.deleteFreezePeriod(state.projectId, id) .then(() => commit(types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS, id)) .catch((e) => { - createFlash({ + createAlert({ message: __('Error: Unable to delete deploy freeze'), }); commit(types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR, id); @@ -76,7 +76,7 @@ export const fetchFreezePeriods = ({ commit, state }) => { commit(types.RECEIVE_FREEZE_PERIODS_SUCCESS, data); }) .catch(() => { - createFlash({ + createAlert({ message: __('There was an error fetching the deploy freezes.'), }); }); diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js index 151f7f39f5a..edb455a8dd5 100644 --- a/app/assets/javascripts/deploy_freeze/store/mutations.js +++ b/app/assets/javascripts/deploy_freeze/store/mutations.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { secondsToHours } from '~/lib/utils/datetime_utility'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; import * as types from './mutation_types'; const formatTimezoneName = (freezePeriod, timezoneList) => { @@ -8,7 +8,7 @@ const formatTimezoneName = (freezePeriod, timezoneList) => { return convertObjectPropsToCamelCase({ ...freezePeriod, cron_timezone: { - formattedTimezone: tz && `[UTC ${secondsToHours(tz.offset)}] ${tz.name}`, + formattedTimezone: tz && formatTimezone(tz), identifier: freezePeriod.cron_timezone, }, }); diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 36d54f586f1..db5e9a954cf 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import eventHub from '../eventhub'; @@ -93,7 +93,7 @@ export default { .catch(() => { this.isLoading = false; this.store.keys = {}; - return createFlash({ + return createAlert({ message: s__('DeployKeys|Error getting deploy keys'), }); }); @@ -103,7 +103,7 @@ export default { .enableKey(deployKey.id) .then(this.fetchKeys) .catch(() => - createFlash({ + createAlert({ message: s__('DeployKeys|Error enabling deploy key'), }), ); @@ -119,7 +119,7 @@ export default { .then(this.fetchKeys) .then(hideModal) .catch(() => - createFlash({ + createAlert({ message: s__('DeployKeys|Error removing deploy key'), }), ); diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue new file mode 100644 index 00000000000..639dd21bd7b --- /dev/null +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -0,0 +1,332 @@ +<script> +import { + GlFormGroup, + GlFormInput, + GlFormCheckbox, + GlButton, + GlDatepicker, + GlFormInputGroup, + GlSprintf, + GlLink, +} from '@gitlab/ui'; +import { createAlert, VARIANT_INFO } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { s__ } from '~/locale'; + +function defaultData() { + return { + expiresAt: null, + name: '', + newTokenDetails: null, + readRepository: false, + writeRepository: false, + readRegistry: false, + writeRegistry: false, + readPackageRegistry: false, + writePackageRegistry: false, + username: '', + placeholders: { + link: { link: ['link_start', 'link_end'] }, + i: { i: ['i_start', 'i_end'] }, + code: { code: ['code_start', 'code_end'] }, + }, + }; +} + +export default { + components: { + GlFormGroup, + GlFormInput, + GlDatepicker, + GlFormCheckbox, + GlButton, + GlFormInputGroup, + ClipboardButton, + GlSprintf, + GlLink, + }, + + props: { + createNewTokenPath: { + type: String, + required: true, + }, + deployTokensHelpUrl: { + type: String, + required: true, + }, + containerRegistryEnabled: { + type: Boolean, + required: true, + }, + packagesRegistryEnabled: { + type: Boolean, + required: true, + }, + tokenType: { + type: String, + required: true, + }, + }, + + data() { + return defaultData(); + }, + translations: { + addTokenButton: s__('DeployTokens|Create deploy token'), + addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'), + addTokenExpiryDescription: s__( + 'DeployTokens|Enter an expiration date for your token. Defaults to never expire.', + ), + addTokenHeader: s__('DeployTokens|New deploy token'), + addTokenDescription: s__( + 'DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}', + ), + addTokenNameLabel: s__('DeployTokens|Name'), + addTokenNameDescription: s__('DeployTokens|Enter a unique name for your deploy token.'), + addTokenScopesLabel: s__('DeployTokens|Scopes (select at least one)'), + addTokenUsernameDescription: s__( + 'DeployTokens|Enter a username for your token. Defaults to %{code_start}gitlab+deploy-token-{n}%{code_end}.', + ), + addTokenUsernameLabel: s__('DeployTokens|Username (optional)'), + newTokenCopyMessage: s__('DeployTokens|Copy deploy token'), + newProjectTokenCreated: s__('DeployTokens|Your new project deploy token has been created.'), + newGroupTokenCreated: s__('DeployTokens|Your new group deploy token has been created.'), + newTokenDescription: s__( + 'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.', + ), + newTokenMessage: s__('DeployTokens|Your New Deploy Token'), + newTokenUsernameCopy: s__('DeployTokens|Copy username'), + newTokenUsernameDescription: s__( + 'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}', + ), + readRepositoryHelp: s__('DeployTokens|Allows read-only access to the repository.'), + readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'), + writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'), + readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'), + writePackageRegistryHelp: s__( + 'DeployTokens|Allows read and write access to the package registry.', + ), + }, + computed: { + formattedExpiryDate() { + return formatDate(this.expiresAt, 'yyyy-mm-dd'); + }, + newTokenCreatedMessage() { + return this.tokenType === 'group' + ? this.$options.translations.newGroupTokenCreated + : this.$options.translations.newProjectTokenCreated; + }, + }, + methods: { + createDeployToken() { + return axios + .post(this.createNewTokenPath, { + deploy_token: { + expires_at: this.expiresAt, + name: this.name, + read_repository: this.readRepository, + read_registry: this.readRegistry, + username: this.username, + }, + }) + .then((response) => { + this.newTokenDetails = response.data; + this.resetData(); + createAlert({ + variant: VARIANT_INFO, + message: this.newTokenCreatedMessage, + }); + }) + .catch((error) => { + createAlert({ + message: error.response.data.message, + }); + }); + }, + resetData() { + const newData = defaultData(); + delete newData.newTokenDetails; + Object.keys(newData).forEach((k) => { + this[k] = newData[k]; + }); + }, + }, +}; +</script> +<template> + <div> + <div v-if="newTokenDetails" class="created-deploy-token-container info-well"> + <div class="well-segment"> + <h5>{{ $options.translations.newTokenMessage }}</h5> + <gl-form-group> + <template #description> + <div class="deploy-token-help-block gl-mt-2 text-success"> + <gl-sprintf + :message="$options.translations.newTokenUsernameDescription" + :placeholders="placeholders.link" + > + <template #link="{ content }"> + <gl-link :href="deployTokensHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + <gl-form-input-group + name="deploy-token-user" + :value="newTokenDetails.username" + select-on-click + readonly + > + <template #append> + <clipboard-button + :text="newTokenDetails.username" + :title="$options.translations.newTokenUsernameCopy" + /> + </template> + </gl-form-input-group> + </gl-form-group> + <gl-form-group> + <template #description> + <div class="deploy-token-help-block gl-mt-2 text-danger"> + <gl-sprintf + :message="$options.translations.newTokenDescription" + :placeholders="placeholders.i" + > + <template #i="{ content }"> + <i>{{ content }}</i> + </template> + </gl-sprintf> + </div> + </template> + <gl-form-input-group :value="newTokenDetails.token" name="deploy-token" readonly> + <template #append> + <clipboard-button + :text="newTokenDetails.token" + :title="$options.translations.newTokenCopyMessage" + /> + </template> + </gl-form-input-group> + </gl-form-group> + </div> + </div> + <h5>{{ $options.translations.addTokenHeader }}</h5> + <p class="profile-settings-content"> + <gl-sprintf + :message="$options.translations.addTokenDescription" + :placeholders="placeholders.link" + > + <template #link="{ content }"> + <gl-link :href="deployTokensHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <gl-form-group + :label="$options.translations.addTokenNameLabel" + :description="$options.translations.addTokenNameDescription" + label-for="deploy_token_name" + > + <gl-form-input + id="deploy_token_name" + v-model="name" + name="deploy_token_name" + class="qa-deploy-token-name" + data-qa-selector="deploy_token_name_field" + /> + </gl-form-group> + <gl-form-group + :label="$options.translations.addTokenExpiryLabel" + :description="$options.translations.addTokenExpiryDescription" + label-for="deploy_token_expires_at" + > + <gl-form-input + id="deploy_token_expires_at" + name="deploy_token_expires_at" + :value="formattedExpiryDate" + data-qa-selector="deploy_token_expires_at_field" + /> + </gl-form-group> + <gl-form-group + :label="$options.translations.addTokenUsernameLabel" + label-for="deploy_token_username" + > + <template #description> + <gl-sprintf + :message="$options.translations.addTokenUsernameDescription" + :placeholders="placeholders.code" + > + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </template> + <gl-form-input id="deploy_token_username" v-model="username" /> + </gl-form-group> + <gl-form-group + :label="$options.translations.addTokenScopesLabel" + label-for="deploy-token-scopes" + > + <div id="deploy-token-scopes"> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <gl-form-checkbox + id="deploy_token_read_repository" + v-model="readRepository" + name="deploy_token_read_repository" + data-qa-selector="deploy_token_read_repository_checkbox" + > + read_repository + <template #help>{{ $options.translations.readRepositoryHelp }}</template> + </gl-form-checkbox> + <gl-form-checkbox + v-if="containerRegistryEnabled" + id="deploy_token_read_registry" + v-model="readRegistry" + name="deploy_token_read_registry" + data-qa-selector="deploy_token_read_registry_checkbox" + > + read_registry + <template #help>{{ $options.translations.readRegistryHelp }}</template> + </gl-form-checkbox> + <gl-form-checkbox + v-if="containerRegistryEnabled" + id="deploy_token_write_registry" + v-model="writeRegistry" + name="deploy_token_write_registry" + data-qa-selector="deploy_token_write_registry_checkbox" + > + write_registry + <template #help>{{ $options.translations.writeRegistryHelp }}</template> + </gl-form-checkbox> + <gl-form-checkbox + v-if="packagesRegistryEnabled" + id="deploy_token_read_package_registry" + v-model="readPackageRegistry" + name="deploy_token_read_package_registry" + data-qa-selector="deploy_token_read_package_registry_checkbox" + > + read_package_registry + <template #help>{{ $options.translations.readPackageRegistryHelp }}</template> + </gl-form-checkbox> + <gl-form-checkbox + v-if="packagesRegistryEnabled" + id="deploy_token_write_package_registry" + v-model="writePackageRegistry" + name="deploy_token_write_package_registry" + data-qa-selector="deploy_token_write_package_registry_checkbox" + > + write_package_registry + <template #help>{{ $options.translations.writePackageRegistryHelp }}</template> + </gl-form-checkbox> + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> + </div> + </gl-form-group> + <div> + <gl-button variant="success" @click="createDeployToken"> + {{ $options.translations.addTokenButton }} + </gl-button> + </div> + <gl-datepicker v-model="expiresAt" target="#deploy_token_expires_at" container="body" /> + </div> +</template> diff --git a/app/assets/javascripts/deploy_tokens/index.js b/app/assets/javascripts/deploy_tokens/index.js new file mode 100644 index 00000000000..334c9930f4b --- /dev/null +++ b/app/assets/javascripts/deploy_tokens/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import NewDeployToken from './components/new_deploy_token.vue'; + +export default function initDeployTokens() { + const el = document.getElementById('js-new-deploy-token'); + + if (el == null) return null; + + const { + createNewTokenPath, + deployTokensHelpUrl, + containerRegistryEnabled, + packagesRegistryEnabled, + tokenType, + } = el.dataset; + return new Vue({ + el, + components: { + NewDeployToken, + }, + render(createElement) { + return createElement(NewDeployToken, { + props: { + createNewTokenPath, + deployTokensHelpUrl, + containerRegistryEnabled: containerRegistryEnabled !== undefined, + packagesRegistryEnabled: packagesRegistryEnabled !== undefined, + tokenType, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 124780df8a5..a4430b15752 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; @@ -155,7 +155,7 @@ export default { methods: { onDone({ data: { createNote } }) { if (hasErrors(createNote)) { - createFlash({ message: ADD_DISCUSSION_COMMENT_ERROR }); + createAlert({ message: ADD_DISCUSSION_COMMENT_ERROR }); } this.discussionComment = ''; this.hideForm(); diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 4faeba3983b..5a6b220e532 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import $ from 'jquery'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; @@ -7,13 +7,23 @@ import Autosave from '~/autosave'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; export default { name: 'DesignReplyForm', + i18n: { + primaryBtn: s__('DesignManagement|Discard changes'), + cancelBtnCreate: s__('DesignManagement|Continue creating'), + cancelBtnUpdate: s__('DesignManagement|Continue editing'), + cancelCreate: s__('DesignManagement|Are you sure you want to cancel creating this comment?'), + cancelUpdate: s__('DesignManagement|Are you sure you want to cancel editing this comment?'), + newCommentButton: s__('DesignManagement|Comment'), + updateCommentButton: s__('DesignManagement|Save comment'), + }, + markdownDocsPath: helpPagePath('user/markdown'), components: { MarkdownField, GlButton, - GlModal, }, props: { markdownPreviewPath: { @@ -54,29 +64,10 @@ export default { hasValue() { return this.value.trim().length > 0; }, - modalSettings() { - if (this.isNewComment) { - return { - title: s__('DesignManagement|Cancel comment confirmation'), - okTitle: s__('DesignManagement|Discard comment'), - cancelTitle: s__('DesignManagement|Keep comment'), - content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'), - }; - } - return { - title: s__('DesignManagement|Cancel comment update confirmation'), - okTitle: s__('DesignManagement|Cancel changes'), - cancelTitle: s__('DesignManagement|Keep changes'), - content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'), - }; - }, buttonText() { return this.isNewComment - ? s__('DesignManagement|Comment') - : s__('DesignManagement|Save comment'); - }, - markdownDocsPath() { - return helpPagePath('user/markdown'); + ? this.$options.i18n.newCommentButton + : this.$options.i18n.updateCommentButton; }, shortDiscussionId() { return isGid(this.discussionId) ? getIdFromGraphQLId(this.discussionId) : this.discussionId; @@ -94,12 +85,30 @@ export default { }, cancelComment() { if (this.hasValue && this.formText !== this.value) { - this.$refs.cancelCommentModal.show(); + this.confirmCancelCommentModal(); } else { this.$emit('cancel-form'); } }, - confirmCancelCommentModal() { + async confirmCancelCommentModal() { + const msg = this.isNewComment + ? this.$options.i18n.cancelCreate + : this.$options.i18n.cancelUpdate; + + const cancelBtn = this.isNewComment + ? this.$options.i18n.cancelBtnCreate + : this.$options.i18n.cancelBtnUpdate; + + const confirmed = await confirmAction(msg, { + primaryBtnText: this.$options.i18n.primaryBtn, + cancelBtnText: cancelBtn, + primaryBtnVariant: 'danger', + }); + + if (!confirmed) { + return; + } + this.$emit('cancel-form'); this.autosaveDiscussion.reset(); }, @@ -126,7 +135,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :enable-autocomplete="true" :textarea-value="value" - :markdown-docs-path="markdownDocsPath" + :markdown-docs-path="$options.markdownDocsPath" class="bordered-box" > <template #textarea> @@ -171,15 +180,5 @@ export default { >{{ __('Cancel') }}</gl-button > </div> - <gl-modal - ref="cancelCommentModal" - ok-variant="danger" - :title="modalSettings.title" - :ok-title="modalSettings.okTitle" - :cancel-title="modalSettings.cancelTitle" - modal-id="cancel-comment-modal" - @ok="confirmCancelCommentModal" - >{{ modalSettings.content }} - </gl-modal> </form> </template> diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index e92f8006a0d..b783ec43cd1 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -1,6 +1,6 @@ import { propertyOf } from 'lodash'; import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_WARNING } from '~/flash'; import { s__ } from '~/locale'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; import allVersionsMixin from './all_versions'; @@ -36,7 +36,7 @@ export default { }, result() { if (this.$route.query.version && !this.hasValidVersion) { - createFlash({ + createAlert({ message: s__( 'DesignManagement|Requested design version does not exist. Showing latest version instead', ), @@ -44,11 +44,11 @@ export default { this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); } if (this.designCollection.copyState === 'ERROR') { - createFlash({ + createAlert({ message: s__( 'DesignManagement|There was an error moving your designs. Please upload your designs below.', ), - type: FLASH_TYPES.WARNING, + variant: VARIANT_WARNING, }); } }, diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 228ad637b9e..d4c177e2e5f 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -4,7 +4,7 @@ import { isNull } from 'lodash'; import Mousetrap from 'mousetrap'; import { ApolloMutation } from 'vue-apollo'; import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -250,7 +250,7 @@ export default { onQueryError(message) { // because we redirect user to /designs (the issue page), // we want to create these flashes on the issue page - createFlash({ message }); + createAlert({ message }); this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); }, onError(message, e) { diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 07f7a19f7d4..fba73cd4bec 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -133,9 +133,13 @@ export default { return this.designCollection && this.designCollection.copyState === 'IN_PROGRESS'; }, designDropzoneWrapperClass() { - return this.isDesignListEmpty - ? 'col-12' - : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mt-5'; + if (!this.isDesignListEmpty) { + return 'gl-flex-direction-column col-md-6 col-lg-3 gl-mt-5'; + } + if (this.showToolbar) { + return 'col-12 gl-mt-5'; + } + return 'col-12'; }, }, mounted() { diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index c8f445bfb88..cfec5828c85 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -2,7 +2,7 @@ import produce from 'immer'; import { differenceBy } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, @@ -234,7 +234,7 @@ export const deletePendingTodoFromStore = (store, todoMarkDone, query, queryVari }; const onError = (data, message) => { - createFlash({ message }); + createAlert({ message }); throw new Error(data.errors); }; @@ -283,7 +283,7 @@ export const updateStoreAfterUploadDesign = (store, data, query) => { export const updateDesignsOnStoreAfterReorder = (store, data, query) => { if (hasErrors(data)) { - createFlash({ message: data.errors[0] }); + createAlert({ message: data.errors[0] }); } else { moveDesignInStore(store, data, query); } diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 833fbb8789e..23eb470503e 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { merge } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import FilesCommentButton from './files_comment_button'; @@ -82,7 +82,7 @@ export default class Diff { .get(link, { params }) .then(({ data }) => $target.parent().replaceWith(data)) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while loading diff'), }), ); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index f5c0776ca35..bc49464a560 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -11,7 +11,7 @@ import { MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT, } from '~/behaviors/shortcuts/keybindings'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; import { helpPagePath } from '~/helpers/help_page_helper'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -471,8 +471,8 @@ export default { }, fetchData(toggleTree = true) { this.fetchDiffFilesMeta() - .then(({ real_size }) => { - this.diffFilesLength = parseInt(real_size, 10); + .then(({ real_size = 0 }) => { + this.diffFilesLength = parseInt(real_size, 10) || 0; if (toggleTree) { this.setTreeDisplay(); } @@ -480,7 +480,7 @@ export default { this.updateChangesTabCount(); }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong on our end. Please try again!'), }); }); @@ -495,7 +495,7 @@ export default { this.setDiscussions(); }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong on our end. Please try again!'), }); }); diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 0e5acd0928b..5a45797ed98 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -128,7 +128,7 @@ export default { :img-src="authorAvatar" :img-alt="authorName" :img-size="32" - class="avatar-cell d-none d-sm-block" + class="avatar-cell d-none d-sm-block gl-my-2 gl-mr-4" /> </div> <div @@ -172,7 +172,7 @@ export default { v-if="commit.description_html" v-safe-html:[$options.safeHtmlConfig]="commitDescription" :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }" - class="commit-row-description gl-mb-3 gl-text-body" + class="commit-row-description gl-mb-3 gl-text-body gl-white-space-pre-line" ></pre> </div> </li> diff --git a/app/assets/javascripts/diffs/components/commit_widget.vue b/app/assets/javascripts/diffs/components/commit_widget.vue index b1a2b2a72ea..facfc553053 100644 --- a/app/assets/javascripts/diffs/components/commit_widget.vue +++ b/app/assets/javascripts/diffs/components/commit_widget.vue @@ -22,7 +22,7 @@ export default { <template> <div class="info-well mw-100 mx-0"> <div class="well-segment"> - <ul class="blob-commit-info"> + <ul class="gl-list-style-none gl-m-0 gl-p-0"> <commit-item :commit="commit" :collapsible="collapsible" /> </ul> </div> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 8a5325cf218..6104a304fbd 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -80,7 +80,7 @@ export default { <template> <div class="mr-version-controls"> - <div class="mr-version-menus-container content-block"> + <div class="mr-version-menus-container gl-px-5 gl-pt-3 gl-pb-2"> <gl-button v-if="hasChanges" v-gl-tooltip.hover diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 3082ba0f16f..b2098b9e82d 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__, sprintf } from '~/locale'; import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants'; import * as utils from '../store/utils'; @@ -92,7 +92,7 @@ export default { ) { this.loadMoreLines({ endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers }) .catch(() => { - createFlash({ + createAlert({ message: s__('Diffs|Something went wrong while fetching diff lines.'), }); }) @@ -224,6 +224,7 @@ export default { <button v-if="showExpandDown" :title="s__('Diffs|Next 20 lines')" + :aria-label="s__('Diffs|Next 20 lines')" :disabled="loading.down" type="button" class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button" @@ -235,6 +236,7 @@ export default { <button v-if="lineCountBetween !== -1 && lineCountBetween < 20" :title="s__('Diffs|Expand all lines')" + :aria-label="s__('Diffs|Expand all lines')" :disabled="loading.all" type="button" class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button" @@ -246,6 +248,7 @@ export default { <button v-if="showExpandUp" :title="s__('Diffs|Previous 20 lines')" + :aria-label="s__('Diffs|Previous 20 lines')" :disabled="loading.up" type="button" class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index aec608007d5..422bf52a1fa 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -10,7 +10,7 @@ import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; @@ -309,7 +309,7 @@ export default { }) .catch(() => { idState.isLoadingCollapsedDiff = false; - createFlash({ + createAlert({ message: this.$options.i18n.genericError, }); }); diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 705b43a222d..91c3df39e32 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -281,7 +281,7 @@ export default { 'gl-z-dropdown-menu!': idState.moreActionsShown, 'is-sidebar-moved': glFeatures.movedMrSidebar, }" - class="js-file-title file-title file-title-flex-parent" + class="js-file-title file-title file-title-flex-parent gl-border" data-qa-selector="file_title_container" :data-qa-file-name="filePath" @click.self="handleToggleFile" diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index f610ac979ca..7732badde34 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -108,7 +108,7 @@ export const mapParallel = (content) => (line) => { ...left, renderDiscussion: hasExpandedDiscussionOnLeft, hasDraft: content.hasParallelDraftLeft(content.diffFile.file_hash, line), - lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'left'), + lineDrafts: content.draftsForLine(content.diffFile.file_hash, line, 'left'), hasCommentForm: left.hasForm, isConflictMarker: line.left.type === CONFLICT_MARKER_OUR || line.left.type === CONFLICT_MARKER_THEIR, @@ -123,7 +123,7 @@ export const mapParallel = (content) => (line) => { hasExpandedDiscussionOnRight && right.type && right.type !== EXPANDED_LINE_TYPE, ), hasDraft: content.hasParallelDraftRight(content.diffFile.file_hash, line), - lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'right'), + lineDrafts: content.draftsForLine(content.diffFile.file_hash, line, 'right'), hasCommentForm: Boolean(right.hasForm && right.type && right.type !== EXPANDED_LINE_TYPE), emptyCellClassMap: { conflict_their: line.left?.type === CONFLICT_OUR }, addCommentTooltip: addCommentTooltip(line.right), @@ -145,7 +145,7 @@ export const mapParallel = (content) => (line) => { lineCode: lineCode(line), isMetaLineLeft: isMetaLine(left?.type), isMetaLineRight: isMetaLine(right?.type), - draftRowClasses: left?.lineDraft > 0 || right?.lineDraft > 0 ? '' : 'js-temp-notes-holder', + draftRowClasses: left?.hasDraft || right?.hasDraft ? '' : 'js-temp-notes-holder', renderCommentRow, commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder', }; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 91bf3283379..5ea118afe78 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -177,6 +177,12 @@ export default { getCodeQualityLine(line) { return (line.left ?? line.right)?.codequality?.[0]?.line; }, + lineDrafts(line, side) { + return (line[side]?.lineDrafts || []).filter((entry) => entry.isDraft); + }, + lineHasDrafts(line, side) { + return this.lineDrafts(line, side).length > 0; + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -297,19 +303,19 @@ export default { class="diff-grid-drafts diff-tr notes_holder" > <div - v-if="!inline || (line.left && line.left.lineDraft.isDraft)" + v-if="!inline || lineHasDrafts(line, 'left')" class="diff-td notes-content parallel old" > - <div v-if="line.left && line.left.lineDraft.isDraft" class="content"> - <draft-note :draft="line.left.lineDraft" :line="line.left" /> + <div v-for="draft in lineDrafts(line, 'left')" :key="draft.id" class="content"> + <draft-note :draft="draft" :line="line.left" /> </div> </div> <div - v-if="!inline || (line.right && line.right.lineDraft.isDraft)" + v-if="!inline || lineHasDrafts(line, 'right')" class="diff-td notes-content parallel new" > - <div v-if="line.right && line.right.lineDraft.isDraft" class="content"> - <draft-note :draft="line.right.lineDraft" :line="line.right" /> + <div v-for="draft in lineDrafts(line, 'right')" :key="draft.id" class="content"> + <draft-note :draft="draft" :line="line.right" /> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js index 693b4a84694..d41bb160e96 100644 --- a/app/assets/javascripts/diffs/mixins/draft_comments.js +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -5,7 +5,7 @@ export default { ...mapGetters('batchComments', [ 'shouldRenderDraftRow', 'shouldRenderParallelDraftRow', - 'draftForLine', + 'draftsForLine', 'draftsForFile', 'hasParallelDraftLeft', 'hasParallelDraftRight', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 5e74a7206b3..5234be44b05 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -5,7 +5,7 @@ import { historyPushState, scrollToElement, } from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; @@ -202,6 +202,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { const worker = new TreeWorker(); const urlParams = { view: 'inline', + w: state.showWhitespace ? '0' : '1', }; commit(types.SET_LOADING, true); @@ -246,7 +247,7 @@ export const fetchCoverageFiles = ({ commit, state }) => { } }, errorCallback: () => - createFlash({ + createAlert({ message: __('Something went wrong on our end. Please try again!'), }), }); @@ -509,7 +510,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) .catch(() => - createFlash({ + createAlert({ message: s__('MergeRequests|Saving the comment failed'), }), ); @@ -619,7 +620,7 @@ export const cacheTreeListWidth = (_, size) => { export const receiveFullDiffError = ({ commit }, filePath) => { commit(types.RECEIVE_FULL_DIFF_ERROR, filePath); - createFlash({ + createAlert({ message: s__('MergeRequest|Error loading full diff. Please try again.'), }); }; @@ -757,7 +758,7 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => commit(types.SET_SHOW_SUGGEST_POPOVER); }) .catch(() => { - createFlash({ + createAlert({ message: s__('MergeRequest|Error dismissing suggestion popover. Please try again.'), }); }); 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 bc3cb163c39..999e91eed19 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 @@ -1,7 +1,7 @@ import { KeyMod, KeyCode } from 'monaco-editor'; import { debounce } from 'lodash'; import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; import syntaxHighlight from '~/syntax_highlight'; @@ -152,7 +152,7 @@ export class EditorMarkdownPreviewExtension { syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); previewEl.style.display = 'block'; }) - .catch(() => createFlash(BLOB_PREVIEW_ERROR)); + .catch(() => createAlert(BLOB_PREVIEW_ERROR)); } setupPreviewAction(instance) { diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 848ba7dbeef..e56932a9a31 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -1,7 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://gitlab.com/.gitlab-ci.yml", - "title": "Gitlab CI configuration", "markdownDescription": "Gitlab has a built-in solution for doing CI called Gitlab CI. It is configured by supplying a file called `.gitlab-ci.yml`, which will list all the jobs that are going to run for the project. A full list of all options can be found [here](https://docs.gitlab.com/ee/ci/yaml). [Learn More](https://docs.gitlab.com/ee/ci/index.html).", "type": "object", "properties": { @@ -9,34 +8,74 @@ "type": "string", "format": "uri" }, - "image": { "$ref": "#/definitions/image" }, - "services": { "$ref": "#/definitions/services" }, - "before_script": { "$ref": "#/definitions/before_script" }, - "after_script": { "$ref": "#/definitions/after_script" }, - "variables": { "$ref": "#/definitions/globalVariables" }, - "cache": { "$ref": "#/definitions/cache" }, - "!reference": {"$ref" : "#/definitions/!reference"}, + "image": { + "$ref": "#/definitions/image" + }, + "services": { + "$ref": "#/definitions/services" + }, + "before_script": { + "$ref": "#/definitions/before_script" + }, + "after_script": { + "$ref": "#/definitions/after_script" + }, + "variables": { + "$ref": "#/definitions/globalVariables" + }, + "cache": { + "$ref": "#/definitions/cache" + }, + "!reference": { + "$ref": "#/definitions/!reference" + }, "default": { "type": "object", "properties": { - "after_script": { "$ref": "#/definitions/after_script" }, - "artifacts": { "$ref": "#/definitions/artifacts" }, - "before_script": { "$ref": "#/definitions/before_script" }, - "cache": { "$ref": "#/definitions/cache" }, - "image": { "$ref": "#/definitions/image" }, - "interruptible": { "$ref": "#/definitions/interruptible" }, - "retry": { "$ref": "#/definitions/retry" }, - "services": { "$ref": "#/definitions/services" }, - "tags": { "$ref": "#/definitions/tags" }, - "timeout": { "$ref": "#/definitions/timeout" }, - "!reference": {"$ref" : "#/definitions/!reference"} + "after_script": { + "$ref": "#/definitions/after_script" + }, + "artifacts": { + "$ref": "#/definitions/artifacts" + }, + "before_script": { + "$ref": "#/definitions/before_script" + }, + "cache": { + "$ref": "#/definitions/cache" + }, + "image": { + "$ref": "#/definitions/image" + }, + "interruptible": { + "$ref": "#/definitions/interruptible" + }, + "retry": { + "$ref": "#/definitions/retry" + }, + "services": { + "$ref": "#/definitions/services" + }, + "tags": { + "$ref": "#/definitions/tags" + }, + "timeout": { + "$ref": "#/definitions/timeout" + }, + "!reference": { + "$ref": "#/definitions/!reference" + } }, "additionalProperties": false }, "stages": { "type": "array", "markdownDescription": "Groups jobs into stages. All jobs in one stage must complete before next stage is executed. Defaults to ['build', 'test', 'deploy']. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#stages).", - "default": ["build", "test", "deploy"], + "default": [ + "build", + "test", + "deploy" + ], "items": { "type": "string" }, @@ -46,10 +85,14 @@ "include": { "markdownDescription": "Can be `IncludeItem` or `IncludeItem[]`. Each `IncludeItem` will be a string, or an object with properties for the method if including external YAML file. The external content will be fetched, included and evaluated along the `.gitlab-ci.yml`. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#include).", "oneOf": [ - { "$ref": "#/definitions/include_item" }, + { + "$ref": "#/definitions/include_item" + }, { "type": "array", - "items": { "$ref": "#/definitions/include_item" } + "items": { + "$ref": "#/definitions/include_item" + } } ] }, @@ -60,21 +103,41 @@ "workflow": { "type": "object", "properties": { + "name": { "$ref": "#/definitions/workflowName" }, "rules": { "type": "array", "items": { "anyOf": [ - {"type": "object"}, - {"type": "array", "minLength": 1, "items": { "type": "string" }} + { + "type": "object" + }, + { + "type": "array", + "minLength": 1, + "items": { + "type": "string" + } + } ], "properties": { - "if": { "$ref": "#/definitions/if" }, - "changes": { "$ref": "#/definitions/changes" }, - "exists": { "$ref": "#/definitions/exists" }, - "variables": { "$ref": "#/definitions/variables" }, + "if": { + "$ref": "#/definitions/if" + }, + "changes": { + "$ref": "#/definitions/changes" + }, + "exists": { + "$ref": "#/definitions/exists" + }, + "variables": { + "$ref": "#/definitions/variables" + }, "when": { "type": "string", - "enum": ["always", "never"] + "enum": [ + "always", + "never" + ] } }, "additionalProperties": false @@ -87,8 +150,12 @@ "^[.]": { "description": "Hidden keys.", "anyOf": [ - { "$ref": "#/definitions/job_template" }, - { "description": "Arbitrary YAML anchor." } + { + "$ref": "#/definitions/job_template" + }, + { + "description": "Arbitrary YAML anchor." + } ] } }, @@ -135,15 +202,21 @@ "default": "on_success", "oneOf": [ { - "enum": ["on_success"], + "enum": [ + "on_success" + ], "description": "Upload artifacts only when the job succeeds (this is the default)." }, { - "enum": ["on_failure"], + "enum": [ + "on_failure" + ], "description": "Upload artifacts only when the job fails." }, { - "enum": ["always"], + "enum": [ + "always" + ], "description": "Upload artifacts regardless of job status." } ] @@ -181,7 +254,9 @@ "properties": { "coverage_format": { "description": "Code coverage format used by the test framework.", - "enum": ["cobertura"] + "enum": [ + "cobertura" + ] }, "path": { "description": "Path to the coverage report file that should be parsed.", @@ -285,18 +360,22 @@ "format": "uri-reference", "pattern": "\\.ya?ml$" }, - "rules": { "$ref": "#/definitions/rules" } + "rules": { + "$ref": "#/definitions/rules" + } }, - "required": ["local"] + "required": [ + "local" + ] }, { "type": "object", "additionalProperties": false, "properties": { "project": { - "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", + "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project` [Learn more](https://docs.gitlab.com/ee/ci/yaml/index.html#includefile).", "type": "string", - "pattern": "\\S/\\S|\\$(\\S+)" + "pattern": "(?:\\S/\\S|\\$\\S+)" }, "ref": { "description": "Branch/Tag/Commit-hash for the target project.", @@ -320,7 +399,10 @@ ] } }, - "required": ["project", "file"] + "required": [ + "project", + "file" + ] }, { "type": "object", @@ -333,7 +415,9 @@ "pattern": "\\.ya?ml$" } }, - "required": ["template"] + "required": [ + "template" + ] }, { "type": "object", @@ -346,7 +430,9 @@ "pattern": "^https?://.+\\.ya?ml$" } }, - "required": ["remote"] + "required": [ + "remote" + ] } ] }, @@ -407,7 +493,16 @@ ] } }, - "required": ["name"] + "required": [ + "name" + ] + }, + { + "type": "array", + "minLength": 1, + "items": { + "type": "string" + } } ], "markdownDescription": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#image)." @@ -481,7 +576,9 @@ "minLength": 1 } }, - "required": ["name"] + "required": [ + "name" + ] } ] } @@ -505,20 +602,37 @@ "engine": { "type": "object", "properties": { - "name": { "type": "string" }, - "path": { "type": "string" } + "name": { + "type": "string" + }, + "path": { + "type": "string" + } }, - "required": ["name", "path"] + "required": [ + "name", + "path" + ] + }, + "path": { + "type": "string" }, - "path": { "type": "string" }, - "field": { "type": "string" } + "field": { + "type": "string" + } }, - "required": ["engine", "path", "field"] + "required": [ + "engine", + "path", + "field" + ] } ] } }, - "required": ["vault"] + "required": [ + "vault" + ] } }, "before_script": { @@ -564,45 +678,77 @@ "type": "object", "additionalProperties": false, "properties": { - "if": { "$ref": "#/definitions/if" }, - "changes": { "$ref": "#/definitions/changes" }, - "exists": { "$ref": "#/definitions/exists" }, - "variables": { "$ref": "#/definitions/variables" }, - "when": { "$ref": "#/definitions/when" }, - "start_in": { "$ref": "#/definitions/start_in" }, - "allow_failure": { "$ref": "#/definitions/allow_failure" } + "if": { + "$ref": "#/definitions/if" + }, + "changes": { + "$ref": "#/definitions/changes" + }, + "exists": { + "$ref": "#/definitions/exists" + }, + "variables": { + "$ref": "#/definitions/variables" + }, + "when": { + "$ref": "#/definitions/when" + }, + "start_in": { + "$ref": "#/definitions/start_in" + }, + "allow_failure": { + "$ref": "#/definitions/allow_failure" + } } }, - {"type": "string", "minLength": 1}, - {"type": "array", "minLength": 1, "items": { "type": "string" }} + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minLength": 1, + "items": { + "type": "string" + } + } ] } }, + "workflowName": { + "type": "string", + "markdownDescription": "Defines the pipeline name. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowname).", + "minLength": 1, + "maxLength": 255 + }, "globalVariables": { - "markdownDescription": "Defines environment variables globally. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. You can use the value and description keywords to define variables that are prefilled when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).", - "anyOf": [ - {"type": "object"}, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "additionalProperties": { - "anyOf": [ - {"type": ["string", "integer", "array"]}, - { - "type": "object", - "properties": { - "value": { "type": "string" }, - "description": { - "type": "string", - "description": "Explains what the variable is used for, what the acceptable values are." - } + "markdownDescription": "Defines default variables for all jobs. Job level property overrides global variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).", + "type": "object", + "patternProperties": { + ".*": { + "oneOf": [ + { + "type": [ + "string", + "number" + ] + }, + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "description": { + "type": "string", + "markdownDescription": "Explains what the variable is used for, what the acceptable values are. Variables with `description` are prefilled when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesdescription)." + } + }, + "additionalProperties": false } - } - ] + ] + }, + "additionalProperties": false } }, "if": { @@ -615,7 +761,9 @@ { "type": "object", "additionalProperties": false, - "required": ["paths"], + "required": [ + "paths" + ], "properties": { "paths": { "type": "array", @@ -646,21 +794,17 @@ } }, "variables": { - "markdownDescription": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesvariables).", - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": ["string", "integer", "array"] - } + "markdownDescription": "Defines environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).", + "type": "object", + "patternProperties": { + ".*": { + "type": [ + "string", + "number" + ] }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "additionalProperties": false + } }, "timeout": { "type": "string", @@ -684,7 +828,9 @@ "description": "Exit code that are not considered failure. The job fails for any other exit code.", "type": "object", "additionalProperties": false, - "required": ["exit_codes"], + "required": [ + "exit_codes" + ], "properties": { "exit_codes": { "type": "integer" @@ -695,7 +841,9 @@ "description": "You can list which exit codes are not considered failures. The job fails for any other exit code.", "type": "object", "additionalProperties": false, - "required": ["exit_codes"], + "required": [ + "exit_codes" + ], "properties": { "exit_codes": { "type": "array", @@ -714,27 +862,39 @@ "default": "on_success", "oneOf": [ { - "enum": ["on_success"], + "enum": [ + "on_success" + ], "description": "Execute job only when all jobs from prior stages succeed." }, { - "enum": ["on_failure"], + "enum": [ + "on_failure" + ], "description": "Execute job when at least one job from prior stages fails." }, { - "enum": ["always"], + "enum": [ + "always" + ], "description": "Execute job regardless of the status from prior stages." }, { - "enum": ["manual"], + "enum": [ + "manual" + ], "markdownDescription": "Execute the job manually from Gitlab UI or API. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when)." }, { - "enum": ["delayed"], + "enum": [ + "delayed" + ], "markdownDescription": "Execute a job after the time limit in 'start_in' expires. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when)." }, { - "enum": ["never"], + "enum": [ + "never" + ], "description": "Never execute the job." } ] @@ -746,15 +906,21 @@ "default": "on_success", "oneOf": [ { - "enum": ["on_success"], + "enum": [ + "on_success" + ], "description": "Save the cache only when the job succeeds." }, { - "enum": ["on_failure"], + "enum": [ + "on_failure" + ], "description": "Save the cache only when the job fails. " }, { - "enum": ["always"], + "enum": [ + "always" + ], "description": "Always save the cache. " } ] @@ -806,15 +972,21 @@ "default": "pull-push", "oneOf": [ { - "enum": ["pull"], + "enum": [ + "pull" + ], "description": "Pull will download cache but skip uploading after job completes." }, { - "enum": ["push"], + "enum": [ + "push" + ], "description": "Push will skip downloading cache and always recreate cache after job completes." }, { - "enum": ["pull-push"], + "enum": [ + "pull-push" + ], "description": "Pull-push will both download cache at job start and upload cache on job success." } ] @@ -829,39 +1001,57 @@ { "oneOf": [ { - "enum": ["branches"], + "enum": [ + "branches" + ], "description": "When a branch is pushed." }, { - "enum": ["tags"], + "enum": [ + "tags" + ], "description": "When a tag is pushed." }, { - "enum": ["api"], + "enum": [ + "api" + ], "description": "When a pipeline has been triggered by a second pipelines API (not triggers API)." }, { - "enum": ["external"], + "enum": [ + "external" + ], "description": "When using CI services other than Gitlab" }, { - "enum": ["pipelines"], + "enum": [ + "pipelines" + ], "description": "For multi-project triggers, created using the API with 'CI_JOB_TOKEN'." }, { - "enum": ["pushes"], + "enum": [ + "pushes" + ], "description": "Pipeline is triggered by a `git push` by the user" }, { - "enum": ["schedules"], + "enum": [ + "schedules" + ], "description": "For scheduled pipelines." }, { - "enum": ["triggers"], + "enum": [ + "triggers" + ], "description": "For pipelines created using a trigger token." }, { - "enum": ["web"], + "enum": [ + "web" + ], "description": "For pipelines created using *Run pipeline* button in Gitlab UI (under your project's *Pipelines*)." } ] @@ -889,7 +1079,9 @@ "$ref": "#/definitions/filter_refs" }, "kubernetes": { - "enum": ["active"], + "enum": [ + "active" + ], "description": "Filter job based on if Kubernetes integration is active." }, "variables": { @@ -913,16 +1105,22 @@ "retry": { "markdownDescription": "Retry a job if it fails. Can be a simple integer or object definition. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#retry).", "oneOf": [ - { "$ref": "#/definitions/retry_max" }, + { + "$ref": "#/definitions/retry_max" + }, { "type": "object", "additionalProperties": false, "properties": { - "max": { "$ref": "#/definitions/retry_max" }, + "max": { + "$ref": "#/definitions/retry_max" + }, "when": { "markdownDescription": "Either a single or array of error types to trigger job retry. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#retrywhen).", "oneOf": [ - { "$ref": "#/definitions/retry_errors" }, + { + "$ref": "#/definitions/retry_errors" + }, { "type": "array", "items": { @@ -1005,21 +1203,39 @@ }, "job": { "allOf": [ - { "$ref": "#/definitions/job_template" } + { + "$ref": "#/definitions/job_template" + } ] }, "job_template": { "type": "object", "additionalProperties": false, "properties": { - "image": { "$ref": "#/definitions/image" }, - "services": { "$ref": "#/definitions/services" }, - "before_script": { "$ref": "#/definitions/before_script" }, - "after_script": { "$ref": "#/definitions/after_script" }, - "rules": { "$ref": "#/definitions/rules" }, - "variables": { "$ref": "#/definitions/variables" }, - "cache": { "$ref": "#/definitions/cache" }, - "secrets": { "$ref": "#/definitions/secrets" }, + "image": { + "$ref": "#/definitions/image" + }, + "services": { + "$ref": "#/definitions/services" + }, + "before_script": { + "$ref": "#/definitions/before_script" + }, + "after_script": { + "$ref": "#/definitions/after_script" + }, + "rules": { + "$ref": "#/definitions/rules" + }, + "variables": { + "$ref": "#/definitions/variables" + }, + "cache": { + "$ref": "#/definitions/cache" + }, + "secrets": { + "$ref": "#/definitions/secrets" + }, "script": { "markdownDescription": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#script)", "oneOf": [ @@ -1047,9 +1263,20 @@ ] }, "stage": { - "type": "string", "description": "Define what stage the job will run in.", - "minLength": 1 + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minLength": 1, + "items": { + "type": "string" + } + } + ] }, "only": { "$ref": "#/definitions/filter", @@ -1092,7 +1319,9 @@ "type": "boolean" } }, - "required": ["job"] + "required": [ + "job" + ] }, { "type": "object", @@ -1108,7 +1337,10 @@ "type": "boolean" } }, - "required": ["job", "pipeline"] + "required": [ + "job", + "pipeline" + ] }, { "type": "object", @@ -1127,7 +1359,11 @@ "type": "boolean" } }, - "required": ["job", "project", "ref"] + "required": [ + "job", + "project", + "ref" + ] } ] } @@ -1164,7 +1400,9 @@ "environment": { "description": "Used to associate environment metadata with a deploy. Environment can have a name and URL attached to it, and will be displayed under /environments under the project.", "oneOf": [ - { "type": "string" }, + { + "type": "string" + }, { "type": "object", "additionalProperties": false, @@ -1185,7 +1423,13 @@ "description": "The name of a job to execute when the environment is about to be stopped." }, "action": { - "enum": ["start", "prepare", "stop", "verify", "access"], + "enum": [ + "start", + "prepare", + "stop", + "verify", + "access" + ], "description": "Specifies what this job will do. 'start' (default) indicates the job will start the deployment. 'prepare'/'verify'/'access' indicates this will not affect the deployment. 'stop' indicates this will stop the deployment.", "default": "start" }, @@ -1216,7 +1460,9 @@ ] } }, - "required": ["name"] + "required": [ + "name" + ] } ] }, @@ -1296,15 +1542,23 @@ ] } }, - "required": ["name", "url"] + "required": [ + "name", + "url" + ] }, "minItems": 1 } }, - "required": ["links"] + "required": [ + "links" + ] } }, - "required": ["tag_name", "description"] + "required": [ + "tag_name", + "description" + ] }, "coverage": { "type": "string", @@ -1335,14 +1589,20 @@ "type": "object", "description": "Defines environment variables for specific job.", "additionalProperties": { - "type": ["string", "number", "array"] + "type": [ + "string", + "number", + "array" + ] } }, "maxItems": 50 } }, "additionalProperties": false, - "required": ["matrix"] + "required": [ + "matrix" + ] } ] }, @@ -1364,7 +1624,7 @@ "project": { "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", "type": "string", - "pattern": "\\S/\\S" + "pattern": "(?:\\S/\\S|\\$\\S+)" }, "branch": { "description": "The branch name that a downstream pipeline will use", @@ -1373,7 +1633,9 @@ "strategy": { "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend", "type": "string", - "enum": ["depend"] + "enum": [ + "depend" + ] }, "forward": { "description": "Specify what to forward to the downstream pipeline.", @@ -1393,9 +1655,13 @@ } } }, - "required": ["project"], + "required": [ + "project" + ], "dependencies": { - "branch": ["project"] + "branch": [ + "project" + ] } }, { @@ -1456,7 +1722,10 @@ "type": "string" } }, - "required": ["artifact", "job"] + "required": [ + "artifact", + "job" + ] }, { "type": "object", @@ -1465,7 +1734,7 @@ "project": { "description": "Path to another private project under the same GitLab instance, like `group/project` or `group/sub-group/project`.", "type": "string", - "pattern": "\\S/\\S" + "pattern": "(?:\\S/\\S|\\$\\S+)" }, "ref": { "description": "Branch/Tag/Commit hash for the target project.", @@ -1479,7 +1748,10 @@ "pattern": "\\.ya?ml$" } }, - "required": ["project", "file"] + "required": [ + "project", + "file" + ] } ] } @@ -1489,7 +1761,9 @@ "strategy": { "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend", "type": "string", - "enum": ["depend"] + "enum": [ + "depend" + ] }, "forward": { "description": "Specify what to forward to the downstream pipeline.", @@ -1511,9 +1785,9 @@ } }, { - "markdownDescription": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#define-multi-project-pipelines-in-your-gitlab-ciyml-file).", + "markdownDescription": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#trigger).", "type": "string", - "pattern": "\\S/\\S" + "pattern": "(?:\\S/\\S|\\$\\S+)" } ] }, @@ -1550,7 +1824,9 @@ "variables": { "markdownDescription": "Whether to inherit all globally-defined variables or not. Or subset of inherited variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#inheritvariables).", "oneOf": [ - { "type": "boolean" }, + { + "type": "boolean" + }, { "type": "array", "items": { @@ -1566,15 +1842,24 @@ "oneOf": [ { "properties": { - "when": { "enum": ["delayed"] } + "when": { + "enum": [ + "delayed" + ] + } }, - "required": ["when", "start_in"] + "required": [ + "when", + "start_in" + ] }, { "properties": { "when": { "not": { - "enum": ["delayed"] + "enum": [ + "delayed" + ] } } } @@ -1583,10 +1868,23 @@ }, "tags": { "type": "array", + "minLength": 1, "markdownDescription": "Used to select runners from the list of available runners. A runner must have all tags listed here to run the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#tags).", "items": { - "type": "string" + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minLength": 1, + "items": { + "type": "string" + } + } + ] } } } -} +}
\ No newline at end of file diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index 3173c2bd644..78e1b8d5cb2 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlModal } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql'; @@ -65,11 +65,11 @@ export default { .then(({ data }) => { const [message] = data?.deleteEvironment?.errors ?? []; if (message) { - createFlash({ message }); + createAlert({ message }); } }) .catch((error) => - createFlash({ + createAlert({ message: s__( 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', ), diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index 3475b38c8c9..b00a0777a03 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -10,7 +10,7 @@ import { import { __, s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import deploymentDetails from '../graphql/queries/deployment_details.query.graphql'; import DeploymentStatusBadge from './deployment_status_badge.vue'; import Commit from './commit.vue'; @@ -119,7 +119,7 @@ export default { return data?.project?.deployment?.tags; }, error(error) { - createFlash({ + createAlert({ message: this.$options.i18n.LOAD_ERROR_MESSAGE, captureError: true, error, diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index 96742a11ebb..901d0f5b34d 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -1,5 +1,5 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import EnvironmentForm from './environment_form.vue'; @@ -39,7 +39,7 @@ export default { .then(({ data: { path } }) => visitUrl(path)) .catch((error) => { const message = error.response.data.message[0]; - createFlash({ message }); + createAlert({ message }); this.loading = false; }); }, diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue index 563fa6c96fb..e40c37b5095 100644 --- a/app/assets/javascripts/environments/components/empty_state.vue +++ b/app/assets/javascripts/environments/components/empty_state.vue @@ -1,9 +1,14 @@ <script> +import { GlEmptyState, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; import { ENVIRONMENTS_SCOPE } from '../constants'; export default { - name: 'EnvironmentsEmptyState', + components: { + GlEmptyState, + GlLink, + }, + inject: ['newEnvironmentPath'], props: { helpPath: { type: String, @@ -13,10 +18,23 @@ export default { type: String, required: true, }, + hasTerm: { + type: Boolean, + required: false, + default: false, + }, }, computed: { title() { - return this.$options.i18n.title[this.scope]; + return this.hasTerm + ? this.$options.i18n.searchingTitle + : this.$options.i18n.title[this.scope]; + }, + content() { + return this.hasTerm ? this.$options.i18n.searchingContent : this.$options.i18n.content; + }, + buttonText() { + return this.hasTerm ? this.$options.i18n.newEnvironmentButtonLabel : ''; }, }, i18n: { @@ -27,20 +45,21 @@ export default { content: s__( 'Environments|Environments are places where code gets deployed, such as staging or production.', ), + searchingTitle: s__('Environments|No results found'), + searchingContent: s__('Environments|Edit your search and try again'), link: s__('Environments|How do I create an environment?'), + newEnvironmentButtonLabel: s__('Environments|New environment'), }, }; </script> <template> - <div class="empty-state"> - <div class="text-content"> - <h4 class="js-blank-state-title"> - {{ title }} - </h4> - <p> - {{ $options.i18n.content }} - <a :href="helpPath"> {{ $options.i18n.link }} </a> - </p> - </div> - </div> + <gl-empty-state :primary-button-text="buttonText" :primary-button-link="newEnvironmentPath"> + <template #title> + <h4>{{ title }}</h4> + </template> + <template #description> + <p>{{ content }}</p> + <gl-link v-if="!hasTerm" :href="helpPath">{{ $options.i18n.link }}</gl-link> + </template> + </gl-empty-state> </template> diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index 6343fe8702a..420ad3d9c42 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -1,18 +1,19 @@ <script> -import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlModal, GlSprintf, GlIcon, GlPopover } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { REVIEW_APP_MODAL_I18N as i18n } from '../constants'; export default { components: { GlLink, GlModal, GlSprintf, + GlIcon, + GlPopover, ModalCopyButton, }, - inject: ['defaultBranchName'], model: { prop: 'visible', event: 'change', @@ -28,25 +29,6 @@ export default { default: false, }, }, - instructionText: { - step1: s__( - 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.', - ), - step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'), - step3: s__( - `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`, - ), - step4: s__( - `EnableReviewApp|%{stepStart}Step 4 (optional)%{stepEnd}. Enable Visual Reviews by following the %{linkStart}setup instructions%{linkEnd}.`, - ), - }, - modalInfo: { - closeText: s__('EnableReviewApp|Close'), - copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), - title: s__('ReviewApp|Enable Review App'), - }, - visualReviewsDocs: helpPagePath('ci/review_apps/index.md', { anchor: 'visual-reviews' }), - connectClusterDocs: helpPagePath('user/clusters/agent/index'), data() { const modalInfoCopyId = uniqueId('enable-review-app-copy-string-'); @@ -57,81 +39,99 @@ export default { return `deploy_review: stage: deploy script: - - echo "Deploy a review app" + - echo "Add script here that deploys the code to your infrastructure" environment: name: review/$CI_COMMIT_REF_NAME url: https://$CI_ENVIRONMENT_SLUG.example.com - only: - - branches - except: - - ${this.defaultBranchName}`; + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event"`; + }, + }, + methods: { + commaOrPeriod(index, length) { + return index + 1 === length ? '.' : ','; }, }, + i18n, + configuringReviewAppsPath: helpPagePath('ci/review_apps/index.md', { + anchor: 'configuring-review-apps', + }), + reviewAppsExamplesPath: helpPagePath('ci/review_apps/index.md', { + anchor: 'review-apps-examples', + }), }; </script> <template> <gl-modal :visible="visible" :modal-id="modalId" - :title="$options.modalInfo.title" + :title="$options.i18n.title" static size="lg" - ok-only - ok-variant="light" - :ok-title="$options.modalInfo.closeText" + hide-footer @change="$emit('change', $event)" > + <p>{{ $options.i18n.intro }}</p> <p> - <gl-sprintf :message="$options.instructionText.step1"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #link="{ content }"> - <gl-link :href="$options.connectClusterDocs" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + <strong>{{ $options.i18n.instructions.title }}</strong> </p> - <div> - <p> - <gl-sprintf :message="$options.instructionText.step2"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <div class="gl-display-flex align-items-start"> - <pre :id="modalInfoCopyId" class="gl-w-full" data-testid="enable-review-app-copy-string"> - {{ modalInfoCopyStr }} </pre - > - <modal-copy-button - :title="$options.modalInfo.copyToClipboardText" - :modal-id="modalId" - css-classes="border-0" - :target="`#${modalInfoCopyId}`" - /> - </div> + <div class="gl-mb-6"> + <ol class="gl-px-6"> + <li> + {{ $options.i18n.instructions.step1 }} + <gl-icon + ref="informationIcon" + name="information-o" + class="gl-text-blue-600 gl-hover-cursor-pointer" + /> + <gl-popover + :target="() => $refs.informationIcon.$el" + :title="$options.i18n.staticSitePopover.title" + triggers="hover focus" + > + {{ $options.i18n.staticSitePopover.body }} + </gl-popover> + </li> + <li>{{ $options.i18n.instructions.step2 }}</li> + <li> + {{ $options.i18n.instructions.step3 }} + <ul class="gl-px-4 gl-py-2"> + <li>{{ $options.i18n.instructions.step3a }}</li> + <li> + <gl-sprintf :message="$options.i18n.instructions.step3b"> + <template #code="{ content }" + ><code>{{ content }}</code></template + > + </gl-sprintf> + </li> + <li class="gl-list-style-none"> + <div class="gl-display-flex align-items-start"> + <pre + :id="modalInfoCopyId" + class="gl-w-full" + data-testid="enable-review-app-copy-string" + >{{ modalInfoCopyStr }}</pre + > + <modal-copy-button + :title="$options.i18n.copyToClipboardText" + :modal-id="modalId" + css-classes="border-0" + :target="`#${modalInfoCopyId}`" + /> + </div> + </li> + </ul> + </li> + <li>{{ $options.i18n.instructions.step4 }}</li> + </ol> + <gl-link :href="$options.configuringReviewAppsPath" target="_blank"> + {{ $options.i18n.learnMore }} + <gl-icon name="external-link" /> + </gl-link> + <gl-link :href="$options.reviewAppsExamplesPath" target="_blank" class="gl-ml-6"> + {{ $options.i18n.viewMoreExampleProjects }} + <gl-icon name="external-link" /> + </gl-link> </div> - <p> - <gl-sprintf :message="$options.instructionText.step3"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #link="{ content }"> - <gl-link :href="`blob/${defaultBranchName}/.gitlab-ci.yml`" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </p> - <p> - <gl-sprintf :message="$options.instructionText.step4"> - <template #step="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #link="{ content }"> - <gl-link :href="$options.visualReviewsDocs" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> </gl-modal> </template> diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue index b8def676e7d..04a390fbba7 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.vue +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -1,6 +1,8 @@ <script> import { GlTooltipDirective, GlButton } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { s__, __ } from '~/locale'; +import { isSafeURL } from '~/lib/utils/url_utility'; /** * Renders the external url link in environments table. @@ -8,6 +10,7 @@ import { s__ } from '~/locale'; export default { components: { GlButton, + ModalCopyButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -21,11 +24,19 @@ export default { i18n: { title: s__('Environments|Open live environment'), open: s__('Environments|Open'), + copy: __('Copy URL'), + copyTitle: s__('Environments|Copy live environment URL'), + }, + computed: { + isSafeUrl() { + return isSafeURL(this.externalUrl); + }, }, }; </script> <template> <gl-button + v-if="isSafeUrl" v-gl-tooltip :title="$options.i18n.title" :aria-label="$options.i18n.title" @@ -37,4 +48,7 @@ export default { > {{ $options.i18n.open }} </gl-button> + <modal-copy-button v-else :title="$options.i18n.copyTitle" :text="externalUrl"> + {{ $options.i18n.copy }} + </modal-copy-button> </template> diff --git a/app/assets/javascripts/environments/components/environment_folder.vue b/app/assets/javascripts/environments/components/environment_folder.vue index 881f404340d..2f6c54e4707 100644 --- a/app/assets/javascripts/environments/components/environment_folder.vue +++ b/app/assets/javascripts/environments/components/environment_folder.vue @@ -24,6 +24,10 @@ export default { type: String, required: true, }, + search: { + type: String, + required: true, + }, }, data() { return { visible: false, interval: undefined }; @@ -32,7 +36,11 @@ export default { folder: { query: folderQuery, variables() { - return { environment: this.nestedEnvironment.latest, scope: this.scope }; + return { + environment: this.nestedEnvironment.latest, + scope: this.scope, + search: this.search, + }; }, }, interval: { diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index f44182e822b..55e6a891e27 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,7 +1,9 @@ <script> -import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import { GlBadge, GlPagination, GlSearchBoxByType, GlTab, GlTabs } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { s__, __, sprintf } from '~/locale'; import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; @@ -31,6 +33,7 @@ export default { StopEnvironmentModal, GlBadge, GlPagination, + GlSearchBoxByType, GlTab, GlTabs, }, @@ -41,11 +44,10 @@ export default { return { scope: this.scope, page: this.page ?? 1, + search: this.search, }; }, - pollInterval() { - return this.interval; - }, + pollInterval: 3000, }, interval: { query: pollIntervalQuery, @@ -80,10 +82,11 @@ export default { next: __('Next'), prev: __('Prev'), goto: (page) => sprintf(__('Go to page %{page}'), { page }), + searchPlaceholder: s__('Environments|Search by environment name'), }, modalId: 'enable-review-app-info', data() { - const { page = '1', scope } = queryToObject(window.location.search); + const { page = '1', search = '', scope } = queryToObject(window.location.search); return { interval: undefined, isReviewAppModalVisible: false, @@ -97,6 +100,7 @@ export default { environmentToStop: {}, environmentToChangeCanary: {}, weight: 0, + search, }; }, computed: { @@ -112,6 +116,9 @@ export default { hasEnvironments() { return this.environments.length > 0 || this.folders.length > 0; }, + hasSearch() { + return Boolean(this.search); + }, availableCount() { return this.environmentApp?.availableCount; }, @@ -152,11 +159,19 @@ export default { return this.pageInfo?.perPage; }, }, + watch: { + interval(val) { + this.$apollo.queries.environmentApp.stopPolling(); + this.$apollo.queries.environmentApp.startPolling(val); + }, + }, mounted() { window.addEventListener('popstate', this.syncPageFromQueryParams); + window.addEventListener('popstate', this.syncSearchFromQueryParams); }, destroyed() { window.removeEventListener('popstate', this.syncPageFromQueryParams); + window.removeEventListener('popstate', this.syncSearchFromQueryParams); this.$apollo.queries.environmentApp.stopPolling(); }, methods: { @@ -173,23 +188,24 @@ export default { moveToPage(page) { this.page = page; updateHistory({ - url: setUrlParams({ page: this.page }), + url: setUrlParams({ page: this.page, scope: this.scope, search: this.search }), title: document.title, }); - this.resetPolling(); }, + setSearch: debounce(function setSearch(input) { + this.search = input; + this.moveToPage(1); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), syncPageFromQueryParams() { const { page = '1' } = queryToObject(window.location.search); this.page = parseInt(page, 10); }, - resetPolling() { - this.$apollo.queries.environmentApp.stopPolling(); + syncSearchFromQueryParams() { + const { search = '' } = queryToObject(window.location.search); + this.search = search; + }, + refetchEnvironments() { this.$apollo.queries.environmentApp.refetch(); - this.$nextTick(() => { - if (this.interval) { - this.$apollo.queries.environmentApp.startPolling(this.interval); - } - }); }, }, ENVIRONMENTS_SCOPE, @@ -237,12 +253,19 @@ export default { </template> </gl-tab> </gl-tabs> + <gl-search-box-by-type + class="gl-mb-4" + :value="search" + :placeholder="$options.i18n.searchPlaceholder" + @input="setSearch" + /> <template v-if="hasEnvironments"> <environment-folder v-for="folder in folders" :key="folder.name" class="gl-mb-3" :scope="scope" + :search="search" :nested-environment="folder" /> <environment-item @@ -250,10 +273,15 @@ export default { :key="environment.name" class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid" :environment="environment.latest" - @change="resetPolling" + @change="refetchEnvironments" /> </template> - <empty-state v-else :help-path="helpPagePath" :scope="scope" /> + <empty-state + v-else-if="!$apollo.queries.environmentApp.loading" + :help-path="helpPagePath" + :scope="scope" + :has-term="hasSearch" + /> <gl-pagination align="center" :total-items="totalItems" diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue index bd67908a6b4..bb2f053b3fc 100644 --- a/app/assets/javascripts/environments/components/environments_detail_header.vue +++ b/app/assets/javascripts/environments/components/environments_detail_header.vue @@ -4,6 +4,8 @@ import csrf from '~/lib/utils/csrf'; import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { isSafeURL } from '~/lib/utils/url_utility'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; @@ -16,6 +18,7 @@ export default { TimeAgo, DeleteEnvironmentModal, StopEnvironmentModal, + ModalCopyButton, }, directives: { GlModalDirective, @@ -73,6 +76,8 @@ export default { deleteButtonText: s__('Environments|Delete'), externalButtonTitle: s__('Environments|Open live environment'), externalButtonText: __('View deployment'), + copyUrlText: __('Copy URL'), + copyUrlTitle: s__('Environments|Copy live environment URL'), cancelAutoStopButtonTitle: __('Prevent environment from auto-stopping'), }, computed: { @@ -82,6 +87,9 @@ export default { shouldShowExternalUrlButton() { return Boolean(this.environment.externalUrl); }, + isSafeUrl() { + return isSafeURL(this.environment.externalUrl); + }, shouldShowStopButton() { return this.canStopEnvironment && this.environment.isAvailable; }, @@ -123,16 +131,25 @@ export default { :href="terminalPath" icon="terminal" /> - <gl-button - v-if="shouldShowExternalUrlButton" - v-gl-tooltip.hover - data-testid="external-url-button" - :title="$options.i18n.externalButtonTitle" - :href="environment.externalUrl" - icon="external-link" - target="_blank" - >{{ $options.i18n.externalButtonText }}</gl-button - > + <template v-if="shouldShowExternalUrlButton"> + <gl-button + v-if="isSafeUrl" + v-gl-tooltip.hover + data-testid="external-url-button" + :title="$options.i18n.externalButtonTitle" + :href="environment.externalUrl" + icon="external-link" + target="_blank" + >{{ $options.i18n.externalButtonText }}</gl-button + > + <modal-copy-button + v-else + :title="$options.i18n.copyUrlTitle" + :text="environment.externalUrl" + > + {{ $options.i18n.copyUrlText }} + </modal-copy-button> + </template> <gl-button v-if="shouldShowExternalUrlButton" v-gl-tooltip.hover diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue index 14da2668417..bb4d6ab3428 100644 --- a/app/assets/javascripts/environments/components/new_environment.vue +++ b/app/assets/javascripts/environments/components/new_environment.vue @@ -1,5 +1,5 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import EnvironmentForm from './environment_form.vue'; @@ -32,7 +32,7 @@ export default { .then(({ data: { path } }) => visitUrl(path)) .catch((error) => { const message = error.response.data.message[0]; - createFlash({ message }); + createAlert({ message }); this.loading = false; }); }, diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 942491039d6..c4d02da9d21 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; // These statuses are based on how the backend defines pod phases here // lib/gitlab/kubernetes/pod.rb @@ -48,3 +48,32 @@ export const ENVIRONMENT_COUNT_BY_SCOPE = { [ENVIRONMENTS_SCOPE.AVAILABLE]: 'availableCount', [ENVIRONMENTS_SCOPE.STOPPED]: 'stoppedCount', }; + +export const REVIEW_APP_MODAL_I18N = { + title: s__('ReviewApp|Enable Review App'), + intro: s__( + 'EnableReviewApp|Review apps are dynamic environments that you can use to provide a live preview of changes made in a feature branch.', + ), + instructions: { + title: s__('EnableReviewApp|To configure a dynamic review app, you must:'), + step1: s__( + 'EnableReviewApp|Have access to infrastructure that can host and deploy the review apps.', + ), + step2: s__('EnableReviewApp|Install and configure a runner to do the deployment.'), + step3: s__('EnableReviewApp|Add a job in your CI/CD configuration that:'), + step3a: s__('EnableReviewApp|Only runs for feature branches or merge requests.'), + step3b: s__( + 'EnableReviewApp|Uses a predefined CI/CD variable like %{codeStart}$(CI_COMMIT_REF_SLUG)%{codeEnd} to dynamically create the review app environments. For example, for a configuration using merge request pipelines:', + ), + step4: s__('EnableReviewApp|Recommended: Set up a job that manually stops the Review Apps.'), + }, + staticSitePopover: { + title: s__('EnableReviewApp|Using a static site?'), + body: s__( + 'EnableReviewApp|Make sure your project has an environment configured with the target URL set to your website URL. If not, create a new one before continuing.', + ), + }, + learnMore: __('Learn more'), + viewMoreExampleProjects: s__('EnableReviewApp|View more example projects'), + copyToClipboardText: s__('EnableReviewApp|Copy snippet'), +}; diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql index c3ab9cf7fca..1a572208a1c 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -1,5 +1,5 @@ -query getEnvironmentApp($page: Int, $scope: String) { - environmentApp(page: $page, scope: $scope) @client { +query getEnvironmentApp($page: Int, $scope: String, $search: String) { + environmentApp(page: $page, scope: $scope, search: $search) @client { availableCount stoppedCount environments diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql index e8c145ee916..c662acb8f93 100644 --- a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql @@ -1,5 +1,5 @@ -query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String) { - folder(environment: $environment, scope: $scope) @client { +query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String, $search: String) { + folder(environment: $environment, scope: $scope, search: $search) @client { availableCount environments stoppedCount diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 722bb78bcf9..afd56d0cf0d 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -30,8 +30,8 @@ const mapEnvironment = (env) => ({ export const resolvers = (endpoint) => ({ Query: { - environmentApp(_context, { page, scope }, { cache }) { - return axios.get(endpoint, { params: { nested: true, page, scope } }).then((res) => { + environmentApp(_context, { page, scope, search }, { cache }) { + return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => { const headers = normalizeHeaders(res.headers); const interval = headers['POLL-INTERVAL']; const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; @@ -59,8 +59,8 @@ export const resolvers = (endpoint) => ({ }; }); }, - folder(_, { environment: { folderPath }, scope }) { - return axios.get(folderPath, { params: { scope, per_page: 3 } }).then((res) => ({ + folder(_, { environment: { folderPath }, scope, search }) { + return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ availableCount: res.data.available_count, environments: res.data.environments.map(mapEnvironment), stoppedCount: res.data.stopped_count, diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 8957a3074ed..5e936ad8c96 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -3,7 +3,7 @@ */ import { isEqual, isFunction, omitBy } from 'lodash'; import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Poll from '~/lib/utils/poll'; import { getParameterByName } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; @@ -94,7 +94,7 @@ export default { errorCallback() { this.isLoading = false; - createFlash({ + createAlert({ message: s__('Environments|An error occurred while fetching the environments.'), }); }, @@ -123,7 +123,7 @@ export default { }) .catch((err) => { this.isLoading = false; - createFlash({ + createAlert({ message: isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage, }); }); @@ -179,7 +179,7 @@ export default { window.location.href = url.join('/'); }) .catch(() => { - createFlash({ + createAlert({ message: errorMessage, }); }); diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index a602c92a840..b02c3cd2cba 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -13,7 +13,7 @@ import { GlIcon, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_WARNING } from '~/flash'; import { __, sprintf, n__ } from '~/locale'; import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -89,7 +89,7 @@ export default { pollInterval: 2000, update: (data) => data.project.sentryErrors.detailedError, error: () => - createFlash({ + createAlert({ message: __('Failed to load error details from Sentry.'), }), result(res) { @@ -234,9 +234,9 @@ export default { if (Date.now() > this.errorPollTimeout) { this.$apollo.queries.error.stopPolling(); this.errorLoading = false; - createFlash({ + createAlert({ message: __('Could not connect to Sentry. Refresh the page to try again.'), - type: 'warning', + variant: VARIANT_WARNING, }); } }, diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index fbfcd6ce2df..603f8611005 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import service from '../services'; @@ -18,7 +18,7 @@ export const updateStatus = ({ commit }, { endpoint, redirectUrl, status }) => return resp.data.result; }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to update issue status'), }), ); diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js index 09fa650f64b..1409399940a 100644 --- a/app/assets/javascripts/error_tracking/store/details/actions.js +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import service from '../../services'; @@ -26,7 +26,7 @@ export function startPollingStacktrace({ commit }, endpoint) { }, errorCallback: () => { commit(types.SET_LOADING_STACKTRACE, false); - createFlash({ + createAlert({ message: __('Failed to load stacktrace.'), }); }, diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 418056314f6..f633711add3 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import Service from '../../services'; @@ -33,7 +33,7 @@ export function startPolling({ state, commit, dispatch }) { }, errorCallback: () => { commit(types.SET_LOADING, false); - createFlash({ + createAlert({ message: __('Failed to load errors from Sentry.'), }); }, diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 70fb1fa9cd7..3bc91a2adbf 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -190,7 +190,7 @@ export default { <gl-form-radio name="error-tracking-integrated" :value="true"> {{ __('GitLab') }} <template #help> - {{ __('Uses GitLab as a lightweight alternative to Sentry.') }} + {{ __('Uses GitLab as an alternative to Sentry.') }} </template> </gl-form-radio> </gl-form-radio-group> diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 972ad58c617..4d6fe767f3a 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -46,7 +46,7 @@ export const requestSettings = ({ commit }) => { export const receiveSettingsError = ({ commit }, { response = {} }) => { const message = response.data && response.data.message ? response.data.message : ''; - createFlash({ + createAlert({ message: `${__('There was an error saving your changes.')} ${message}`, }); commit(types.UPDATE_SETTINGS_LOADING, false); diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue index 70b60b4b113..ce5f7915dbf 100644 --- a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlSearchBoxByType } from '@gitlab/ui'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -87,7 +87,7 @@ export default { .catch(() => { this.isLoading = false; this.closeSuggestions(); - createFlash({ + createAlert({ message: __('Something went wrong on our end. Please try again.'), }); }); diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue index 98982920121..89400bc4742 100644 --- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -8,7 +8,7 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; @@ -52,7 +52,7 @@ export default { this.results = data || []; }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong on our end. Please try again.'), }); }) diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js index 8656479190a..97c22781ac5 100644 --- a/app/assets/javascripts/feature_flags/store/edit/actions.js +++ b/app/assets/javascripts/feature_flags/store/edit/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -49,7 +49,7 @@ export const receiveFeatureFlagSuccess = ({ commit }, response) => commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response); export const receiveFeatureFlagError = ({ commit }) => { commit(types.RECEIVE_FEATURE_FLAG_ERROR); - createFlash({ + createAlert({ message: __('Something went wrong on our end. Please try again!'), }); }; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js index b26a96499ba..a9542a9667e 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -10,7 +10,7 @@ export function dismiss(endpoint, highlightId) { feature_name: highlightId, }) .catch(() => - createFlash({ + createAlert({ message: __( 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', ), diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 9726b2164b7..23591fc0667 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import AjaxFilter from './droplab/plugins/ajax_filter'; import DropdownUtils from './dropdown_utils'; @@ -27,7 +27,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { searchValueFunction: this.getSearchInput.bind(this), loadingTemplate: this.loadingTemplate, onError() { - createFlash({ + createAlert({ message: __('An error occurred fetching the dropdown data.'), }); }, diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index aeea66bf51c..8c50c1860ec 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import Ajax from './droplab/plugins/ajax'; import Filter from './droplab/plugins/filter'; @@ -14,7 +14,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown { method: 'setData', loadingTemplate: this.loadingTemplate, onError() { - createFlash({ + createAlert({ message: __('An error occurred fetching the dropdown data.'), }); }, diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index ddc3c06a9d1..ab95986dc62 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import Ajax from './droplab/plugins/ajax'; import Filter from './droplab/plugins/filter'; @@ -17,7 +17,7 @@ export default class DropdownNonUser extends FilteredSearchDropdown { loadingTemplate: this.loadingTemplate, preprocessing, onError() { - createFlash({ + createAlert({ message: __('An error occurred fetching the dropdown data.'), }); }, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index ac2cf27e873..bc0f5398b4c 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,7 +1,7 @@ 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 createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { ENTER_KEY_CODE, BACKSPACE_KEY_CODE, @@ -91,7 +91,7 @@ export default class FilteredSearchManager { .fetch() .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; - createFlash({ + createAlert({ message: __('An error occurred while parsing recent searches'), }); // Gracefully fail to empty array diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 0d144398531..1ad2006d689 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -4,7 +4,7 @@ import * as Emoji from '~/emoji'; import FilteredSearchContainer from '~/filtered_search/container'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; @@ -85,7 +85,7 @@ export default class VisualTokenValue { ); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while fetching label colors.'), }), ); @@ -111,7 +111,7 @@ export default class VisualTokenValue { VisualTokenValue.replaceEpicTitle(tokenValueContainer, matchingEpic.title, matchingEpic.id); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while adding formatted title for epic'), }), ); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index edf83a33812..5665231e613 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -158,7 +158,7 @@ const createAlert = function createAlert({ onDismiss(); } this.$destroy(); - this.$el.parentNode.removeChild(this.$el); + this.$el.parentNode?.removeChild(this.$el); }, }, render(h) { diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index d376c9f76ba..0a4733de65f 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { queryToObject } from '~/lib/utils/url_utility'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -19,7 +19,7 @@ export default class GpgBadges { badges.children().attr('aria-label', __('Loading')); const displayError = () => - createFlash({ + createAlert({ message: __('An error occurred while loading commit signatures'), }); diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js index 25347ad6433..db2fd3cc256 100644 --- a/app/assets/javascripts/grafana_integration/store/actions.js +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -38,7 +38,7 @@ export const receiveGrafanaIntegrationUpdateError = (_, error) => { const { response } = error; const message = response.data && response.data.message ? response.data.message : ''; - createFlash({ + createAlert({ message: `${__('There was an error saving your changes.')} ${message}`, }); }; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index e86103c332b..3b737dfff33 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -4,14 +4,13 @@ import { concatPagination } from '@apollo/client/utilities'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; -import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; -import { WIDGET_TYPE_LABELS } from '~/work_items/constants'; +import { WIDGET_TYPE_MILESTONE } from '~/work_items/constants'; export const temporaryConfig = { typeDefs, cacheConfig: { possibleTypes: { - LocalWorkItemWidget: ['LocalWorkItemLabels'], + LocalWorkItemWidget: ['LocalWorkItemMilestone'], }, typePolicies: { Project: { @@ -28,18 +27,32 @@ export const temporaryConfig = { return ( widgets || [ { - __typename: 'LocalWorkItemLabels', - type: WIDGET_TYPE_LABELS, - allowScopedLabels: true, - nodes: [], + __typename: 'LocalWorkItemMilestone', + type: WIDGET_TYPE_MILESTONE, + nodes: [ + { + dueDate: null, + expired: false, + id: 'gid://gitlab/Milestone/30', + title: 'v4.0', + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Milestone', + }, + ], }, ] ); }, }, widgets: { - merge(_, incoming) { - return incoming; + merge(existing = [], incoming) { + if (existing.length === 0) { + return incoming; + } + return existing.map((existingWidget) => { + const incomingWidget = incoming.find((w) => w.type === existingWidget.type); + return incomingWidget || existingWidget; + }); }, }, }, @@ -62,27 +75,6 @@ export const resolvers = { }); cache.writeQuery({ query: getIssueStateQuery, data }); }, - localUpdateWorkItem(_, { input }, { cache }) { - const sourceData = cache.readQuery({ - query: workItemQuery, - variables: { id: input.id }, - }); - - const data = produce(sourceData, (draftData) => { - if (input.labels) { - const labelsWidget = draftData.workItem.mockWidgets.find( - (widget) => widget.type === WIDGET_TYPE_LABELS, - ); - labelsWidget.nodes = [...input.labels]; - } - }); - - cache.writeQuery({ - query: workItemQuery, - variables: { id: input.id }, - data, - }); - }, }, }; diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index c6bd9e563c0..545c150e536 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -144,7 +144,7 @@ "WorkItemWidgetIteration", "WorkItemWidgetLabels", "WorkItemWidgetStartAndDueDate", - "WorkItemWidgetVerificationStatus", + "WorkItemWidgetStatus", "WorkItemWidgetWeight" ] } diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index f64c4276deb..c05b4a5950c 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -9,6 +9,7 @@ query projectUsersSearch($search: String!, $fullPath: ID!, $after: String, $firs relations: [DIRECT, INHERITED, INVITED_GROUPS] first: $first after: $after + sort: USER_FULL_NAME_ASC ) { pageInfo { hasNextPage diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql index 2bd016feb19..5a589b094de 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql @@ -8,7 +8,11 @@ query projectUsersSearchWithMRPermissions( ) { workspace: project(fullPath: $fullPath) { id - users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { + users: projectMembers( + search: $search + relations: [DIRECT, INHERITED, INVITED_GROUPS] + sort: USER_FULL_NAME_ASC + ) { nodes { id mergeRequestInteraction(id: $mergeRequestId) { diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 49e7dd28ff6..cc70d832edc 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,6 +1,6 @@ import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { getGroupPathAvailability } from '~/rest_api'; import axios from '~/lib/utils/axios_utils'; @@ -77,7 +77,7 @@ export default class Group { element.value = suggestedSlug; }); } else if (exists && !suggests.length) { - createFlash({ + createAlert({ message: __('Unable to suggest a path. Please refresh and try again.'), }); } @@ -87,7 +87,7 @@ export default class Group { return; } - createFlash({ + createAlert({ message: __('An error occurred while checking group path. Please refresh and try again.'), }); }); diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 0bd7371d39b..15f5a3518a5 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon, GlModal } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; @@ -51,7 +51,6 @@ export default { isModalVisible: false, isLoading: true, isSearchEmpty: false, - searchEmptyMessage: '', targetGroup: null, targetParentGroup: null, showEmptyState: false, @@ -88,15 +87,12 @@ export default { }, }, created() { - this.searchEmptyMessage = this.hideProjects - ? COMMON_STR.GROUP_SEARCH_EMPTY - : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY; - eventHub.$on(`${this.action}fetchPage`, this.fetchPage); eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren); eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal); eventHub.$on(`${this.action}updatePagination`, this.updatePagination); eventHub.$on(`${this.action}updateGroups`, this.updateGroups); + eventHub.$on(`${this.action}fetchFilteredAndSortedGroups`, this.fetchFilteredAndSortedGroups); }, mounted() { this.fetchAllGroups(); @@ -111,6 +107,7 @@ export default { eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal); eventHub.$off(`${this.action}updatePagination`, this.updatePagination); eventHub.$off(`${this.action}updateGroups`, this.updateGroups); + eventHub.$off(`${this.action}fetchFilteredAndSortedGroups`, this.fetchFilteredAndSortedGroups); }, methods: { hideModal() { @@ -132,7 +129,7 @@ export default { this.isLoading = false; window.scrollTo({ top: 0, behavior: 'smooth' }); - createFlash({ message: COMMON_STR.FAILURE }); + createAlert({ message: COMMON_STR.FAILURE }); }); }, fetchAllGroups() { @@ -153,6 +150,18 @@ export default { this.updateGroups(res, Boolean(this.filterGroupsBy)); }); }, + fetchFilteredAndSortedGroups({ filterGroupsBy, sortBy }) { + this.isLoading = true; + + return this.fetchGroups({ + filterGroupsBy, + sortBy, + updatePagination: true, + }).then((res) => { + this.isLoading = false; + this.updateGroups(res, Boolean(filterGroupsBy)); + }); + }, fetchPage({ page, filterGroupsBy, sortBy, archived }) { this.isLoading = true; @@ -218,7 +227,7 @@ export default { if (err.status === 403) { message = COMMON_STR.LEAVE_FORBIDDEN; } - createFlash({ message }); + createAlert({ message }); this.targetGroup.isBeingRemoved = false; }); }, @@ -245,7 +254,7 @@ export default { const hasGroups = groups && groups.length > 0; if (this.renderEmptyState) { - this.isSearchEmpty = this.filterGroupsBy !== null && !hasGroups; + this.isSearchEmpty = fromSearch && !hasGroups; } else { this.isSearchEmpty = !hasGroups; } @@ -280,7 +289,6 @@ export default { v-else :groups="groups" :search-empty="isSearchEmpty" - :search-empty-message="searchEmptyMessage" :page-info="pageInfo" :action="action" /> diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 3a05c308a2a..43aa0753082 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,11 +1,18 @@ <script> +import { GlEmptyState } from '@gitlab/ui'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { getParameterByName } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; import eventHub from '../event_hub'; export default { + i18n: { + emptyStateTitle: __('No results found'), + emptyStateDescription: __('Edit your search and try again'), + }, components: { PaginationLinks, + GlEmptyState, }, props: { groups: { @@ -20,10 +27,6 @@ export default { type: Boolean, required: true, }, - searchEmptyMessage: { - type: String, - required: true, - }, action: { type: String, required: false, @@ -43,12 +46,11 @@ export default { <template> <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container"> - <div + <gl-empty-state v-if="searchEmpty" - class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5" - > - {{ searchEmptyMessage }} - </div> + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDescription" + /> <template v-else> <group-folder :groups="groups" :action="action" /> <pagination-links diff --git a/app/assets/javascripts/groups/components/new_top_level_group_alert.vue b/app/assets/javascripts/groups/components/new_top_level_group_alert.vue new file mode 100644 index 00000000000..c6af6cdb59f --- /dev/null +++ b/app/assets/javascripts/groups/components/new_top_level_group_alert.vue @@ -0,0 +1,40 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + name: 'NewTopLevelGroupAlert', + components: { + GlAlert, + UserCalloutDismisser, + }, + i18n: { + titleText: s__("Groups|You're creating a new top-level group"), + bodyText: s__( + 'Groups|Members, projects, trials, and paid subscriptions are tied to a specific top-level group. If you are already a member of a top-level group, you can create a subgroup so your new work is part of your existing top-level group. Do you want to create a subgroup instead?', + ), + primaryBtnText: s__('Groups|Learn more about subgroups'), + }, + subgroupsDocsPath: helpPagePath('user/group/subgroups/index'), +}; +</script> + +<template> + <user-callout-dismisser feature-name="new_top_level_group_alert"> + <template #default="{ dismiss, shouldShowCallout }"> + <gl-alert + v-if="shouldShowCallout" + ref="newTopLevelAlert" + data-testid="new-top-level-alert" + :title="$options.i18n.titleText" + :primary-button-text="$options.i18n.primaryBtnText" + :primary-button-link="$options.subgroupsDocsPath" + @dismiss="dismiss" + > + {{ $options.i18n.bodyText }} + </gl-alert> + </template> + </user-callout-dismisser> +</template> diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 325e42af0f8..d0c5846ac88 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -1,58 +1,77 @@ <script> -import { GlTabs, GlTab } from '@gitlab/ui'; -import { isString } from 'lodash'; +import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@gitlab/ui'; +import { isString, debounce } from 'lodash'; import { __ } from '~/locale'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; import GroupsStore from '../store/groups_store'; import GroupsService from '../service/groups_service'; import { ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED, + OVERVIEW_TABS_SORTING_ITEMS, } from '../constants'; +import eventHub from '../event_hub'; import GroupsApp from './app.vue'; +const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS; + export default { - components: { GlTabs, GlTab, GroupsApp }, - inject: ['endpoints'], + components: { GlTabs, GlTab, GroupsApp, GlSearchBoxByType, GlSorting, GlSortingItem }, + inject: ['endpoints', 'initialSort'], data() { + const tabs = [ + { + title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], + key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + renderEmptyState: true, + lazy: this.$route.name !== ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + store: new GroupsStore({ showSchemaMarkup: true }), + }, + { + title: this.$options.i18n[ACTIVE_TAB_SHARED], + key: ACTIVE_TAB_SHARED, + renderEmptyState: false, + lazy: this.$route.name !== ACTIVE_TAB_SHARED, + service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), + store: new GroupsStore(), + }, + { + title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], + key: ACTIVE_TAB_ARCHIVED, + renderEmptyState: false, + lazy: this.$route.name !== ACTIVE_TAB_ARCHIVED, + service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), + store: new GroupsStore(), + }, + ]; return { - tabs: [ - { - title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], - key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, - renderEmptyState: true, - lazy: false, - service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), - store: new GroupsStore({ showSchemaMarkup: true }), - }, - { - title: this.$options.i18n[ACTIVE_TAB_SHARED], - key: ACTIVE_TAB_SHARED, - renderEmptyState: false, - lazy: true, - service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), - store: new GroupsStore(), - }, - { - title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], - key: ACTIVE_TAB_ARCHIVED, - renderEmptyState: false, - lazy: true, - service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), - store: new GroupsStore(), - }, - ], - activeTabIndex: 0, + tabs, + activeTabIndex: tabs.findIndex((tab) => tab.key === this.$route.name), + sort: SORTING_ITEM_NAME, + isAscending: true, + search: '', }; }, + computed: { + activeTab() { + return this.tabs[this.activeTabIndex]; + }, + sortQueryStringValue() { + return this.isAscending ? this.sort.asc : this.sort.desc; + }, + }, mounted() { - const activeTabIndex = this.tabs.findIndex((tab) => tab.key === this.$route.name); - - if (activeTabIndex === -1) { - return; - } + this.search = this.$route.query?.filter || ''; - this.activeTabIndex = activeTabIndex; + const sortQueryStringValue = this.$route.query?.sort || this.initialSort; + const sort = + OVERVIEW_TABS_SORTING_ITEMS.find((sortOption) => + [sortOption.asc, sortOption.desc].includes(sortQueryStringValue), + ) || SORTING_ITEM_NAME; + this.sort = sort; + this.isAscending = sort.asc === sortQueryStringValue; }, methods: { handleTabInput(tabIndex) { @@ -72,14 +91,64 @@ export default { ? this.$route.params.group.split('/') : this.$route.params.group; - this.$router.push({ name: tab.key, params: { group: groupParam } }); + this.$router.push({ name: tab.key, params: { group: groupParam }, query: this.$route.query }); + }, + handleSearchOrSortChange() { + // Update query string + const query = {}; + if (this.sortQueryStringValue !== this.initialSort) { + query.sort = this.isAscending ? this.sort.asc : this.sort.desc; + } + if (this.search) { + query.filter = this.search; + } + this.$router.push({ query }); + + // Reset `lazy` prop so that groups/projects are fetched with updated `sort` and `filter` params when switching tabs + this.tabs.forEach((tab, index) => { + if (index === this.activeTabIndex) { + return; + } + // eslint-disable-next-line no-param-reassign + tab.lazy = true; + }); + + // Update data + eventHub.$emit(`${this.activeTab.key}fetchFilteredAndSortedGroups`, { + filterGroupsBy: this.search, + sortBy: this.sortQueryStringValue, + }); + }, + handleSortDirectionChange() { + this.isAscending = !this.isAscending; + + this.handleSearchOrSortChange(); + }, + handleSortingItemClick(sortingItem) { + if (sortingItem === this.sort) { + return; + } + + this.sort = sortingItem; + + this.handleSearchOrSortChange(); + }, + handleSearchInput(value) { + this.search = value; + + this.debouncedSearch(); }, + debouncedSearch: debounce(async function debouncedSearch() { + this.handleSearchOrSortChange(); + }, DEBOUNCE_DELAY), }, i18n: { [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: __('Subgroups and projects'), [ACTIVE_TAB_SHARED]: __('Shared projects'), [ACTIVE_TAB_ARCHIVED]: __('Archived projects'), + searchPlaceholder: __('Search'), }, + OVERVIEW_TABS_SORTING_ITEMS, }; </script> @@ -99,5 +168,37 @@ export default { :render-empty-state="renderEmptyState" /> </gl-tab> + <template #tabs-end> + <li class="gl-flex-grow-1 gl-align-self-center gl-w-full gl-lg-w-auto gl-py-2"> + <div class="gl-lg-display-flex gl-justify-content-end gl-mx-n2 gl-my-n2"> + <div class="gl-p-2 gl-lg-form-input-md gl-w-full"> + <gl-search-box-by-type + :value="search" + :placeholder="$options.i18n.searchPlaceholder" + data-qa-selector="groups_filter_field" + @input="handleSearchInput" + /> + </div> + <div class="gl-p-2 gl-w-full gl-lg-w-auto"> + <gl-sorting + class="gl-w-full" + dropdown-class="gl-w-full" + data-testid="group_sort_by_dropdown" + :text="sort.label" + :is-ascending="isAscending" + @sortDirectionChange="handleSortDirectionChange" + > + <gl-sorting-item + v-for="sortingItem in $options.OVERVIEW_TABS_SORTING_ITEMS" + :key="sortingItem.label" + :active="sortingItem === sort" + @click="handleSortingItemClick(sortingItem)" + >{{ sortingItem.label }}</gl-sorting-item + > + </gl-sorting> + </div> + </div> + </li> + </template> </gl-tabs> </template> diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index 7e7282a27b0..e28459811d7 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -2,7 +2,7 @@ import { GlFormGroup } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; export const i18n = { confirmationMessage: __( diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 223c2975c11..6fb12cd6270 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -24,8 +24,6 @@ export const COMMON_STR = { EDIT_BTN_TITLE: s__('GroupsTree|Edit'), REMOVE_BTN_TITLE: s__('GroupsTree|Delete'), OPTIONS_DROPDOWN_TITLE: s__('GroupsTree|Options'), - GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'), - GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'), }; export const ITEM_TYPE = { @@ -62,3 +60,26 @@ export const VISIBILITY_TYPE_ICON = { [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock', }; + +export const OVERVIEW_TABS_SORTING_ITEMS = [ + { + label: __('Name'), + asc: 'name_asc', + desc: 'name_desc', + }, + { + label: __('Created'), + asc: 'created_asc', + desc: 'created_desc', + }, + { + label: __('Updated'), + asc: 'latest_activity_asc', + desc: 'latest_activity_desc', + }, + { + label: __('Stars'), + asc: 'stars_asc', + desc: 'stars_desc', + }, +]; diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index 4fa3682c729..664d07ca13d 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -51,6 +51,7 @@ export const initGroupOverviewTabs = () => { subgroupsAndProjectsEndpoint, sharedProjectsEndpoint, archivedProjectsEndpoint, + initialSort, } = el.dataset; return new Vue({ @@ -70,6 +71,7 @@ export const initGroupOverviewTabs = () => { [ACTIVE_TAB_SHARED]: sharedProjectsEndpoint, [ACTIVE_TAB_ARCHIVED]: archivedProjectsEndpoint, }, + initialSort, }, render(createElement) { return createElement(OverviewTabs); diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue index 28f059fa23e..db8e424e166 100644 --- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__, n__ } from '~/locale'; import { getSubGroups } from '../api/access_dropdown_api'; import { LEVEL_TYPES } from '../constants'; @@ -98,7 +98,7 @@ export default { this.consolidateData(groupsResponse.data); this.setSelected({ initial }); }) - .catch(() => createFlash({ message: __('Failed to load groups.') })) + .catch(() => createAlert({ message: __('Failed to load groups.') })) .finally(() => { this.initialLoading = false; this.loading = false; diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 64bba91eb4d..34e984a9bb9 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,21 +1,11 @@ import $ from 'jquery'; import { escape } from 'lodash'; +import { groupsPath } from '~/vue_shared/components/group_select/utils'; import { __ } from '~/locale'; import Api from './api'; import { loadCSSFile } from './lib/utils/css_utils'; import { select2AxiosTransport } from './lib/utils/select2_utils'; -const groupsPath = (groupsFilter, parentGroupID) => { - switch (groupsFilter) { - case 'descendant_groups': - return Api.descendantGroupsPath.replace(':id', parentGroupID); - case 'subgroups': - return Api.subgroupsPath.replace(':id', parentGroupID); - default: - return Api.groupsPath; - } -}; - const groupsSelect = () => { loadCSSFile(gon.select2_css_path) .then(() => { diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index f4b939fb20f..8fc0ce48e61 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -14,6 +14,7 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { truncate } from '~/lib/utils/text_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; import { FIRST_DROPDOWN_INDEX, @@ -163,8 +164,17 @@ export default { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; - this.isFocused = true; - this.$emit('expandSearchBar', 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: 'top_navigation', + }); + } }, closeDropdown() { this.showDropdown = false; @@ -178,6 +188,11 @@ export default { this.showDropdown = false; this.isFocused = false; this.$emit('collapseSearchBar'); + + Tracking.event(undefined, 'blur_input', { + label: 'global_search', + property: 'top_navigation', + }); }, 200); }, submitSearch() { diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index c184e25f67f..00059d01308 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -23,7 +23,12 @@ export default { <template> <div class="d-flex align-items-center"> - <ci-icon is-borderless :status="job.status" :size="24" class="d-flex" /> + <ci-icon + is-borderless + :status="job.status" + :size="24" + class="gl-align-items-center gl-border gl-display-inline-flex gl-z-index-1" + /> <span class="gl-ml-3"> {{ job.name }} <a diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 2284ffb8480..4d8c62d3430 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { __ } from '~/locale'; import Item from './item.vue'; export default { @@ -10,7 +10,6 @@ export default { components: { GlIcon, GlBadge, - CiIcon, Item, GlLoadingIcon, }, @@ -27,11 +26,15 @@ export default { }, computed: { collapseIcon() { - return this.stage.isCollapsed ? 'chevron-lg-left' : 'chevron-lg-down'; + return this.stage.isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'; }, showLoadingIcon() { return this.stage.isLoading && !this.stage.jobs.length; }, + stageTitle() { + const prefix = __('Stage'); + return `${prefix}: ${this.stage.name}`; + }, jobsCount() { return this.stage.jobs.length; }, @@ -57,29 +60,29 @@ export default { <template> <div class="ide-stage card gl-mt-3"> <div - ref="cardHeader" :class="{ 'border-bottom-0': stage.isCollapsed, }" - class="card-header" + class="card-header gl-align-items-center gl-cursor-pointer gl-display-flex" + data-testid="card-header" @click="toggleCollapsed" > - <ci-icon :status="stage.status" :size="24" /> <strong ref="stageTitle" v-gl-tooltip="showTooltip" :title="showTooltip ? stage.name : null" data-container="body" - class="gl-ml-3 text-truncate" + class="gl-text-truncate" + data-testid="stage-title" > - {{ stage.name }} + {{ stageTitle }} </strong> <div v-if="!stage.isLoading || stage.jobs.length" class="gl-mr-3 gl-ml-2"> <gl-badge>{{ jobsCount }}</gl-badge> </div> - <gl-icon :name="collapseIcon" class="ide-stage-collapse-icon" /> + <gl-icon :name="collapseIcon" class="gl-absolute gl-right-5" /> </div> - <div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0"> + <div v-show="!stage.isCollapsed" class="card-body p-0" data-testid="job-list"> <gl-loading-icon v-if="showLoadingIcon" size="sm" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 9684bf8f18c..dbfaeba9708 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal, GlButton } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; import { modalTypes } from '../../constants'; import { trimPathComponents, getPathParent } from '../../utils'; @@ -77,7 +77,7 @@ export default { if (this.modalType === modalTypes.rename) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { - createFlash({ + createAlert({ message: sprintf(__('The name "%{name}" is already taken in this directory.'), { name: this.entryName, }), diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index a1396995a3b..5f35dbdc5e7 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -11,7 +11,7 @@ import { import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import SourceEditor from '~/editor/source_editor'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import ModelManager from '~/ide/lib/common/model_manager'; import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options'; import { __ } from '~/locale'; @@ -239,7 +239,7 @@ export default { this.createEditorInstance(); }) .catch((err) => { - createFlash({ + createAlert({ message: __('Error setting up editor. Please try again.'), fadeTransition: false, addBodyClass: true, @@ -331,7 +331,7 @@ export default { useLivePreviewExtension(); }) .catch((e) => - createFlash({ + createAlert({ message: e, }), ); diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 10e9f6a9488..1a191f6f76f 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -99,7 +99,9 @@ export function startIde(options) { return; } - if (gon.features?.vscodeWebIde) { + const useNewWebIde = parseBoolean(ideElement.dataset.useNewWebIde); + + if (useNewWebIde) { initGitlabWebIDE(ideElement); } else { resetServiceWorkersPublicPath(); diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index a061da38d4f..140f2895a29 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -7,8 +7,7 @@ export const initGitlabWebIDE = async (el) => { const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); // what: Pull what we need from the element. We will replace it soon. - const { path_with_namespace: projectPath } = JSON.parse(el.dataset.project); - const { cspNonce: nonce, branchName: ref } = el.dataset; + const { cspNonce: nonce, branchName: ref, projectPath } = el.dataset; // what: Clean up the element, but preserve id. // why: This way we don't inherit any `ide-loading` side-effects. This diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index b22e58a376d..dc0f3a1d7e9 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,6 +1,6 @@ import { escape } from 'lodash'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -36,7 +36,7 @@ export const createTempEntry = ( const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (getters.entryExists(name)) { - createFlash({ + createAlert({ message: sprintf(__('The name "%{name}" is already taken in this directory.'), { name: name.split('/').pop(), }), @@ -281,7 +281,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = if (e.response.status === 404) { reject(e); } else { - createFlash({ + createAlert({ message: __('Error loading branch data. Please try again.'), fadeTransition: false, addBodyClass: true, diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index f3f603d4ae9..cd8088bf667 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants'; import service from '../../services'; @@ -34,7 +34,7 @@ export const getMergeRequestsForBranch = ( } }) .catch((e) => { - createFlash({ + createAlert({ message: __(`Error fetching merge requests for ${branchId}`), fadeTransition: false, addBodyClass: true, @@ -233,7 +233,7 @@ export const openMergeRequest = async ( await dispatch('openMergeRequestChanges', changes); } catch (e) { - createFlash({ message: __('Error while loading the merge request. Please try again.') }); + createAlert({ message: __('Error while loading the merge request. Please try again.') }); throw e; } }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 37a405e3fac..7a6a267e7d0 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; import { logError } from '~/lib/logger'; import api from '~/api'; @@ -11,7 +11,7 @@ const ERROR_LOADING_PROJECT = __('Error loading project data. Please try again.' const errorFetchingData = (e) => { logError(ERROR_LOADING_PROJECT, e); - createFlash({ + createAlert({ message: ERROR_LOADING_PROJECT, fadeTransition: false, addBodyClass: true, @@ -51,7 +51,7 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) }); }) .catch((e) => { - createFlash({ + createAlert({ message: __('Error loading last commit.'), fadeTransition: false, addBodyClass: true, diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 2ff71523b1b..cbc6e0fe519 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { addNumericSuffix } from '~/ide/utils'; import { sprintf, __ } from '~/locale'; import { leftSidebarViews } from '../../../constants'; @@ -143,7 +143,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo commit(types.UPDATE_LOADING, false); if (!data.short_id) { - createFlash({ + createAlert({ message: data.message, fadeTransition: false, addBodyClass: true, diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js index 82d9300d30b..91868132a5a 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import * as terminalService from '../../../../services/terminals'; @@ -26,7 +26,7 @@ export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => { }; export const receiveStartSessionError = ({ dispatch }) => { - createFlash({ message: messages.UNEXPECTED_ERROR_STARTING }); + createAlert({ message: messages.UNEXPECTED_ERROR_STARTING }); dispatch('killSession'); }; @@ -59,7 +59,7 @@ export const receiveStopSessionSuccess = ({ dispatch }) => { }; export const receiveStopSessionError = ({ dispatch }) => { - createFlash({ message: messages.UNEXPECTED_ERROR_STOPPING }); + createAlert({ message: messages.UNEXPECTED_ERROR_STOPPING }); dispatch('killSession'); }; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js index 7fe1a8cc2df..4aa0768d394 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as messages from '../messages'; import * as types from '../mutation_types'; @@ -42,7 +42,7 @@ export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => { }; export const receiveSessionStatusError = ({ dispatch }) => { - createFlash({ message: messages.UNEXPECTED_ERROR_STATUS }); + createAlert({ message: messages.UNEXPECTED_ERROR_STATUS }); dispatch('killSession'); }; diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index a7e6506b045..83a3d7f2ac3 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,5 +1,6 @@ import { flatten, isString } from 'lodash'; import { languages } from 'monaco-editor'; +import { setDiagnosticsOptions as yamlDiagnosticsOptions } from 'monaco-yaml'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { SIDE_LEFT, SIDE_RIGHT } from './constants'; @@ -82,17 +83,16 @@ export function registerLanguages(def, ...defs) { } export function registerSchema(schema, options = {}) { - const defaults = [languages.json.jsonDefaults, languages.yaml.yamlDefaults]; - defaults.forEach((d) => - d.setDiagnosticsOptions({ - validate: true, - enableSchemaRequest: true, - hover: true, - completion: true, - schemas: [schema], - ...options, - }), - ); + const defaultOptions = { + validate: true, + enableSchemaRequest: true, + hover: true, + completion: true, + schemas: [schema], + ...options, + }; + languages.json.jsonDefaults.setDiagnosticsOptions(defaultOptions); + yamlDiagnosticsOptions(defaultOptions); } export const otherSide = (side) => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT); diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 98ee858ca91..0cdd64b1b98 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -12,7 +12,7 @@ import { GlFormCheckbox, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__, __, n__, sprintf } from '~/locale'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; @@ -150,6 +150,10 @@ export default { }, groupsTableData() { + if (!this.availableNamespaces) { + return []; + } + return this.groups.map((group) => { const importTarget = this.getImportTarget(group); const status = this.getStatus(group); @@ -232,6 +236,10 @@ export default { version: this.bulkImportSourceGroups.versionValidation.features.sourceInstanceVersion, }); }, + + pageInfo() { + return this.bulkImportSourceGroups?.pageInfo ?? {}; + }, }, watch: { @@ -342,7 +350,7 @@ export default { variables: { importRequests }, }); } catch (error) { - createFlash({ + createAlert({ message: i18n.ERROR_IMPORT, captureError: true, error, @@ -503,6 +511,7 @@ export default { permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }), popoverOptions: { title: __('What is listed here?') }, i18n, + LOCAL_STORAGE_KEY: 'gl-bulk-imports-status-page-size-v1', }; </script> @@ -696,14 +705,15 @@ export default { /> </template> </gl-table> - <pagination-bar - v-if="hasGroups" - :page-info="bulkImportSourceGroups.pageInfo" - class="gl-mt-3" - @set-page="setPage" - @set-page-size="setPageSize" - /> </template> </template> + <pagination-bar + v-show="!$apollo.loading && hasGroups" + :page-info="pageInfo" + class="gl-mt-3" + :storage-key="$options.LOCAL_STORAGE_KEY" + @set-page="setPage" + @set-page-size="setPageSize" + /> </div> </template> diff --git a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js index ba0f2bb947a..6ad5e448a40 100644 --- a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js +++ b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { s__ } from '~/locale'; @@ -15,7 +15,7 @@ export class StatusPoller { statuses.forEach((status) => updateImportStatus(status)); }, errorCallback: () => - createFlash({ + createAlert({ message: s__('BulkImport|Update of import statuses with realtime changes failed'), }), }); diff --git a/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue b/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue new file mode 100644 index 00000000000..a8fdf9b9ec5 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue @@ -0,0 +1,51 @@ +<script> +import { GlAccordion, GlAccordionItem, GlAlert, GlForm, GlFormCheckbox } from '@gitlab/ui'; + +export default { + components: { + GlAccordion, + GlAccordionItem, + GlAlert, + GlForm, + GlFormCheckbox, + }, + props: { + stages: { + required: true, + type: Array, + }, + value: { + required: true, + type: Object, + }, + isInitiallyExpanded: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> +<template> + <gl-accordion :header-level="3"> + <gl-accordion-item + :title="s__('ImportProjects|Advanced import settings')" + :visible="isInitiallyExpanded" + > + <gl-alert variant="warning" class="gl-mb-5" :dismissible="false">{{ + s__('ImportProjects|The more information you select, the longer it will take to import') + }}</gl-alert> + <gl-form> + <gl-form-checkbox + v-for="{ name, label, details } in stages" + :key="name" + :checked="value[name]" + @change="$emit('input', { ...value, [name]: $event })" + > + {{ label }} + <template v-if="details" #help>{{ details }} </template> + </gl-form-checkbox> + </gl-form> + </gl-accordion-item> + </gl-accordion> +</template> 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 848c7361601..97a7ed4bf55 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 @@ -9,10 +9,12 @@ import { import { mapActions, mapState, mapGetters } from 'vuex'; import { n__, __, sprintf } from '~/locale'; import ProviderRepoTableRow from './provider_repo_table_row.vue'; +import AdvancedSettings from './advanced_settings.vue'; export default { name: 'ImportProjectsTable', components: { + AdvancedSettings, ProviderRepoTableRow, GlLoadingIcon, GlButton, @@ -35,6 +37,24 @@ export default { required: false, default: false, }, + optionalStages: { + type: Array, + required: false, + default: () => [], + }, + isAdvancedSettingsPanelInitiallyExpanded: { + type: Boolean, + required: false, + default: true, + }, + }, + + data() { + return { + optionalStagesSelection: Object.fromEntries( + this.optionalStages.map(({ name }) => [name, false]), + ), + }; }, computed: { @@ -127,7 +147,7 @@ export default { modal-id="import-all-modal" :title="s__('ImportProjects|Import repositories')" :ok-title="__('Import')" - @ok="importAll" + @ok="importAll({ optionalStages: optionalStagesSelection })" > {{ n__( @@ -150,6 +170,13 @@ export default { /> </form> </div> + <advanced-settings + v-if="optionalStages && optionalStages.length" + v-model="optionalStagesSelection" + :stages="optionalStages" + :is-initially-expanded="isAdvancedSettingsPanelInitiallyExpanded" + 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"> @@ -171,6 +198,7 @@ export default { :repo="repo" :available-namespaces="namespaces" :user-namespace="defaultTargetNamespace" + :optional-stages="optionalStagesSelection" /> </template> </tbody> 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 e4090a378e1..458e0fb1cb1 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 @@ -43,6 +43,10 @@ export default { type: Array, required: true, }, + optionalStages: { + type: Object, + required: true, + }, }, computed: { @@ -177,7 +181,7 @@ export default { v-if="isImportNotStarted" type="button" data-qa-selector="import_button" - @click="fetchImport(repo.importSource.id)" + @click="fetchImport({ repoId: repo.importSource.id, optionalStages })" > {{ importButtonText }} </gl-button> diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 5146a0eb461..4daa9e8a1b8 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -42,6 +42,7 @@ export function initPropsFromElement(element) { providerTitle: element.dataset.provider, filterable: parseBoolean(element.dataset.filterable), paginatable: parseBoolean(element.dataset.paginatable), + optionalStages: JSON.parse(element.dataset.optionalStages), }; } diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index 92be028b8a9..a30c14f9d28 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -43,11 +43,14 @@ const restartJobsPolling = () => { const setImportTarget = ({ commit }, { repoId, importTarget }) => commit(types.SET_IMPORT_TARGET, { repoId, importTarget }); -const importAll = ({ state, dispatch }) => { +const importAll = ({ state, dispatch }, config = {}) => { return Promise.all( - state.repositories - .filter(isProjectImportable) - .map((r) => dispatch('fetchImport', r.importSource.id)), + state.repositories.filter(isProjectImportable).map((r) => + dispatch('fetchImport', { + repoId: r.importSource.id, + optionalStages: config?.optionalStages, + }), + ), ); }; @@ -73,7 +76,7 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) if (hasRedirectInError(e)) { redirectToUrlInError(e); } else if (tooManyRequests(e)) { - createFlash({ + createAlert({ message: sprintf(s__('ImportProjects|%{provider} rate limit exceeded. Try again later'), { provider: capitalizeFirstCharacter(provider), }), @@ -81,7 +84,7 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) commit(types.RECEIVE_REPOS_ERROR); } else { - createFlash({ + createAlert({ message: sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { provider, }), @@ -92,7 +95,10 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) }); }; -const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, getters }, repoId) => { +const fetchImportFactory = (importPath = isRequired()) => ( + { state, commit, getters }, + { repoId, optionalStages }, +) => { const { ciCdOnly } = state; const importTarget = getters.getImportTarget(repoId); @@ -105,6 +111,7 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett ci_cd_only: ciCdOnly, new_name: newName, target_namespace: targetNamespace, + ...(Object.keys(optionalStages).length ? { optional_stages: optionalStages } : {}), }) .then(({ data }) => { commit(types.RECEIVE_IMPORT_SUCCESS, { @@ -124,7 +131,7 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett ) : s__('ImportProjects|Importing the project failed'); - createFlash({ + createAlert({ message: flashMessage, }); @@ -149,7 +156,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d if (hasRedirectInError(e)) { redirectToUrlInError(e); } else { - createFlash({ + createAlert({ message: s__('ImportProjects|Update of imported projects with realtime changes failed'), }); } @@ -177,7 +184,7 @@ const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) = commit(types.RECEIVE_NAMESPACES_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), ) .catch(() => { - createFlash({ + createAlert({ message: s__('ImportProjects|Requesting namespaces failed'), }); diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 437bcc39886..2806b785816 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -91,3 +91,5 @@ export const placeholderForType = { [INTEGRATION_TYPE_SLACK]: __('#general, #development'), [INTEGRATION_TYPE_MATTERMOST]: __('my-channel'), }; + +export const INTEGRATION_FORM_TYPE_SLACK = 'gitlab_slack_application'; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 7a6f1a953a8..15f76c16516 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -14,12 +14,14 @@ import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, + INTEGRATION_FORM_TYPE_SLACK, integrationLevels, integrationFormSectionComponents, billingPlanNames, } from '~/integrations/constants'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import csrf from '~/lib/utils/csrf'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; import ConfirmationModal from './confirmation_modal.vue'; @@ -65,6 +67,7 @@ export default { GlModal: GlModalDirective, SafeHtml, }, + mixins: [glFeatureFlagsMixin()], inject: { helpHtml: { default: '', @@ -101,6 +104,9 @@ export default { return Boolean(this.isSaving || this.isResetting || this.isTesting); }, hasSections() { + if (this.hasSlackNotificationsDisabled) { + return false; + } return this.customState.sections.length !== 0; }, fieldsWithoutSection() { @@ -108,6 +114,24 @@ export default { ? this.propsSource.fields.filter((field) => !field.section) : this.propsSource.fields; }, + hasFieldsWithoutSection() { + if (this.hasSlackNotificationsDisabled) { + return false; + } + return this.fieldsWithoutSection.length; + }, + isSlackIntegration() { + return this.propsSource.type === INTEGRATION_FORM_TYPE_SLACK; + }, + hasSlackNotificationsDisabled() { + return this.isSlackIntegration && !this.glFeatures.integrationSlackAppNotifications; + }, + showHelpHtml() { + if (this.isSlackIntegration) { + return this.helpHtml; + } + return !this.hasSections && this.helpHtml; + }, }, methods: { ...mapActions(['setOverride', 'requestJiraIssueTypes']), @@ -227,6 +251,31 @@ export default { @change="setOverride" /> + <section v-if="showHelpHtml" class="gl-lg-display-flex gl-justify-content-end gl-mb-6"> + <!-- helpHtml is trusted input --> + <div + v-safe-html:[$options.helpHtmlConfig]="helpHtml" + data-testid="help-html" + class="gl-flex-basis-two-thirds" + ></div> + </section> + + <section v-if="!hasSections" class="gl-lg-display-flex gl-justify-content-end"> + <div class="gl-flex-basis-two-thirds"> + <active-checkbox + v-if="propsSource.showActive" + :key="`${currentKey}-active-checkbox`" + @toggle-integration-active="onToggleIntegrationState" + /> + <trigger-fields + v-if="propsSource.triggerEvents.length" + :key="`${currentKey}-trigger-fields`" + :events="propsSource.triggerEvents" + :type="propsSource.type" + /> + </div> + </section> + <template v-if="hasSections"> <div v-for="(section, index) in customState.sections" @@ -234,8 +283,8 @@ export default { :class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }" data-testid="integration-section" > - <div class="row"> - <div class="col-lg-4"> + <section class="gl-lg-display-flex"> + <div class="gl-flex-basis-third gl-mr-4"> <h4 class="gl-mt-0"> {{ section.title }}<gl-badge @@ -253,7 +302,7 @@ export default { <p v-safe-html="section.description"></p> </div> - <div class="col-lg-8"> + <div class="gl-flex-basis-two-thirds"> <component :is="$options.integrationFormSectionComponents[section.type]" :fields="fieldsForSection(section)" @@ -262,28 +311,12 @@ export default { @request-jira-issue-types="onRequestJiraIssueTypes" /> </div> - </div> + </section> </div> </template> - <div class="row"> - <div class="col-lg-4"></div> - - <div class="col-lg-8"> - <!-- helpHtml is trusted input --> - <div v-if="helpHtml && !hasSections" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> - - <active-checkbox - v-if="propsSource.showActive && !hasSections" - :key="`${currentKey}-active-checkbox`" - @toggle-integration-active="onToggleIntegrationState" - /> - <trigger-fields - v-if="propsSource.triggerEvents.length && !hasSections" - :key="`${currentKey}-trigger-fields`" - :events="propsSource.triggerEvents" - :type="propsSource.type" - /> + <section v-if="hasFieldsWithoutSection" class="gl-lg-display-flex gl-justify-content-end"> + <div class="gl-flex-basis-two-thirds"> <dynamic-field v-for="field in fieldsWithoutSection" :key="`${currentKey}-${field.name}`" @@ -292,12 +325,12 @@ export default { :data-qa-selector="`${field.name}_div`" /> </div> - </div> + </section> - <div v-if="isEditable" class="row"> - <div :class="hasSections ? 'col' : 'col-lg-8 offset-lg-4'"> + <section v-if="isEditable" :class="!hasSections && 'gl-lg-display-flex gl-justify-content-end'"> + <div :class="!hasSections && 'gl-flex-basis-two-thirds'"> <div - class="footer-block row-content-block gl-display-flex gl-justify-content-space-between" + class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between" > <div> <template v-if="isInstanceOrGroupLevel"> @@ -359,6 +392,6 @@ export default { </template> </div> </div> - </div> + </section> </gl-form> </template> diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js deleted file mode 100644 index 243d82f55aa..00000000000 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ /dev/null @@ -1,56 +0,0 @@ -import $ from 'jquery'; -import { loadCSSFile } from '../lib/utils/css_utils'; - -let instanceCount = 0; - -class AutoWidthDropdownSelect { - constructor(selectElement) { - this.$selectElement = $(selectElement); - this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`; - instanceCount += 1; - } - - init() { - const { dropdownClass } = this; - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); - }) - .catch(() => {}); - }) - .catch(() => {}); - - return this; - } - - static selectOptions(dropdownClass) { - return { - dropdownCss() { - let resultantWidth = 'auto'; - const $dropdown = $(`.${dropdownClass}`); - - // We have to look at the parent because - // `offsetParent` on a `display: none;` is `null` - const offsetParentWidth = $(this).parent().offsetParent().width(); - // Reset any width to let it naturally flow - $dropdown.css('width', 'auto'); - if ($dropdown.outerWidth(false) > offsetParentWidth) { - resultantWidth = offsetParentWidth; - } - - return { - width: resultantWidth, - maxWidth: offsetParentWidth, - }; - }, - }; - } -} - -export default AutoWidthDropdownSelect; diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_dropdown.vue index 9509399e91d..ba94932289e 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_dropdown.vue @@ -1,10 +1,9 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; -import { ISSUE_STATUS_SELECT_OPTIONS } from '../constants'; +import { statusDropdownOptions } from '../constants'; export default { - name: 'StatusSelect', components: { GlDropdown, GlDropdownItem, @@ -36,7 +35,7 @@ export default { dropdownTitle: __('Change status'), defaultDropdownText: __('Select status'), }, - ISSUE_STATUS_SELECT_OPTIONS, + statusDropdownOptions, }; </script> <template> @@ -44,7 +43,7 @@ export default { <input type="hidden" name="update[state_event]" :value="selectedValue" /> <gl-dropdown :text="dropdownText" :title="$options.i18n.dropdownTitle" class="gl-w-full"> <gl-dropdown-item - v-for="statusOption in $options.ISSUE_STATUS_SELECT_OPTIONS" + v-for="statusOption in $options.statusDropdownOptions" :key="statusOption.value" :is-checked="selectedValue === statusOption.value" is-check-item diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue b/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue new file mode 100644 index 00000000000..8774b065c22 --- /dev/null +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue @@ -0,0 +1,51 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { subscriptionsDropdownOptions } from '../constants'; + +export default { + subscriptionsDropdownOptions, + i18n: { + defaultDropdownText: __('Select subscription'), + headerText: __('Change subscription'), + }, + components: { + GlDropdown, + GlDropdownItem, + }, + data() { + return { + subscription: undefined, + }; + }, + computed: { + dropdownText() { + return this.subscription?.text ?? this.$options.i18n.defaultDropdownText; + }, + selectedValue() { + return this.subscription?.value; + }, + }, + methods: { + handleClick(option) { + this.subscription = option.value === this.subscription?.value ? undefined : option; + }, + }, +}; +</script> +<template> + <div> + <input type="hidden" name="update[subscription_event]" :value="selectedValue" /> + <gl-dropdown class="gl-w-full" :header-text="$options.i18n.headerText" :text="dropdownText"> + <gl-dropdown-item + v-for="subscriptionsOption in $options.subscriptionsDropdownOptions" + :key="subscriptionsOption.value" + is-check-item + :is-checked="selectedValue === subscriptionsOption.value" + @click="handleClick(subscriptionsOption)" + > + {{ subscriptionsOption.text }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js index ad15b25f9cf..68133ceb3c7 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js @@ -1,17 +1,23 @@ import { __ } from '~/locale'; -export const ISSUE_STATUS_MODIFIERS = { - REOPEN: 'reopen', - CLOSE: 'close', -}; - -export const ISSUE_STATUS_SELECT_OPTIONS = [ +export const statusDropdownOptions = [ { - value: ISSUE_STATUS_MODIFIERS.REOPEN, text: __('Open'), + value: 'reopen', }, { - value: ISSUE_STATUS_MODIFIERS.CLOSE, text: __('Closed'), + value: 'close', + }, +]; + +export const subscriptionsDropdownOptions = [ + { + text: __('Subscribe'), + value: 'subscribe', + }, + { + text: __('Unsubscribe'), + value: 'unsubscribe', }, ]; diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js index 967996b859e..4657771353f 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; -import StatusSelect from './components/status_select.vue'; +import StatusDropdown from './components/status_dropdown.vue'; +import SubscriptionsDropdown from './components/subscriptions_dropdown.vue'; import issuableBulkUpdateActions from './issuable_bulk_update_actions'; import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; @@ -14,8 +15,8 @@ export function initBulkUpdateSidebar(prefixId) { new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new } -export function initIssueStatusSelect() { - const el = document.querySelector('.js-issue-status'); +export function initStatusDropdown() { + const el = document.querySelector('.js-status-dropdown'); if (!el) { return null; @@ -23,7 +24,21 @@ export function initIssueStatusSelect() { return new Vue({ el, - name: 'StatusSelectRoot', - render: (createElement) => createElement(StatusSelect), + name: 'StatusDropdownRoot', + render: (createElement) => createElement(StatusDropdown), + }); +} + +export function initSubscriptionsDropdown() { + const el = document.querySelector('.js-subscriptions-dropdown'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'SubscriptionsDropdownRoot', + render: (createElement) => createElement(SubscriptionsDropdown), }); } diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js index 8a55176fed0..a33c6ae8030 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -5,7 +5,6 @@ import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; import MilestoneSelect from '~/milestones/milestone_select'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; -import subscriptionSelect from './subscription_select'; const HIDDEN_CLASS = 'hidden'; const DISABLED_CONTENT_CLASS = 'disabled-content'; @@ -52,7 +51,6 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); new MilestoneSelect(); - subscriptionSelect(); // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js deleted file mode 100644 index b12ac776b4f..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js +++ /dev/null @@ -1,28 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { __ } from '~/locale'; - -export default function subscriptionSelect() { - $('.js-subscription-event').each((i, element) => { - const fieldName = $(element).data('fieldName'); - - return initDeprecatedJQueryDropdown($(element), { - selectable: true, - fieldName, - toggleLabel(selected, el, instance) { - let label = __('Subscription'); - const $item = instance.dropdown.find('.is-active'); - if ($item.length) { - label = $item.text(); - } - return label; - }, - clicked(options) { - return options.e.preventDefault(); - }, - id(obj, el) { - return $(el).data('id'); - }, - }); - }); -} diff --git a/app/assets/javascripts/issuable/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js index 37001d00a27..8c2e2a5df67 100644 --- a/app/assets/javascripts/issuable/issuable_context.js +++ b/app/assets/javascripts/issuable/issuable_context.js @@ -1,7 +1,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; -import { loadCSSFile } from '~/lib/utils/css_utils'; import UsersSelect from '~/users_select'; export default class IssuableContext { @@ -9,24 +8,6 @@ export default class IssuableContext { this.userSelect = new UsersSelect(currentUser); this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search'); - const $select2 = $('select.select2'); - - if ($select2.length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - $select2.select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); - }) - .catch(() => {}); - }) - .catch(() => {}); - } - $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); }); diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 81bf7ca6ccc..e8ba99e0e9e 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -2,10 +2,7 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import Autosave from '~/autosave'; -import AutoWidthDropdownSelect from '~/issuable/auto_width_dropdown_select'; -import { loadCSSFile } from '~/lib/utils/css_utils'; import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; -import { select2AxiosTransport } from '~/lib/utils/select2_utils'; import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import ZenMode from '~/zen_mode'; @@ -118,12 +115,6 @@ export default class IssuableForm { }); calendar.setDate(parsePikadayDate($issuableDueDate.val())); } - - this.$targetBranchSelect = $('.js-target-branch-select', this.form); - - if (this.$targetBranchSelect.length) { - this.initTargetBranchDropdown(); - } } initAutosave() { @@ -214,47 +205,4 @@ export default class IssuableForm { addWip() { this.titleField.val(`Draft: ${this.titleField.val()}`); } - - initTargetBranchDropdown() { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results({ results }) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: results[Object.keys(results)[0]].map((name) => ({ - id: name, - text: name, - })), - }; - }, - transport: select2AxiosTransport, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, - }); - }, - }); - }) - .catch(() => {}); - }) - .catch(() => {}); - } } diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 0b424d105b9..acb6aa93f0f 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -247,8 +247,8 @@ export default { }, defaultWorkItemTypes() { return this.isWorkItemsEnabled - ? defaultWorkItemTypes.concat(WORK_ITEM_TYPE_ENUM_TASK) - : defaultWorkItemTypes; + ? defaultWorkItemTypes + : defaultWorkItemTypes.filter((type) => type !== WORK_ITEM_TYPE_ENUM_TASK); }, typeTokenOptions() { return this.isWorkItemsEnabled @@ -563,7 +563,8 @@ export default { if (!this.hasInitBulkEdit) { const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar'); bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); - bulkUpdateSidebar.initIssueStatusSelect(); + bulkUpdateSidebar.initStatusDropdown(); + bulkUpdateSidebar.initSubscriptionsDropdown(); const usersSelect = await import('~/users_select'); const UsersSelect = usersSelect.default; diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 27738d7a3e6..9fe8899ab39 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -7,11 +7,13 @@ import { FILTER_UPCOMING, OPERATOR_IS, OPERATOR_IS_NOT, + TOKEN_TYPE_HEALTH, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, WORK_ITEM_TYPE_ENUM_ISSUE, WORK_ITEM_TYPE_ENUM_TEST_CASE, + WORK_ITEM_TYPE_ENUM_TASK, } from '~/work_items/constants'; export const i18n = { @@ -147,14 +149,16 @@ export const TOKEN_TYPE_EPIC = 'epic_id'; export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_CONTACT = 'crm_contact'; export const TOKEN_TYPE_ORGANIZATION = 'crm_organization'; -export const TOKEN_TYPE_HEALTH = 'health_status'; -export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' }; +export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' }; +// This should be consistent with Issue::TYPES_FOR_LIST in the backend +// https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/models/issue.rb#L48 export const defaultWorkItemTypes = [ WORK_ITEM_TYPE_ENUM_ISSUE, WORK_ITEM_TYPE_ENUM_INCIDENT, WORK_ITEM_TYPE_ENUM_TEST_CASE, + WORK_ITEM_TYPE_ENUM_TASK, ]; export const defaultTypeTokenOptions = [ @@ -327,10 +331,12 @@ export const filters = { [TOKEN_TYPE_HEALTH]: { [API_PARAM]: { [NORMAL_FILTER]: 'healthStatus', + [SPECIAL_FILTER]: 'healthStatus', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'health_status', + [SPECIAL_FILTER]: 'health_status', }, }, }, diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 4c5f783cd66..5138a4530e9 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,10 +1,11 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlSprintf } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { TimeAgoTooltip, + GlSprintf, }, props: { updatedAt: { @@ -33,13 +34,27 @@ export default { <template> <small class="edited-text js-issue-widgets"> - Edited - <time-ago-tooltip v-if="updatedAt" :time="updatedAt" tooltip-placement="bottom" /> - <span v-if="hasUpdatedBy"> - by - <a :href="updatedByPath" class="author-link"> - <span>{{ updatedByName }}</span> - </a> - </span> + <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> </small> </template> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index c2ab7c4f298..dbe634e7295 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,13 +1,16 @@ <script> import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import updateMixin from '../../mixins/update'; export default { components: { MarkdownField, + MarkdownEditor, }, - mixins: [updateMixin], + mixins: [updateMixin, glFeaturesFlagMixin()], props: { value: { type: String, @@ -38,7 +41,12 @@ export default { }, }, mounted() { - this.$refs.textarea.focus(); + this.focus(); + }, + methods: { + focus() { + this.$refs.textarea?.focus(); + }, }, }; </script> @@ -46,7 +54,26 @@ export default { <template> <div class="common-note-form"> <label class="sr-only" for="issue-description">{{ __('Description') }}</label> + <markdown-editor + v-if="glFeatures.contentEditorOnIssues" + class="gl-mt-3" + :value="value" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :form-field-aria-label="__('Description')" + :form-field-placeholder="__('Write a comment or drag your files here…')" + form-field-id="issue-description" + form-field-name="issue-description" + :quick-actions-docs-path="quickActionsDocsPath" + :enable-autocomplete="enableAutocomplete" + supports-quick-actions + init-on-autofocus + @input="$emit('input', $event)" + @keydown.meta.enter="updateIssuable" + @keydown.ctrl.enter="updateIssuable" + /> <markdown-field + v-else :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index f479c8ae78d..0c6b61fb893 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,7 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import $ from 'jquery'; -import Autosave from '~/autosave'; +import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; import { IssuableType } from '~/issues/constants'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; @@ -76,10 +75,17 @@ export default { }, }, data() { + const autosaveKey = [document.location.pathname, document.location.search]; + const descriptionAutosaveKey = [...autosaveKey, 'description']; + const titleAutosaveKey = [...autosaveKey, 'title']; + return { + titleAutosaveKey, + descriptionAutosaveKey, + autosaveReset: false, formData: { - title: this.formState.title, - description: this.formState.description, + title: getDraft(titleAutosaveKey) || this.formState.title, + description: getDraft(descriptionAutosaveKey) || this.formState.description, }, showOutdatedDescriptionWarning: false, }; @@ -118,58 +124,40 @@ export default { }, methods: { initAutosave() { - const { - description: { - $refs: { textarea }, - }, - title: { - $refs: { input }, - }, - } = this.$refs; - - this.autosaveDescription = new Autosave( - $(textarea), - [document.location.pathname, document.location.search, 'description'], - null, - this.formState.lock_version, - ); - - const savedLockVersion = this.autosaveDescription.getSavedLockVersion(); + const savedLockVersion = getLockVersion(this.descriptionAutosaveKey); this.showOutdatedDescriptionWarning = savedLockVersion && String(this.formState.lock_version) !== savedLockVersion; - - this.autosaveTitle = new Autosave($(input), [ - document.location.pathname, - document.location.search, - 'title', - ]); }, resetAutosave() { - this.autosaveDescription.reset(); - this.autosaveTitle.reset(); + this.autosaveReset = true; + clearDraft(this.descriptionAutosaveKey); + clearDraft(this.titleAutosaveKey); }, keepAutosave() { - const { - description: { - $refs: { textarea }, - }, - } = this.$refs; - - textarea.focus(); + this.$refs.description.focus(); this.showOutdatedDescriptionWarning = false; }, discardAutosave() { - const { - description: { - $refs: { textarea }, - }, - } = this.$refs; - - textarea.value = this.initialDescriptionText; - textarea.focus(); + this.formData.description = this.initialDescriptionText; + clearDraft(this.descriptionAutosaveKey); + this.$refs.description.focus(); this.showOutdatedDescriptionWarning = false; }, + updateTitleDraft(title) { + updateDraft(this.titleAutosaveKey, title); + }, + updateDescriptionDraft(description) { + /* + * This conditional statement prevents a race-condition + * between clearing the draft and submitting a new draft + * update while the user is typing. It happens when saving + * using the cmd + enter keyboard shortcut. + */ + if (!this.autosaveReset) { + updateDraft(this.descriptionAutosaveKey, description, this.formState.lock_version); + } + }, }, }; </script> @@ -194,7 +182,7 @@ export default { > <div class="row gl-mb-3"> <div class="col-12"> - <issuable-title-field ref="title" v-model="formData.title" /> + <issuable-title-field ref="title" v-model="formData.title" @input="updateTitleDraft" /> </div> </div> <div class="row"> @@ -220,6 +208,7 @@ export default { :markdown-docs-path="markdownDocsPath" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" + @input="updateDescriptionDraft" /> <edit-actions :endpoint="endpoint" :form-state="formState" :issuable-type="issuableType" /> diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index adf449aca7b..74d166f82bb 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -229,7 +229,7 @@ export default { </script> <template> - <div class="detail-page-header-actions gl-display-flex"> + <div class="detail-page-header-actions gl-display-flex gl-align-self-start"> <gl-dropdown v-if="hasMobileDropdown" class="gl-sm-display-none! w-100" diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index dd84a1d7d67..5725d0f8d6a 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -52,9 +52,6 @@ export default { loading() { return this.$apollo.queries.alert.loading; }, - incidentTabEnabled() { - return this.glFeatures.incidentTimeline; - }, }, mounted() { this.trackPageViews(); @@ -112,7 +109,7 @@ export default { > <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> - <timeline-tab v-if="incidentTabEnabled" /> + <timeline-tab /> </gl-tabs> </div> </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 b7ae18372ab..55cd8b5f606 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 @@ -74,6 +74,9 @@ export default { return utcDate.toISOString(); }, + hasTimelineText() { + return this.timelineText.length > 0; + }, }, mounted() { this.focusDate(); @@ -167,6 +170,8 @@ export default { variant="confirm" category="primary" class="gl-mr-3" + data-testid="save-button" + :disabled="!hasTimelineText" :loading="isEventProcessed" @click="handleSave(false)" > @@ -177,6 +182,8 @@ export default { variant="confirm" category="secondary" class="gl-mr-3 gl-ml-n2" + data-testid="save-and-add-button" + :disabled="!hasTimelineText" :loading="isEventProcessed" @click="handleSave(true)" > diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index e5eed9f6b79..3cb5007ab0d 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -60,6 +60,7 @@ export function initIncidentApp(issueData = {}) { projectId, slaFeatureAvailable: parseBoolean(slaFeatureAvailable), uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), + contentEditorOnIssues: gon.features.contentEditorOnIssues, }, render(createElement) { return createElement(IssueApp, { diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index 853834ed51d..f73241aed6b 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -1,5 +1,4 @@ import { s__, __ } from '~/locale'; -import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; /* Error constants */ export const POST_FAILURE = 'post_failure'; @@ -29,62 +28,46 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); /* Table constants */ - -const defaultTableClasses = { - tdClass: 'gl-p-5!', - thClass: DEFAULT_TH_CLASSES, -}; -// eslint-disable-next-line @gitlab/require-i18n-strings -const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; - export const DEFAULT_FIELDS = [ { key: 'status', label: __('Status'), - ...defaultTableClasses, columnClass: 'gl-w-10p', }, { key: 'job', label: __('Job'), - ...defaultTableClasses, columnClass: 'gl-w-20p', }, { key: 'pipeline', label: __('Pipeline'), - ...defaultTableClasses, columnClass: 'gl-w-10p', }, { key: 'stage', label: __('Stage'), - ...defaultTableClasses, columnClass: 'gl-w-10p', }, { key: 'name', label: __('Name'), - ...defaultTableClasses, columnClass: 'gl-w-15p', }, { key: 'duration', label: __('Duration'), - ...defaultTableClasses, columnClass: 'gl-w-15p', }, { key: 'coverage', label: __('Coverage'), - tdClass: coverageTdClasses, - thClass: defaultTableClasses.thClass, + tdClass: 'gl-display-none! gl-lg-display-table-cell!', columnClass: 'gl-w-10p', }, { key: 'actions', label: '', - ...defaultTableClasses, columnClass: 'gl-w-10p', }, ]; 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 0a4757d11a8..3209fc4b90d 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -1,7 +1,7 @@ <script> import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; import { validateQueryString } from '../filtered_search/utils'; @@ -134,7 +134,7 @@ export default { // when a user enters raw text we alert them that it is // not supported and we do not make an additional API call if (!filter.type) { - createFlash({ + createAlert({ message: RAW_TEXT_WARNING, type: 'warning', }); diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 927ba7c7e1e..272181f830c 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -100,7 +100,7 @@ export const receiveJobSuccess = ({ commit }, data = {}) => { }; export const receiveJobError = ({ commit }) => { commit(types.RECEIVE_JOB_ERROR); - createFlash({ + createAlert({ message: __('An error occurred while fetching the job.'), }); resetFavicon(); @@ -205,14 +205,14 @@ export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JO export const receiveJobLogError = ({ dispatch }) => { dispatch('stopPollingJobLog'); - createFlash({ + createAlert({ message: __('An error occurred while fetching the job log.'), }); }; export const receiveJobLogUnauthorizedError = ({ dispatch }) => { dispatch('stopPollingJobLog'); - createFlash({ + createAlert({ message: __('The current user is not authorized to access the job log.'), }); }; @@ -254,7 +254,7 @@ export const receiveJobsForStageSuccess = ({ commit }, data) => export const receiveJobsForStageError = ({ commit }) => { commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR); - createFlash({ + createAlert({ message: __('An error occurred while fetching the jobs.'), }); }; @@ -271,7 +271,7 @@ export const triggerManualJob = ({ state }, variables) => { job_variables_attributes: parsedVariables, }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while triggering the job.'), }), ); diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue index 8598500c842..1b99a094c48 100644 --- a/app/assets/javascripts/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/labels/components/promote_label_modal.vue @@ -1,6 +1,6 @@ <script> import { GlSprintf, GlModal } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; @@ -70,7 +70,7 @@ export default { labelUrl: this.url, successful: false, }); - createFlash({ + createAlert({ message: error, }); }); diff --git a/app/assets/javascripts/labels/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js index ea69e6585e6..c4f80d32a83 100644 --- a/app/assets/javascripts/labels/group_label_subscription.js +++ b/app/assets/javascripts/labels/group_label_subscription.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; import { fixTitle, hide } from '~/tooltips'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; const tooltipTitles = { @@ -31,7 +31,7 @@ export default class GroupLabelSubscription { this.$unsubscribeButtons.removeAttr('data-url'); }) .catch(() => - createFlash({ + createAlert({ message: __('There was an error when unsubscribing from this label.'), }), ); @@ -50,7 +50,7 @@ export default class GroupLabelSubscription { .then(() => GroupLabelSubscription.setNewTooltip($btn)) .then(() => this.toggleSubscriptionButtons()) .catch(() => - createFlash({ + createAlert({ message: __('There was an error when subscribing to this label.'), }), ); diff --git a/app/assets/javascripts/labels/label_manager.js b/app/assets/javascripts/labels/label_manager.js index 1927ac6e1ec..be515869bff 100644 --- a/app/assets/javascripts/labels/label_manager.js +++ b/app/assets/javascripts/labels/label_manager.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; import { dispose } from '~/tooltips'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -112,7 +112,7 @@ export default class LabelManager { onPrioritySortUpdate() { this.savePrioritySort().catch(() => - createFlash({ + createAlert({ message: this.errorMessage, }), ); @@ -127,7 +127,7 @@ export default class LabelManager { rollbackLabelPosition($label, originalAction) { const action = originalAction === 'remove' ? 'add' : 'remove'; this.toggleLabelPriority($label, action, false); - createFlash({ + createAlert({ message: this.errorMessage, }); } diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 51fedac339b..65dda804a20 100644 --- a/app/assets/javascripts/labels/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -6,7 +6,7 @@ import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import IssuableBulkUpdateActions from '~/issuable/bulk_update_sidebar/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { sprintf, __ } from '~/locale'; import CreateLabelDropdown from './create_label_dropdown'; @@ -146,7 +146,7 @@ export default class LabelsSelect { }); }) .catch(() => - createFlash({ + createAlert({ message: __('Error saving label update.'), }), ); @@ -185,7 +185,7 @@ export default class LabelsSelect { } }) .catch(() => - createFlash({ + createAlert({ message: __('Error fetching labels.'), }), ); diff --git a/app/assets/javascripts/labels/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js index b2612e9ede0..9ca6ee5609c 100644 --- a/app/assets/javascripts/labels/project_label_subscription.js +++ b/app/assets/javascripts/labels/project_label_subscription.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { fixTitle } from '~/tooltips'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -61,7 +61,7 @@ export default class ProjectLabelSubscription { }); }) .catch(() => - createFlash({ + createAlert({ message: __('There was an error subscribing to this label.'), }), ); diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 6f24590f9e7..27760e483aa 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -3,12 +3,21 @@ import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/util const { sanitize: dompurifySanitize, addHook, isValidAttribute } = DOMPurify; -const defaultConfig = { +export const defaultConfig = { // Safely allow SVG <use> tags ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], // Prevent possible XSS attacks with data-* attributes used by @rails/ujs // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421 - FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'], + FORBID_ATTR: [ + 'data-remote', + 'data-url', + 'data-type', + 'data-method', + 'data-disable-with', + 'data-disabled', + 'data-disable', + 'data-turbo', + ], FORBID_TAGS: ['style', 'mstyle'], ALLOW_UNKNOWN_PROTOCOLS: true, }; diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js index dac1da743a2..01316be06a2 100644 --- a/app/assets/javascripts/lib/utils/autosave.js +++ b/app/assets/javascripts/lib/utils/autosave.js @@ -1,8 +1,27 @@ +import { isString } from 'lodash'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +const normalizeKey = (autosaveKey) => { + let normalizedKey; + + if (Array.isArray(autosaveKey) && autosaveKey.every(isString)) { + normalizedKey = autosaveKey.join('/'); + } else if (isString(autosaveKey)) { + normalizedKey = autosaveKey; + } else { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Invalid autosave key'); + } + + return `autosave/${normalizedKey}`; +}; + +const lockVersionKey = (autosaveKey) => `${normalizeKey(autosaveKey)}/lockVersion`; + export const clearDraft = (autosaveKey) => { try { - window.localStorage.removeItem(`autosave/${autosaveKey}`); + window.localStorage.removeItem(normalizeKey(autosaveKey)); + window.localStorage.removeItem(lockVersionKey(autosaveKey)); } catch (e) { // eslint-disable-next-line no-console console.error(e); @@ -11,7 +30,17 @@ export const clearDraft = (autosaveKey) => { export const getDraft = (autosaveKey) => { try { - return window.localStorage.getItem(`autosave/${autosaveKey}`); + return window.localStorage.getItem(normalizeKey(autosaveKey)); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return null; + } +}; + +export const getLockVersion = (autosaveKey) => { + try { + return window.localStorage.getItem(lockVersionKey(autosaveKey)); } catch (e) { // eslint-disable-next-line no-console console.error(e); @@ -19,9 +48,12 @@ export const getDraft = (autosaveKey) => { } }; -export const updateDraft = (autosaveKey, text) => { +export const updateDraft = (autosaveKey, text, lockVersion) => { try { - window.localStorage.setItem(`autosave/${autosaveKey}`, text); + window.localStorage.setItem(normalizeKey(autosaveKey), text); + if (lockVersion) { + window.localStorage.setItem(lockVersionKey(autosaveKey), lockVersion); + } } catch (e) { // eslint-disable-next-line no-console console.error(e); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 7925a10344a..4448a106bb6 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -60,6 +60,15 @@ export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventNa }); }; +/** + * Return the given element's offset height, or 0 if the element doesn't exist. + * Probably not useful outside of handleLocationHash. + * + * @param {HTMLElement} element The element to measure. + * @returns {number} The element's offset height. + */ +const getElementOffsetHeight = (element) => element?.offsetHeight ?? 0; + // automatically adjust scroll position for hash urls taking the height of the navbar into account // https://github.com/twitter/bootstrap/issues/1768 export const handleLocationHash = () => { @@ -84,40 +93,26 @@ export const handleLocationHash = () => { const fixedIssuableTitle = document.querySelector('.issue-sticky-header'); let adjustment = 0; - if (fixedNav) adjustment -= fixedNav.offsetHeight; - - if (target && target.scrollIntoView) { - target.scrollIntoView(true); - } - if (fixedTabs) { - adjustment -= fixedTabs.offsetHeight; - } - - if (fixedDiffStats) { - adjustment -= fixedDiffStats.offsetHeight; - } - - if (performanceBar) { - adjustment -= performanceBar.offsetHeight; - } - - if (diffFileHeader) { - adjustment -= diffFileHeader.offsetHeight; - } - - if (versionMenusContainer) { - adjustment -= versionMenusContainer.offsetHeight; - } + adjustment -= getElementOffsetHeight(fixedNav); + adjustment -= getElementOffsetHeight(fixedTabs); + adjustment -= getElementOffsetHeight(fixedDiffStats); + adjustment -= getElementOffsetHeight(performanceBar); + adjustment -= getElementOffsetHeight(diffFileHeader); + adjustment -= getElementOffsetHeight(versionMenusContainer); if (isInIssuePage()) { - adjustment -= fixedIssuableTitle.offsetHeight; + adjustment -= getElementOffsetHeight(fixedIssuableTitle); } if (isInMRPage()) { adjustment -= topPadding; } + if (target?.scrollIntoView) { + target.scrollIntoView(true); + } + setTimeout(() => { window.scrollBy(0, adjustment); }); @@ -172,7 +167,7 @@ export const contentTop = () => { return size; }, - () => getOuterHeight('.merge-request-tabs'), + () => getOuterHeight('.merge-request-sticky-header, .merge-request-tabs'), () => getOuterHeight('.js-diff-files-changed'), () => getOuterHeight('.issue-sticky-header.gl-fixed'), ({ desktop }) => { @@ -180,7 +175,9 @@ export const contentTop = () => { let size; if (desktop && diffsTabIsActive) { - size = getOuterHeight('.diff-file .file-title-flex-parent:not([style="display:none"])'); + size = getOuterHeight( + '.diffs .diff-file .file-title-flex-parent:not([style="display:none"])', + ); } return size; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 6c5d4ecc901..c11cf1a7882 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -271,24 +271,6 @@ export const secondsToMilliseconds = (seconds) => seconds * 1000; export const secondsToDays = (seconds) => Math.round(seconds / 86400); /** - * Converts a numeric utc offset in seconds to +/- hours - * ie -32400 => -9 hours - * ie -12600 => -3.5 hours - * - * @param {Number} offset UTC offset in seconds as a integer - * - * @return {String} the + or - offset in hours - */ -export const secondsToHours = (offset) => { - const parsed = parseInt(offset, 10); - if (Number.isNaN(parsed) || parsed === 0) { - return `0`; - } - const num = offset / 3600; - return parseInt(num, 10) !== num ? num.toFixed(1) : num; -}; - -/** * Returns the date `n` days after the date provided * * @param {Date} date the initial date diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index d07abb72210..737c18d1bce 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -406,3 +406,29 @@ export const durationTimeFormatted = (duration) => { return `${hh}:${mm}:${ss}`; }; + +/** + * Converts a numeric utc offset in seconds to +/- hours + * ie -32400 => -9 hours + * ie -12600 => -3.5 hours + * + * @param {Number} offset UTC offset in seconds as a integer + * + * @return {String} the + or - offset in hours, e.g. `- 10`, `0`, `+ 4` + */ +export const formatUtcOffset = (offset) => { + const parsed = parseInt(offset, 10); + if (Number.isNaN(parsed) || parsed === 0) { + return `0`; + } + const prefix = offset > 0 ? '+' : '-'; + return `${prefix} ${Math.abs(offset / 3600)}`; +}; + +/** + * Returns formatted timezone + * + * @param {Object} timezone item with offset and name + * @returns {String} the UTC timezone with the offset, e.g. `[UTC + 2] Berlin` + */ +export const formatTimezone = ({ offset, name }) => `[UTC ${formatUtcOffset(offset)}] ${name}`; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 48be8af3ff6..3894ec36a0b 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -391,13 +391,15 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo /** * Indents selected lines to the right by 2 spaces * - * @param {Object} textArea - the targeted text area + * @param {Object} textArea - jQuery object with the targeted text area */ -function indentLines(textArea) { +function indentLines($textArea) { + const textArea = $textArea.get(0); const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea); const shiftedLines = []; let totalAdded = 0; + textArea.focus(); textArea.setSelectionRange(startPos, endPos); lines.forEach((line) => { @@ -418,13 +420,15 @@ function indentLines(textArea) { * * @param {Object} textArea - the targeted text area */ -function outdentLines(textArea) { +function outdentLines($textArea) { + const textArea = $textArea.get(0); const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea); const shiftedLines = []; let totalRemoved = 0; let removedFromFirstline = -1; let removedFromLine = 0; + textArea.focus(); textArea.setSelectionRange(startPos, endPos); lines.forEach((line) => { @@ -460,28 +464,10 @@ function outdentLines(textArea) { ); } -function handleIndentOutdent(e, textArea) { - if (e.altKey || e.ctrlKey || e.shiftKey) return; - if (!e.metaKey) return; - - switch (e.key) { - case ']': - e.preventDefault(); - indentLines(textArea); - break; - case '[': - e.preventDefault(); - outdentLines(textArea); - break; - default: - break; - } -} - /* eslint-disable @gitlab/require-i18n-strings */ function handleSurroundSelectedText(e, textArea) { if (!gon.markdown_surround_selection) return; - if (e.metaKey) return; + if (e.metaKey || e.ctrlKey) return; if (textArea.selectionStart === textArea.selectionEnd) return; const keys = { @@ -532,6 +518,7 @@ function continueOlText(listLineMatch, nextLineMatch) { } function handleContinueList(e, textArea) { + if (!gon.markdown_automatic_lists) return; if (!(e.key === 'Enter')) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (textArea.selectionStart !== textArea.selectionEnd) return; @@ -586,7 +573,6 @@ export function keypressNoteText(e) { if ($(textArea).atwho?.('isSelecting')) return; - handleIndentOutdent(e, textArea); handleContinueList(e, textArea); handleSurroundSelectedText(e, textArea); } @@ -600,15 +586,26 @@ export function compositionEndNoteText() { } export function updateTextForToolbarBtn($toolbarBtn) { - return updateText({ - textArea: $toolbarBtn.closest('.md-area').find('textarea'), - tag: $toolbarBtn.data('mdTag'), - cursorOffset: $toolbarBtn.data('mdCursorOffset'), - blockTag: $toolbarBtn.data('mdBlock'), - wrap: !$toolbarBtn.data('mdPrepend'), - select: $toolbarBtn.data('mdSelect'), - tagContent: $toolbarBtn.attr('data-md-tag-content'), - }); + const $textArea = $toolbarBtn.closest('.md-area').find('textarea'); + + switch ($toolbarBtn.data('mdCommand')) { + case 'indentLines': + indentLines($textArea); + break; + case 'outdentLines': + outdentLines($textArea); + break; + default: + return updateText({ + textArea: $textArea, + tag: $toolbarBtn.data('mdTag'), + cursorOffset: $toolbarBtn.data('mdCursorOffset'), + blockTag: $toolbarBtn.data('mdBlock'), + wrap: !$toolbarBtn.data('mdPrepend'), + select: $toolbarBtn.data('mdSelect'), + tagContent: $toolbarBtn.attr('data-md-tag-content'), + }); + } } export function addMarkdownListeners(form) { diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 59645d50e29..367180714df 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,5 +1,5 @@ import { isString, memoize } from 'lodash'; - +import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util'; import { TRUNCATE_WIDTH_DEFAULT_WIDTH, TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, @@ -513,3 +513,15 @@ export const limitedCounterWithDelimiter = (count) => { return count > limit ? '1,000+' : count; }; + +// Encoding UTF8 ⇢ base64 +export function base64EncodeUnicode(str) { + const encoder = new TextEncoder('utf8'); + return bufferToBase64(encoder.encode(str)); +} + +// Decoding base64 ⇢ UTF8 +export function base64DecodeUnicode(str) { + const decoder = new TextDecoder('utf8'); + return decoder.decode(base64ToBuffer(str)); +} diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js index f63171e2785..7eacbf7fcdd 100644 --- a/app/assets/javascripts/listbox/index.js +++ b/app/assets/javascripts/listbox/index.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlListbox } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -31,37 +31,25 @@ export function initListbox(el, { onChange } = {}) { }, }, render(h) { - return h( - GlDropdown, - { - props: { - text: this.text, - right, + return h(GlListbox, { + props: { + items, + right, + selected: this.selected, + toggleText: this.text, + }, + class: className, + on: { + select: (selectedValue) => { + this.selected = selectedValue; + const selectedItem = items.find(({ value }) => value === selectedValue); + + if (typeof onChange === 'function') { + onChange(selectedItem); + } }, - class: className, }, - items.map((item) => - h( - GlDropdownItem, - { - props: { - isCheckItem: true, - isChecked: this.selected === item.value, - }, - on: { - click: () => { - this.selected = item.value; - - if (typeof onChange === 'function') { - onChange(item); - } - }, - }, - }, - item.text, - ), - ), - ); + }); }, }); } diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js index c570f8810a8..ca3f1caec67 100644 --- a/app/assets/javascripts/logo.js +++ b/app/assets/javascripts/logo.js @@ -1,5 +1,5 @@ export default function initLogoAnimation() { window.addEventListener('beforeunload', () => { - document.querySelector('.tanuki-logo').classList.add('animate'); + document.querySelector('.tanuki-logo')?.classList.add('animate'); }); } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c16ed68096d..8e4ebd510aa 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -21,7 +21,7 @@ import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime/timeago_utility'; -import { getLocationHash, visitUrl } from './lib/utils/url_utility'; +import { getLocationHash, visitUrl, mergeUrlParams } from './lib/utils/url_utility'; // everything else import initFeatureHighlight from './feature_highlight'; @@ -250,11 +250,10 @@ $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { const link = document.createElement('a'); link.href = this.action; - const action = `${this.action}${link.search === '' ? '?' : '&'}`; + const action = mergeUrlParams(Object.fromEntries(new FormData(this)), this.action); event.preventDefault(); - // eslint-disable-next-line no-jquery/no-serialize - visitUrl(`${action}${$(this).serialize()}`); + visitUrl(action); }); const flashContainer = document.querySelector('.flash-container'); diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue index ce28283ccdf..01f145e0862 100644 --- a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue +++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue @@ -4,6 +4,7 @@ import { mapState } from 'vuex'; import { visitUrl } from '~/lib/utils/url_utility'; import { FIELDS } from '~/members/constants'; import { parseSortParam, buildSortHref } from '~/members/utils'; +import { SORT_DIRECTION_UI } from '~/search/sort/constants'; export default { name: 'SortDropdown', @@ -30,6 +31,9 @@ export default { isAscending() { return !this.sort.sortDesc; }, + sortDirectionData() { + return this.isAscending ? SORT_DIRECTION_UI.asc : SORT_DIRECTION_UI.desc; + }, filteredOptions() { return FIELDS.filter( (field) => this.tableSortableFields.includes(field.key) && field.sort, @@ -70,7 +74,7 @@ export default { data-testid="members-sort-dropdown" :text="activeOptionLabel" :is-ascending="isAscending" - :sort-direction-tool-tip="__('Sort direction')" + :sort-direction-tool-tip="sortDirectionData.tooltip" @sortDirectionChange="handleSortDirectionChange" > <gl-sorting-item diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 460dc0041ab..0512bc04085 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -2,7 +2,7 @@ import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; import { mapState } from 'vuex'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; -import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; +import { canUnban, canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import UserDate from '~/vue_shared/components/user_date.vue'; import { @@ -90,7 +90,8 @@ export default { canRemove(member) || canResend(member) || canUpdate(member, this.currentUserId) || - canOverride(member) + canOverride(member) || + canUnban(member) ); }, showField(field) { diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 0da44b7d468..bf87ab53d36 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -105,9 +105,12 @@ export const buildSortHref = ({ return setUrlParams({ ...filterParams, sort: sortParam }, window.location.href, true); }; -// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` +// Defined in `ee/app/assets/javascripts/members/utils.js` export const canOverride = () => false; +// Defined in `ee/app/assets/javascripts/members/utils.js` +export const canUnban = () => false; + export const parseDataAttributes = (el) => { const { membersData } = el.dataset; diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue index 7168efa28ad..707e8a0645f 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { debounce } from 'lodash'; import { mapActions } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import { INTERACTIVE_RESOLVE_MODE } from '../constants'; @@ -75,7 +75,7 @@ export default { }, ) .catch(() => { - createFlash({ + createAlert({ message: __('An error occurred while loading the file'), }); }); diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js index 9c101da52f5..f84eaabf9e7 100644 --- a/app/assets/javascripts/merge_conflicts/store/actions.js +++ b/app/assets/javascripts/merge_conflicts/store/actions.js @@ -1,5 +1,5 @@ import { setCookie } from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants'; @@ -33,7 +33,7 @@ export const submitResolvedConflicts = async ({ commit, getters }, resolveConfli window.location.assign(data.redirect_to); } catch (e) { commit(types.SET_SUBMIT_STATE, false); - createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') }); + createAlert({ message: __('Failed to save merge conflicts resolutions. Please try again!') }); } }; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 8cdb9eb5fc4..57b5e9809d2 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,7 +1,7 @@ /* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import eventHub from '~/vue_merge_request_widget/event_hub'; @@ -44,7 +44,7 @@ function MergeRequest(opts) { } }, onError: () => { - createFlash({ + createAlert({ message: __( 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', ), @@ -98,7 +98,7 @@ MergeRequest.prototype.initMRBtnListeners = function () { MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready'); }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); }) diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 0b53a8ede64..17ee2a0d8b6 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,12 +1,12 @@ /* eslint-disable no-new, class-methods-use-this */ import $ from 'jquery'; import Vue from 'vue'; +import { createAlert } from '~/flash'; import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils'; import { parseUrlPathname } from '~/lib/utils/url_utility'; import createEventHub from '~/helpers/event_hub_factory'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import Diff from './diff'; -import createFlash from './flash'; import { initDiffStatsDropdown } from './init_diff_stats_dropdown'; import axios from './lib/utils/axios_utils'; @@ -447,7 +447,7 @@ export default class MergeRequestTabs { .then((m) => m.default()) .catch(() => { toggleLoader(false); - createFlash({ + createAlert({ message: __('An error occurred while fetching this tab.'), }); }); @@ -480,7 +480,7 @@ export default class MergeRequestTabs { this.diffsLoaded = true; }) .catch(() => { - createFlash({ + createAlert({ message: __('An error occurred while fetching this tab.'), }); }) diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index f067982fce1..b7629ba001f 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -86,6 +86,7 @@ export default { <template> <gl-intersection-observer + class="gl-relative gl-top-2" @appear="setStickyHeaderVisible(false)" @disappear="setStickyHeaderVisible(true)" > diff --git a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue index cac6d722ced..9e537fa2c82 100644 --- a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue @@ -1,6 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; @@ -63,7 +63,7 @@ export default { visitUrl(response.data.url); }) .catch((error) => { - createFlash({ + createAlert({ message: error, }); }) diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js index 8f2721c2a5b..d9e72340d62 100644 --- a/app/assets/javascripts/milestones/milestone.js +++ b/app/assets/javascripts/milestones/milestone.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -34,7 +34,7 @@ export default class Milestone { this.loadedTabs.add(tab); }) .catch(() => - createFlash({ + createAlert({ message: __('Error loading milestone tab'), }), ); diff --git a/app/assets/javascripts/milestones/milestone_select.js b/app/assets/javascripts/milestones/milestone_select.js index c95ec3dd10b..d4876c3dbe8 100644 --- a/app/assets/javascripts/milestones/milestone_select.js +++ b/app/assets/javascripts/milestones/milestone_select.js @@ -121,7 +121,7 @@ export default class MilestoneSelect { title: __('Started'), }); } - if (extraOptions.length) { + if (extraOptions.length && data.length) { extraOptions.push({ type: 'divider' }); } diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 5bf08be1ead..2995f19c470 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import { hide } from '~/tooltips'; @@ -120,7 +120,7 @@ export default class MirrorRepos { .put(this.mirrorEndpoint, payload) .then(() => this.removeRow($target)) .catch(() => - createFlash({ + createAlert({ message: __('Failed to remove mirror.'), }), ); diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index eb7c43034a4..3b7e5a5f2ee 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { escape } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -115,7 +115,7 @@ export default class SSHMirror { const failureMessage = response.data ? response.data.message : __('An error occurred while detecting host keys'); - createFlash({ + createAlert({ message: failureMessage, }); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index e3fcdf716d4..b6ad2d21757 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -11,7 +11,7 @@ import { import Mousetrap from 'mousetrap'; import VueDraggable from 'vuedraggable'; import { mapActions, mapState, mapGetters } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import invalidUrl from '~/lib/utils/invalid_url'; import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -176,7 +176,7 @@ export default { this.setExpandedPanel(expandedPanel); } } catch { - createFlash({ + createAlert({ message: s__( 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.', ), @@ -201,7 +201,7 @@ export default { * This watcher is set for future SPA behaviour of the dashboard */ if (hasWarnings) { - createFlash({ + createAlert({ message: s__( 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.', ), @@ -319,7 +319,7 @@ export default { this.isRearrangingPanels = isRearrangingPanels; }, onDateTimePickerInvalid() { - createFlash({ + createAlert({ message: s__( 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', ), diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 5c99dbc0d98..0ef365c6368 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -134,7 +134,7 @@ export const fetchDashboard = ({ state, commit, dispatch, getters }) => { if (state.showErrorBanner) { if (error.response.data && error.response.data.message) { const { message } = error.response.data; - createFlash({ + createAlert({ message: sprintf( s__('Metrics|There was an error while retrieving metrics. %{message}'), { message }, @@ -142,7 +142,7 @@ export const fetchDashboard = ({ state, commit, dispatch, getters }) => { ), }); } else { - createFlash({ + createAlert({ message: s__('Metrics|There was an error while retrieving metrics'), }); } @@ -176,7 +176,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { dispatch('fetchDeploymentsData'); if (!state.timeRange) { - createFlash({ + createAlert({ message: s__(`Metrics|Invalid time range, please verify.`), type: 'warning', }); @@ -207,7 +207,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { }); }) .catch(() => { - createFlash({ + createAlert({ message: s__(`Metrics|There was an error while retrieving metrics`), type: 'warning', }); @@ -246,7 +246,7 @@ export const fetchPrometheusMetric = ( Sentry.captureException(error); commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metricId, error }); - // Continue to throw error so the dashboard can notify using createFlash + // Continue to throw error so the dashboard can notify using createAlert throw error; }); }; @@ -262,7 +262,7 @@ export const fetchDeploymentsData = ({ state, dispatch }) => { .then((resp) => resp.data) .then((response) => { if (!response || !response.deployments) { - createFlash({ + createAlert({ message: s__('Metrics|Unexpected deployment data response from prometheus endpoint'), }); } @@ -272,7 +272,7 @@ export const fetchDeploymentsData = ({ state, dispatch }) => { .catch((error) => { Sentry.captureException(error); dispatch('receiveDeploymentsDataFailure'); - createFlash({ + createAlert({ message: s__('Metrics|There was an error getting deployment information.'), }); }); @@ -302,7 +302,7 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { ) .then((environments) => { if (!environments) { - createFlash({ + createAlert({ message: s__( 'Metrics|There was an error fetching the environments data, please try again', ), @@ -314,7 +314,7 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { .catch((err) => { Sentry.captureException(err); dispatch('receiveEnvironmentsDataFailure'); - createFlash({ + createAlert({ message: s__('Metrics|There was an error getting environments information.'), }); }); @@ -348,7 +348,7 @@ export const fetchAnnotations = ({ state, dispatch, getters }) => { .then(parseAnnotationsResponse) .then((annotations) => { if (!annotations) { - createFlash({ + createAlert({ message: s__('Metrics|There was an error fetching annotations. Please try again.'), }); } @@ -358,7 +358,7 @@ export const fetchAnnotations = ({ state, dispatch, getters }) => { .catch((err) => { Sentry.captureException(err); dispatch('receiveAnnotationsFailure'); - createFlash({ + createAlert({ message: s__('Metrics|There was an error getting annotations information.'), }); }); @@ -397,7 +397,7 @@ export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) = .catch((err) => { Sentry.captureException(err); dispatch('receiveDashboardValidationWarningsFailure'); - createFlash({ + createAlert({ message: s__( 'Metrics|There was an error getting dashboard validation warnings information.', ), @@ -502,7 +502,7 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); }) .catch(() => { - createFlash({ + createAlert({ message: sprintf( s__('Metrics|There was an error getting options for variable "%{name}".'), { @@ -569,7 +569,7 @@ export const fetchPanelPreviewMetrics = ({ state, commit }) => { Sentry.captureException(error); commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE, { index, error }); - // Continue to throw error so the panel builder can notify using createFlash + // Continue to throw error so the panel builder can notify using createAlert throw error; }); }); diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 297420bf94d..c32a1f4c2ac 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -6,7 +6,6 @@ import initDiffsApp from '../diffs'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import MergeRequest from '../merge_request'; import DiscussionCounter from '../notes/components/discussion_counter.vue'; -import initDiscussionFilters from '../notes/discussion_filters'; import initNotesApp from './init_notes'; export default function initMrNotes() { @@ -49,7 +48,5 @@ export default function initMrNotes() { }, }); } - - initDiscussionFilters(store); }); } diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index e4a7a7bd9fc..3a67e7925c3 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -5,19 +5,27 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import store from '~/mr_notes/stores'; import discussionNavigator from '../notes/components/discussion_navigator.vue'; import NotesApp from '../notes/components/notes_app.vue'; +import { getNotesFilterData } from '../notes/utils/get_notes_filter_data'; import initWidget from '../vue_merge_request_widget'; export default () => { + const el = document.getElementById('js-vue-mr-discussions'); + if (!el) { + return; + } + + const notesFilterProps = getNotesFilterData(el); + // eslint-disable-next-line no-new new Vue({ - el: '#js-vue-mr-discussions', + el, name: 'MergeRequestDiscussions', components: { NotesApp, }, store, data() { - const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + const notesDataset = el.dataset; const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; noteableData.targetType = notesDataset.targetType; @@ -95,6 +103,7 @@ export default () => { userData: this.currentUserData, shouldShow: this.isShowTabActive, helpPagePath: this.helpPagePath, + ...notesFilterProps, }, }), ]); diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js index e00c2abfbef..09757ce17fa 100644 --- a/app/assets/javascripts/namespaces/leave_by_url.js +++ b/app/assets/javascripts/namespaces/leave_by_url.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { initRails } from '~/lib/utils/rails_ujs'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; @@ -18,7 +18,7 @@ export default function leaveByUrl(namespaceType) { if (leaveLink) { leaveLink.click(); } else { - createFlash({ + createAlert({ message: sprintf(__('You do not have permission to leave this %{namespaceType}.'), { namespaceType, }), diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index ca6e6567f74..e55bf25a60c 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -1,5 +1,6 @@ <script> import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui'; +import Tracking from '~/tracking'; import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; export default { @@ -19,6 +20,14 @@ export default { required: true, }, }, + methods: { + trackToggleEvent() { + Tracking.event(undefined, 'click_nav', { + label: 'hamburger_menu', + property: 'top_navigation', + }); + }, + }, }; </script> @@ -32,6 +41,7 @@ export default { toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" no-flip no-caret + @toggle="trackToggleEvent" > <template #button-content> <gl-icon name="hamburger" /> diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index fdcea300388..5437a607e8a 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -44,7 +44,7 @@ export default { sandbox :srcdoc="rawCode" frameborder="0" - scrolling="no" + scrolling="auto" width="100%" class="gl-overflow-auto" ></iframe> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index bf35d5c3b25..0d7ff022f8f 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -5,7 +5,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { badgeState } from '~/issuable/components/status_box.vue'; import httpStatusCodes from '~/lib/utils/http_status'; import { @@ -111,7 +111,10 @@ export default { return this.getNoteableData.current_user.can_create_note; }, canSetInternalNote() { - return this.getNoteableData.current_user.can_update && (this.isIssue || this.isEpic); + return ( + this.getNoteableData.current_user.can_create_confidential_note && + (this.isIssue || this.isEpic) + ); }, issueActionButtonTitle() { const openOrClose = this.isOpen ? 'close' : 'reopen'; @@ -276,7 +279,7 @@ export default { .then(() => badgeState.updateStatus && badgeState.updateStatus()) .then(refreshUserMergeRequestCounts) .catch(() => - createFlash({ + createAlert({ message: constants.toggleStateErrorMessage[this.noteableType][this.openState], }), ); diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 1b1923a90f7..cf6474270a2 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -84,8 +84,8 @@ export default { return sprintf(text, { commitDisplay, linkStart, linkEnd }, false); }, - adaptiveAvatarSize() { - return { default: 24, md: 32 }; + toggleClass() { + return this.discussion.expanded ? 'expanded' : 'collapsed'; }, }, methods: { @@ -98,16 +98,13 @@ export default { </script> <template> - <div class="discussion-header gl-display-flex gl-align-items-center gl-p-5"> - <div - v-once - class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-mx-3 gl-md-ml-2 gl-md-mr-5" - > + <div class="discussion-header gl-display-flex gl-align-items-center"> + <div v-once class="timeline-avatar gl-align-self-start gl-flex-shrink-0 gl-flex-shrink"> <gl-avatar-link v-if="author" :href="author.path"> - <gl-avatar :src="author.avatar_url" :alt="author.name" :size="adaptiveAvatarSize" /> + <gl-avatar :src="author.avatar_url" :alt="author.name" :size="32" /> </gl-avatar-link> </div> - <div class="timeline-content w-100"> + <div class="timeline-content w-100 gl-ml-3" :class="toggleClass"> <note-header :author="author" :created-at="firstNote.created_at" @@ -123,14 +120,14 @@ export default { :edited-at="discussion.resolved_at" :edited-by="discussion.resolved_by" :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline gl-pl-2" + class-name="discussion-headline-light js-discussion-headline gl-pl-3" /> <note-edited-text v-else-if="lastUpdatedAt" :edited-at="lastUpdatedAt" :edited-by="lastUpdatedBy" :action-text="__('Last updated')" - class-name="discussion-headline-light js-discussion-headline gl-pl-2" + class-name="discussion-headline-light js-discussion-headline gl-pl-3" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 6521b86edbb..37935e9c3c6 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -81,16 +81,18 @@ export default { :class="{ 'gl-bg-orange-50': blocksMerge && !allResolved, 'gl-bg-gray-50': !blocksMerge || allResolved, - 'gl-pr-2': !allResolved, }" data-testid="discussions-counter-text" > <template v-if="allResolved"> {{ __('All threads resolved!') }} <gl-dropdown + v-gl-tooltip:discussionCounter.hover.bottom size="small" category="tertiary" right + :title="__('Thread options')" + :aria-label="__('Thread options')" toggle-class="btn-icon" class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2" > @@ -133,9 +135,12 @@ export default { @click="jumpNext" /> <gl-dropdown + v-gl-tooltip:discussionCounter.hover.bottom size="small" category="tertiary" right + :title="__('Thread options')" + :aria-label="__('Thread options')" toggle-class="btn-icon" class="gl-pt-0! gl-px-2" > diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 8a42fb6bd85..21b48a2a666 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -168,7 +168,7 @@ export default { id="discussion-preferences-dropdown" class="full-width-mobile" data-qa-selector="discussion_preferences_dropdown" - text="Sort or filter" + :text="__('Sort or filter')" :disabled="isLoading" right > diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index 61af0b06535..39b3df899a5 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -31,7 +31,7 @@ export default { <div class="timeline-icon d-none d-lg-flex"> <gl-icon name="comment" /> </div> - <div class="timeline-content"> + <div class="timeline-content gl-pl-8"> <div data-testid="discussion-filter-timeline-content"> <gl-sprintf :message="$options.i18n.information"> <template #bold="{ content }"> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 6fcfa66ea49..2dbc9b10836 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -142,7 +142,7 @@ export default { :edited-at="discussion.resolved_at" :edited-by="discussion.resolved_by" :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text gl-mb-2 gl-ml-3" /> </template> <template #avatar-badge> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 9806f8e5dc2..930876e90b1 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui import { mapActions, mapGetters, mapState } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; @@ -238,7 +238,7 @@ export default { }) .then(() => this.handleAssigneeUpdate(assignees)) .catch(() => - createFlash({ + createAlert({ message: __('Something went wrong while updating assignees'), }), ); @@ -281,6 +281,7 @@ export default { > {{ __('Contributor') }} </user-access-role-badge> + <span class="note-actions__mobile-spacer"></span> <gl-button v-if="canResolve" ref="resolveButton" diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 835750cc137..9d59994788e 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import AwardsList from '~/vue_shared/components/awards_list.vue'; @@ -49,7 +49,7 @@ export default { }; this.toggleAwardRequest(data).catch(() => - createFlash({ + createAlert({ message: __('Something went wrong on our end.'), }), ); diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index f700802d6bc..f3530344181 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -9,8 +9,6 @@ import { import { mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, @@ -21,13 +19,11 @@ export default { GlIcon, GlBadge, GlLoadingIcon, - UserNameWithStatus, }, directives: { SafeHtml, GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], props: { author: { type: Object, @@ -74,12 +70,15 @@ export default { required: false, default: false, }, + isSystemNote: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { isUsernameLinkHovered: false, - emojiTitle: '', - authorStatusHasTooltip: false, }; }, computed: { @@ -100,15 +99,6 @@ export default { 'js-user-link': true, }; }, - authorStatus() { - if (this.author?.show_status) { - return this.author.status_tooltip_html; - } - return false; - }, - emojiElement() { - return this.$refs?.authorStatus?.querySelector('gl-emoji'); - }, authorName() { return this.author.name; }, @@ -116,14 +106,6 @@ export default { return s__('Notes|This internal note will always remain confidential'); }, }, - mounted() { - this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; - - const authorStatusTitle = this.$refs?.authorStatus - ?.querySelector('.user-status-emoji') - ?.getAttribute('title'); - this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== ''; - }, methods: { ...mapActions(['setTargetNoteHash']), handleToggle() { @@ -134,12 +116,6 @@ export default { this.setTargetNoteHash(this.noteTimestampLink); } }, - removeEmojiTitle() { - this.emojiElement.removeAttribute('title'); - }, - addEmojiTitle() { - this.emojiElement.setAttribute('title', this.emojiTitle); - }, handleUsernameMouseEnter() { this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter')); this.isUsernameLinkHovered = true; @@ -148,9 +124,6 @@ export default { this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave')); this.isUsernameLinkHovered = false; }, - userAvailability(selectedAuthor) { - return selectedAuthor?.availability || ''; - }, }, i18n: { showThread: __('Show thread'), @@ -185,35 +158,11 @@ export default { :data-user-id="author.id" :data-username="author.username" > - <span - v-if="glFeatures.removeUserAttributesProjects || glFeatures.removeUserAttributesGroups" - class="note-header-author-name gl-font-weight-bold" - > + <span class="note-header-author-name gl-font-weight-bold"> {{ authorName }} </span> - <user-name-with-status - v-else - :name="authorName" - :availability="userAvailability(author)" - container-classes="note-header-author-name gl-font-weight-bold" - /> </a> - <span - v-if=" - authorStatus && - !glFeatures.removeUserAttributesProjects && - !glFeatures.removeUserAttributesGroups - " - ref="authorStatus" - v-safe-html:[$options.safeHtmlConfig]="authorStatus" - v-on=" - authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} - " - ></span> - <span - v-if="!glFeatures.removeUserAttributesProjects && !glFeatures.removeUserAttributesGroups" - class="text-nowrap author-username" - > + <span v-if="!isSystemNote" class="text-nowrap author-username"> <a ref="authorUsernameLink" class="author-username-link" @@ -252,7 +201,7 @@ export default { data-testid="internalNoteIndicator" variant="warning" size="sm" - class="gl-mb-3 gl-ml-2" + class="gl-ml-2" :title="internalNoteTooltip" > {{ __('Internal note') }} diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index afa5e39d8b0..50d166b6db5 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import DraftNote from '~/batch_comments/components/draft_note.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -247,7 +247,7 @@ export default { const msg = __( 'Your comment could not be submitted! Please check your network connection and try again.', ); - createFlash({ + createAlert({ message: msg, parent: this.$el, }); diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index e51969f95c7..c4b3111b919 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -5,7 +5,7 @@ import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -199,9 +199,6 @@ export default { isMRDiffView() { return this.line && !this.isOverviewTab; }, - authorAvatarAdaptiveSize() { - return { default: 24, md: 32 }; - }, }, created() { const line = this.note.position?.line_range?.start || this.line; @@ -273,7 +270,7 @@ export default { this.isDeleting = false; }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong while deleting your note. Please try again.'), }); this.isDeleting = false; @@ -352,7 +349,7 @@ export default { }, handleUpdateError() { const msg = __('Something went wrong while editing your comment. Please try again.'); - createFlash({ + createAlert({ message: msg, parent: this.$el, }); @@ -409,13 +406,13 @@ export default { :class="{ ...classNameBindings, 'internal-note': note.internal }" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note note-wrapper" + class="note note-wrapper note-comment" data-qa-selector="noteable_note_container" > <div v-if="showMultiLineComment" data-testid="multiline-comment" - class="gl-mb-5 gl-text-gray-500 gl-border-gray-100 gl-border-b-solid gl-border-b-1 gl-pb-4" + class="gl-text-gray-500 gl-border-gray-100 gl-border-b-solid gl-border-b-1 gl-px-5 gl-py-3" > <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> <template #startLine> @@ -427,7 +424,7 @@ export default { </gl-sprintf> </div> - <div v-if="isMRDiffView" class="gl-float-left gl-mt-n1 gl-mr-3"> + <div v-if="isMRDiffView" class="timeline-avatar gl-float-left gl-pt-2"> <gl-avatar-link :href="author.path"> <gl-avatar :src="author.avatar_url" @@ -440,13 +437,13 @@ export default { </gl-avatar-link> </div> - <div v-else class="gl-float-left gl-pl-3 gl-md-pl-2"> + <div v-else class="timeline-avatar gl-float-left"> <gl-avatar-link :href="author.path"> <gl-avatar :src="author.avatar_url" :entity-name="author.username" :alt="author.name" - :size="authorAvatarAdaptiveSize" + :size="32" /> <slot name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue new file mode 100644 index 00000000000..e4f88962731 --- /dev/null +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -0,0 +1,38 @@ +<script> +import DiscussionFilter from './discussion_filter.vue'; + +export default { + components: { + TimelineToggle: () => import('./timeline_toggle.vue'), + DiscussionFilter, + }, + inject: { + showTimelineViewToggle: { + default: false, + }, + }, + props: { + notesFilters: { + type: Array, + required: true, + }, + notesFilterValue: { + type: Number, + default: undefined, + required: false, + }, + }, +}; +</script> + +<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 gl-mt-5 gl-border-t" + > + <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"> + <timeline-toggle v-if="showTimelineViewToggle" /> + <discussion-filter :filters="notesFilters" :selected-value="notesFilterValue" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 37bc8bad305..9c2ff2c3e7f 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,7 +1,7 @@ <script> import { mapGetters, mapActions } from 'vuex'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; @@ -19,10 +19,12 @@ import DiscussionFilterNote from './discussion_filter_note.vue'; import NoteableDiscussion from './noteable_discussion.vue'; import NoteableNote from './noteable_note.vue'; import SidebarSubscription from './sidebar_subscription.vue'; +import NotesActivityHeader from './notes_activity_header.vue'; export default { name: 'NotesApp', components: { + NotesActivityHeader, NoteableNote, NoteableDiscussion, SystemNote, @@ -46,6 +48,15 @@ export default { type: Object, required: true, }, + notesFilters: { + type: Array, + required: true, + }, + notesFilterValue: { + type: Number, + default: undefined, + required: false, + }, userData: { type: Object, required: false, @@ -221,7 +232,7 @@ export default { .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); - createFlash({ + createAlert({ message: __('Something went wrong while fetching comments. Please try again.'), }); }); @@ -281,6 +292,7 @@ export default { <template> <div v-show="shouldShow" id="notes"> <sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" /> + <notes-activity-header :notes-filters="notesFilters" :notes-filter-value="notesFilterValue" /> <ordered-layout :slot-keys="slotKeys"> <template #form> <comment-form @@ -292,7 +304,11 @@ export default { <template #comments> <ul id="notes-list" class="notes main-notes-list timeline"> <template v-for="discussion in allDiscussions"> - <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" /> + <skeleton-loading-container + v-if="discussion.isSkeletonNote" + :key="discussion.id" + class="note-skeleton" + /> <timeline-entry-item v-else-if="discussion.isDraft" :key="discussion.id"> <draft-note :draft="discussion" /> </timeline-entry-item> @@ -327,7 +343,7 @@ export default { :help-page-path="helpPagePath" /> </template> - <discussion-filter-note v-show="commentsDisabled" /> + <discussion-filter-note v-if="commentsDisabled" /> </ul> </template> </ordered-layout> diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue index 8632eea5d8e..59a3cc2d306 100644 --- a/app/assets/javascripts/notes/components/timeline_toggle.vue +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -53,6 +53,7 @@ export default { :selected="timelineEnabled" :title="tooltip" :aria-label="tooltip" + data-testid="timeline-toggle-button" @click="toggleTimeline" /> </template> diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 2bd3488ae1b..734e08dd586 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -61,7 +61,7 @@ export default { <template> <li :class="liClasses" - class="gl-display-flex! gl-align-items-center gl-flex-wrap gl-bg-gray-10 gl-py-3 gl-px-5 gl-border-t" + class="toggle-replies-widget gl-display-flex! gl-align-items-center gl-flex-wrap gl-bg-gray-10 gl-py-3 gl-px-5 gl-border" > <gl-button ref="toggle" @@ -75,7 +75,7 @@ export default { <user-avatar-link v-for="author in uniqueAuthors" :key="author.username" - class="gl-mr-3" + class="gl-mr-3 reply-author-avatar" :link-href="author.path" :img-alt="author.name" img-css-classes="gl-mr-0!" diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js deleted file mode 100644 index 104e9d4183a..00000000000 --- a/app/assets/javascripts/notes/discussion_filters.js +++ /dev/null @@ -1,34 +0,0 @@ -import Vue from 'vue'; -import DiscussionFilter from './components/discussion_filter.vue'; - -export default (store) => { - const discussionFilterEl = document.getElementById('js-vue-discussion-filter'); - - if (discussionFilterEl) { - const { defaultFilter, notesFilters } = discussionFilterEl.dataset; - const filterValues = notesFilters ? JSON.parse(notesFilters) : {}; - const filters = Object.keys(filterValues).map((entry) => ({ - title: entry, - value: filterValues[entry], - })); - const props = { filters }; - - if (defaultFilter) { - props.selectedValue = parseInt(defaultFilter, 10); - } - - return new Vue({ - el: discussionFilterEl, - name: 'DiscussionFilterRoot', - components: { - DiscussionFilter, - }, - store, - render(createElement) { - return createElement('discussion-filter', { props }); - }, - }); - } - - return null; -}; diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 08792fd1a3f..9b5fd69f816 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -16,7 +16,7 @@ export const COMMENT_FORM = { bodyPlaceholderInternal: __('Write an internal note or drag your files here…'), internal: s__('Notes|Make this an internal note'), internalVisibility: s__( - 'Notes|Internal notes are only visible to the author, assignees, and members with the role of Reporter or higher', + 'Notes|Internal notes are only visible to members with the role of Reporter or higher', ), discussionThatNeedsResolution: __( 'Discuss a specific suggestion or question that needs to be resolved.', diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 054a5bd36e2..defcb0533b7 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,9 +1,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import NotesApp from './components/notes_app.vue'; -import initDiscussionFilters from './discussion_filters'; import { store } from './stores'; -import initTimelineToggle from './timeline'; +import { getNotesFilterData } from './utils/get_notes_filter_data'; export default () => { const el = document.getElementById('js-vue-notes'); @@ -11,6 +10,9 @@ export default () => { return; } + const notesFilterProps = getNotesFilterData(el); + const showTimelineViewToggle = parseBoolean(el.dataset.showTimelineViewToggle); + // eslint-disable-next-line no-new new Vue({ el, @@ -19,6 +21,9 @@ export default () => { NotesApp, }, store, + provide: { + showTimelineViewToggle, + }, data() { const notesDataset = el.dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); @@ -56,11 +61,9 @@ export default () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, + ...notesFilterProps, }, }); }, }); - - initDiscussionFilters(store); - initTimelineToggle(store); }; diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 7b9c0959464..9a140029c07 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -1,7 +1,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils'; import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { clearDraft } from '~/lib/utils/autosave'; import { s__ } from '~/locale'; import { formatLineRange } from '~/notes/components/multiline_comment_utils'; @@ -42,7 +42,7 @@ export default { this.handleClearForm(this.discussion.line_code); }) .catch(() => { - createFlash({ + createAlert({ message: s__('MergeRequests|An error occurred while saving the draft comment.'), }); }); @@ -82,7 +82,7 @@ export default { } }) .catch(() => { - createFlash({ + createAlert({ message: s__('MergeRequests|An error occurred while saving the draft comment.'), }); }); diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index db5f9ebf3f0..d75a4158440 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,136 +1,12 @@ import { mapGetters, mapActions, mapState } from 'vuex'; -import { scrollToElementWithContext, scrollToElement, contentTop } from '~/lib/utils/common_utils'; -import { updateHistory } from '~/lib/utils/url_utility'; -import eventHub from '../event_hub'; - -/** - * @param {string} selector - * @returns {boolean} - */ -function scrollTo(selector, { withoutContext = false, offset = 0 } = {}) { - const el = document.querySelector(selector); - const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext; - - if (el) { - scrollFunction(el, { - behavior: 'auto', - offset, - }); - return true; - } - - return false; -} - -function updateUrlWithNoteId(noteId) { - const newHistoryEntry = { - state: null, - title: window.title, - url: `#note_${noteId}`, - replace: true, - }; - - if (noteId) { - // Temporarily mask the ID to avoid the browser default - // scrolling taking over which is broken with virtual - // scrolling enabled. - const note = document.querySelector(`#note_${noteId}`); - note?.setAttribute('id', `masked::${note.id}`); - - // Update the hash now that the ID "doesn't exist" in the page - updateHistory(newHistoryEntry); - - // Unmask the note's ID - note?.setAttribute('id', `note_${noteId}`); - } -} - -/** - * @param {object} self Component instance with mixin applied - * @param {string} id Discussion id we are jumping to - */ -function diffsJump({ expandDiscussion }, id, firstNoteId) { - const selector = `ul.notes[data-discussion-id="${id}"]`; - - eventHub.$once('scrollToDiscussion', () => { - scrollTo(selector); - // Wait for the discussion scroll before updating to the more specific ID - setTimeout(() => updateUrlWithNoteId(firstNoteId), 0); - }); - expandDiscussion({ discussionId: id }); -} - -/** - * @param {object} self Component instance with mixin applied - * @param {string} id Discussion id we are jumping to - * @returns {boolean} - */ -function discussionJump({ expandDiscussion }, id) { - const selector = `div.discussion[data-discussion-id="${id}"]`; - expandDiscussion({ discussionId: id }); - return scrollTo(selector, { - withoutContext: true, - offset: window.gon?.features?.movedMrSidebar ? -28 : 0, - }); -} - -/** - * @param {object} self Component instance with mixin applied - * @param {string} id Discussion id we are jumping to - */ -function switchToDiscussionsTabAndJumpTo(self, id) { - window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { - setTimeout(() => discussionJump(self, id), 0); - }); - - window.mrTabs.tabShown('show'); -} - -/** - * @param {object} self Component instance with mixin applied - * @param {object} discussion Discussion we are jumping to - */ -function jumpToDiscussion(self, discussion) { - const { id, diff_discussion: isDiffDiscussion, notes } = discussion; - const firstNoteId = notes?.[0]?.id; - if (id) { - const activeTab = window.mrTabs.currentAction; - - if (activeTab === 'diffs' && isDiffDiscussion) { - diffsJump(self, id, firstNoteId); - } else { - switchToDiscussionsTabAndJumpTo(self, id); - } - } -} - -/** - * @param {object} self Component instance with mixin applied - * @param {function} fn Which function used to get the target discussion's id - */ -function handleDiscussionJump(self, fn) { - const isDiffView = window.mrTabs.currentAction === 'diffs'; - const targetId = fn(self.currentDiscussionId, isDiffView); - const discussion = self.getDiscussion(targetId); - const discussionFilePath = discussion?.diff_file?.file_path; - - window.location.hash = ''; - - if (discussionFilePath) { - self.scrollToFile({ - path: discussionFilePath, - }); - } - - self.$nextTick(() => { - jumpToDiscussion(self, discussion); - self.setCurrentDiscussionId(targetId); - }); -} +import { scrollToElement, contentTop } from '~/lib/utils/common_utils'; function getAllDiscussionElements() { + const containerEl = window.mrTabs?.currentAction === 'diffs' ? '.diffs' : '.notes'; return Array.from( - document.querySelectorAll('[data-discussion-id]:not([data-discussion-resolved])'), + document.querySelectorAll( + `${containerEl} div[data-discussion-id]:not([data-discussion-resolved])`, + ), ); } @@ -182,14 +58,10 @@ function getPreviousDiscussion() { } function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { - if (window.mrTabs.currentAction !== 'show') { - handleDiscussionJump(ctx, fn); - } else { - const discussion = getDiscussion(); - const id = discussion.dataset.discussionId; - ctx.expandDiscussion({ discussionId: id }); - scrollToElement(discussion, scrollOptions); - } + const discussion = getDiscussion(); + const id = discussion.dataset.discussionId; + ctx.expandDiscussion({ discussionId: id }); + scrollToElement(discussion, scrollOptions); } export default { @@ -205,9 +77,11 @@ export default { }, methods: { ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']), - ...mapActions('diffs', ['scrollToFile']), + ...mapActions('diffs', ['scrollToFile', 'disableVirtualScroller']), + + async jumpToNextDiscussion(scrollOptions) { + await this.disableVirtualScroller(); - jumpToNextDiscussion(scrollOptions) { handleJumpForBothPages( getNextDiscussion, this, @@ -216,7 +90,9 @@ export default { ); }, - jumpToPreviousDiscussion(scrollOptions) { + async jumpToPreviousDiscussion(scrollOptions) { + await this.disableVirtualScroller(); + handleJumpForBothPages( getPreviousDiscussion, this, diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 9783def1b46..44751020173 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; export default { @@ -46,7 +46,7 @@ export default { this.isResolving = false; const msg = __('Something went wrong while resolving this discussion. Please try again.'); - createFlash({ + createAlert({ message: msg, parent: this.$el, }); diff --git a/app/assets/javascripts/notes/timeline.js b/app/assets/javascripts/notes/timeline.js deleted file mode 100644 index df6d1b21400..00000000000 --- a/app/assets/javascripts/notes/timeline.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import TimelineToggle from './components/timeline_toggle.vue'; - -export default function initTimelineToggle(store) { - const el = document.getElementById('js-incidents-timeline-toggle'); - - if (!el) return null; - - return new Vue({ - el, - store, - render(createElement) { - return createElement(TimelineToggle); - }, - }); -} diff --git a/app/assets/javascripts/notes/utils/get_notes_filter_data.js b/app/assets/javascripts/notes/utils/get_notes_filter_data.js new file mode 100644 index 00000000000..6d62ab5e91b --- /dev/null +++ b/app/assets/javascripts/notes/utils/get_notes_filter_data.js @@ -0,0 +1,21 @@ +/** + * Returns parsed notes filter data from a given element's dataset + * + * @param {Element} el containing info in the dataset + */ +export const getNotesFilterData = (el) => { + const { notesFilterValue: valueData, notesFilters: filtersData } = el.dataset; + + const filtersParsed = filtersData ? JSON.parse(filtersData) : {}; + const filters = Object.keys(filtersParsed).map((key) => ({ + title: key, + value: filtersParsed[key], + })); + + const value = valueData ? Number(valueData) : undefined; + + return { + notesFilters: filters, + notesFilterValue: value, + }; +}; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js index 529eb7d207b..5f60cab8bdd 100644 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -35,7 +35,7 @@ export const receiveSaveChangesError = (_, error) => { const { response = {} } = error; const message = response.data && response.data.message ? response.data.message : ''; - createFlash({ + createAlert({ message: `${__('There was an error saving your changes.')} ${message}`, }); }; 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 9e8eb92d87a..597df2b9bc3 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 @@ -1,6 +1,6 @@ <script> import { GlEmptyState } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { n__ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; @@ -69,7 +69,7 @@ export default { return this.queryVariables; }, error() { - createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); }, }, }, 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 9ebbdfa920d..8b66165a57a 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -1,7 +1,7 @@ <script> import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; @@ -66,7 +66,7 @@ export default { this.updateBreadcrumb(); }, error() { - createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); }, }, }, diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index c1bd71de646..794be8d5195 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -10,7 +10,7 @@ import { } from '@gitlab/ui'; import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import Tracking from '~/tracking'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; @@ -100,7 +100,7 @@ export default { this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount; }, error() { - createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); }, }, additionalDetails: { @@ -115,7 +115,7 @@ export default { return data[this.graphqlResource]?.containerRepositories.nodes; }, error() { - createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); }, }, }, diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js index 26d4aa13715..223f427ce0e 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; import { DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, @@ -20,7 +20,7 @@ export const fetchPackageVersions = ({ commit, state }) => { } }) .catch(() => { - createFlash({ message: FETCH_PACKAGE_VERSIONS_ERROR, type: 'warning' }); + createAlert({ message: FETCH_PACKAGE_VERSIONS_ERROR, variant: VARIANT_WARNING }); }) .finally(() => { commit(types.SET_LOADING, false); @@ -33,7 +33,7 @@ export const deletePackage = ({ }, }) => { return Api.deleteProjectPackage(project_id, id).catch(() => { - createFlash({ message: DELETE_PACKAGE_ERROR_MESSAGE, type: 'warning' }); + createAlert({ message: DELETE_PACKAGE_ERROR_MESSAGE, variant: VARIANT_WARNING }); }); }; @@ -51,9 +51,9 @@ export const deletePackageFile = ( .then(() => { const filtered = packageFiles.filter((f) => f.id !== fileId); commit(types.UPDATE_PACKAGE_FILES, filtered); - createFlash({ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, type: 'success' }); + createAlert({ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, variant: VARIANT_SUCCESS }); }) .catch(() => { - createFlash({ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, type: 'warning' }); + createAlert({ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, variant: VARIANT_WARNING }); }); }; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue index dab4a051d0c..8b6a5c59847 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -81,10 +81,9 @@ export default { }, }, i18n: { - deleteModalContent: s__( - 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?', - ), - modalAction: s__('PackageRegistry|Delete package'), + deleteModalContent: s__('PackageRegistry|You are about to delete %{name}, are you sure?'), + modalTitle: s__('PackageRegistry|Delete package'), + modalAction: s__('PackageRegistry|Permanently delete'), }, }; </script> @@ -120,13 +119,13 @@ export default { <gl-modal ref="packageListDeleteModal" size="sm" - modal-id="confirm-delete-pacakge" + modal-id="confirm-delete-package" :action-primary="deleteModalActionPrimaryProps" :action-cancel="deleteModalActionCancelProps" @ok="deleteItemConfirmation" @cancel="deleteItemCanceled" > - <template #modal-title>{{ $options.i18n.modalAction }}</template> + <template #modal-title>{{ $options.i18n.modalTitle }}</template> <gl-sprintf :message="$options.i18n.deleteModalContent"> <template #name> <strong>{{ deletePackageName }}</strong> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 184a24047eb..2adf6187c4b 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -1,7 +1,7 @@ <script> import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { @@ -84,7 +84,7 @@ export default { const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); if (showAlert) { // to be refactored to use gl-alert - createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' }); + createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO }); const cleanUrl = window.location.href.split('?')[0]; historyReplaceState(cleanUrl); } diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 51a38c434cb..37b51797490 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; import { @@ -43,7 +43,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { dispatch('receivePackagesListSuccess', { data, headers }); }) .catch(() => { - createFlash({ + createAlert({ message: FETCH_PACKAGES_LIST_ERROR_MESSAGE, }); }) @@ -54,7 +54,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { export const requestDeletePackage = ({ dispatch, state }, { _links }) => { if (!_links || !_links.delete_api_path) { - createFlash({ + createAlert({ message: DELETE_PACKAGE_ERROR_MESSAGE, }); const error = new Error(MISSING_DELETE_PATH_ERROR); @@ -69,14 +69,14 @@ export const requestDeletePackage = ({ dispatch, state }, { _links }) => { const page = getNewPaginationPage(currentPage, perPage, total - 1); dispatch('requestPackagesList', { page }); - createFlash({ + createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, - type: 'success', + variant: VARIANT_SUCCESS, }); }) .catch(() => { dispatch('setLoading', false); - createFlash({ + createAlert({ message: DELETE_PACKAGE_ERROR_MESSAGE, }); }); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index 11fd0db3106..cee976656f9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -2,7 +2,8 @@ import { GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { __ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime_utility'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; @@ -25,6 +26,7 @@ export default { }, inject: ['isGroupPage'], i18n: { + lastDownloadedAt: s__('PackageRegistry|Last downloaded %{dateTime}'), packageInfo: __('v%{version} published %{timeAgo}'), }, props: { @@ -39,6 +41,11 @@ export default { }; }, computed: { + packageLastDownloadedAtDisplay() { + return sprintf(this.$options.i18n.lastDownloadedAt, { + dateTime: formatDate(this.packageEntity.lastDownloadedAt, 'mmm d, yyyy'), + }); + }, packageTypeDisplay() { return getPackageTypeLabel(this.packageEntity.packageType); }, @@ -136,6 +143,15 @@ export default { <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> </template> + <template v-if="packageEntity.lastDownloadedAt" #metadata-last-downloaded-at> + <metadata-item + data-testid="package-last-downloaded-at" + icon="download" + :text="packageLastDownloadedAtDisplay" + size="m" + /> + </template> + <template #right-actions> <slot name="delete-button"></slot> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue index 7a85fd3052e..e1cf4883029 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue @@ -1,6 +1,6 @@ <script> import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; import { s__ } from '~/locale'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/package_registry/constants'; @@ -39,15 +39,15 @@ export default { throw data.destroyPackage.errors[0]; } if (this.showSuccessAlert) { - createFlash({ + createAlert({ message: this.$options.i18n.successMessage, - type: 'success', + variant: VARIANT_SUCCESS, }); } } catch (error) { - createFlash({ + createAlert({ message: this.$options.i18n.errorMessage, - type: 'warning', + variant: VARIANT_WARNING, captureError: true, error, }); 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 e84f181e9b2..c6583b8f09f 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 @@ -122,10 +122,9 @@ export default { }, }, i18n: { - deleteModalContent: s__( - 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?', - ), - modalAction: s__('PackageRegistry|Delete package'), + deleteModalContent: s__('PackageRegistry|You are about to delete %{name}, are you sure?'), + modalTitle: s__('PackageRegistry|Delete package'), + modalAction: s__('PackageRegistry|Permanently delete'), errorMessageBodyAlert: s__( 'PackageRegistry|There was a timeout and the package was not published. Delete this package and try again.', ), @@ -172,14 +171,14 @@ export default { <gl-modal v-model="showDeleteModal" - modal-id="confirm-delete-pacakge" + modal-id="confirm-delete-package" size="sm" :action-primary="deleteModalActionPrimaryProps" :action-cancel="deleteModalActionCancelProps" @ok="deleteItemConfirmation" @cancel="deleteItemCanceled" > - <template #modal-title>{{ $options.i18n.modalAction }}</template> + <template #modal-title>{{ $options.i18n.modalTitle }}</template> <gl-sprintf :message="$options.i18n.deleteModalContent"> <template #name> <strong>{{ deletePackageName }}</strong> 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 06a04ee248a..4e35176c757 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -78,6 +78,17 @@ export const TRACKING_ACTION_CLICK_COMMIT_LINK = 'click_commit_link_from_package export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history'; export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; + +export const DELETE_MODAL_TITLE = s__('PackageRegistry|Delete package version'); +export const DELETE_MODAL_CONTENT = s__( + `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, +); +export const DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT = s__( + `PackageRegistry|Deleting all package assets will remove version %{version} of %{name}. Are you sure?`, +); +export const DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT = s__( + `PackageRegistry|Deleting the last package asset will remove version %{version} of %{name}. Are you sure?`, +); export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package asset.', ); 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 f3f0d096d10..8e50c95b10b 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 @@ -4,6 +4,7 @@ query getPackageDetails($id: PackagesPackageID!) { name packageType version + lastDownloadedAt createdAt updatedAt status 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 c10fc914d56..eeed56b77c3 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 @@ -10,7 +10,7 @@ import { GlTabs, GlSprintf, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; @@ -44,6 +44,10 @@ import { DELETE_PACKAGE_FILES_ERROR_MESSAGE, DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, + DELETE_MODAL_TITLE, + DELETE_MODAL_CONTENT, + DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT, + DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT, } from '~/packages_and_registries/package_registry/constants'; import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; @@ -86,6 +90,7 @@ export default { }, data() { return { + deletePackageModalContent: DELETE_MODAL_CONTENT, filesToDelete: [], mutationLoading: false, packageEntity: {}, @@ -101,7 +106,7 @@ export default { return data.package || {}; }, error(error) { - createFlash({ + createAlert({ message: FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, captureError: true, error, @@ -205,20 +210,18 @@ export default { if (data?.destroyPackageFiles?.errors[0]) { throw data.destroyPackageFiles.errors[0]; } - createFlash({ - message: - ids.length === 1 - ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE - : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, - type: 'success', + createAlert({ + message: this.isLastItem(ids) + ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE + : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + variant: VARIANT_SUCCESS, }); } catch (error) { - createFlash({ - message: - ids.length === 1 - ? DELETE_PACKAGE_FILE_ERROR_MESSAGE - : DELETE_PACKAGE_FILES_ERROR_MESSAGE, - type: 'warning', + createAlert({ + message: this.isLastItem(ids) + ? DELETE_PACKAGE_FILE_ERROR_MESSAGE + : DELETE_PACKAGE_FILES_ERROR_MESSAGE, + variant: VARIANT_WARNING, captureError: true, error, }); @@ -231,18 +234,26 @@ export default { files.length === this.packageFiles.length && !this.packageEntity.packageFiles?.pageInfo?.hasNextPage ) { + if (this.isLastItem(files)) { + this.deletePackageModalContent = DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT; + } else { + this.deletePackageModalContent = DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT; + } this.$refs.deleteModal.show(); } else { this.filesToDelete = files; - if (files.length === 1) { + if (this.isLastItem(files)) { this.$refs.deleteFileModal.show(); } else if (files.length > 1) { this.$refs.deleteFilesModal.show(); } } }, + isLastItem(items) { + return items.length === 1; + }, confirmFilesDelete() { - if (this.filesToDelete.length === 1) { + if (this.isLastItem(this.filesToDelete)) { this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION); } else { this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION); @@ -250,12 +261,12 @@ export default { this.deletePackageFiles(this.filesToDelete.map((file) => file.id)); this.filesToDelete = []; }, + resetDeleteModalContent() { + this.deletePackageModalContent = DELETE_MODAL_CONTENT; + }, }, i18n: { - deleteModalTitle: s__(`PackageRegistry|Delete Package Version`), - deleteModalContent: s__( - `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, - ), + DELETE_MODAL_TITLE, deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`), deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, @@ -263,7 +274,7 @@ export default { }, modal: { packageDeletePrimaryAction: { - text: __('Delete'), + text: s__('PackageRegistry|Permanently delete'), attributes: [ { variant: 'danger' }, { category: 'primary' }, @@ -371,10 +382,11 @@ export default { :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" @primary="deletePackage(packageEntity)" + @hidden="resetDeleteModalContent" @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" > - <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template> - <gl-sprintf :message="$options.i18n.deleteModalContent"> + <template #modal-title>{{ $options.i18n.DELETE_MODAL_TITLE }}</template> + <gl-sprintf :message="deletePackageModalContent"> <template #version> <strong>{{ packageEntity.version }}</strong> </template> @@ -398,7 +410,7 @@ export default { @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" > <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template> - <gl-sprintf v-if="filesToDelete.length === 1" :message="$options.i18n.deleteFileModalContent"> + <gl-sprintf v-if="isLastItem(filesToDelete)" :message="$options.i18n.deleteFileModalContent"> <template #filename> <strong>{{ filesToDelete[0].fileName }}</strong> </template> 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 38df701157a..ed9ab0367dd 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,6 +1,6 @@ <script> import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; @@ -105,7 +105,7 @@ export default { const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); if (showAlert) { // to be refactored to use gl-alert - createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' }); + createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO }); const cleanUrl = window.location.href.split('?')[0]; historyReplaceState(cleanUrl); } diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue index 72e68aca070..b8405b09840 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue @@ -57,6 +57,9 @@ export default { isEnabled() { return this.containerExpirationPolicy || this.enableHistoricEntries; }, + isLoading() { + return this.$apollo.queries.containerExpirationPolicy.loading; + }, showDisabledFormMessage() { return !this.isEnabled && !this.fetchSettingsError; }, @@ -86,10 +89,10 @@ export default { <container-expiration-policy-form v-if="isEnabled" v-model="workingCopy" - :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-loading="isLoading" :is-edited="isEdited" /> - <template v-else> + <template v-if="!isLoading"> <gl-alert v-if="showDisabledFormMessage" :dismissible="false" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index b003b6aeb6b..1dd88d69d30 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -110,7 +110,7 @@ export default { {{ cleanupRulesButtonText }} </gl-button> </gl-card> - <template v-else> + <template v-if="!$apollo.queries.containerExpirationPolicy.loading"> <gl-alert v-if="showDisabledFormMessage" :dismissible="false" diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js index 8cecc1d3ef7..97fb64f9971 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -29,7 +29,7 @@ export default class PayloadDownloader { PayloadDownloader.downloadFile(data); }) .catch(() => { - createFlash({ + createAlert({ message: __('Error fetching payload data.'), }); }) diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index 616005565c4..1cd19fc09a8 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -43,7 +43,7 @@ export default class PayloadPreviewer { }) .catch(() => { this.spinner.classList.remove('gl-display-inline'); - createFlash({ + createAlert({ message: __('Error fetching payload data.'), }); }); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index 18ba89f8856..40348e0b18a 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; @@ -30,7 +30,7 @@ export default () => { $jsBroadcastMessagePreview.html(data); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while rendering preview broadcast message'), }), ); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js index f687423594d..ffd976be8c6 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js @@ -1,5 +1,10 @@ +import initBroadcastMessages from '~/admin/broadcast_messages'; import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import initBroadcastMessagesForm from './broadcast_message'; -initBroadcastMessagesForm(); -initDeprecatedRemoveRowBehavior(); +if (gon.features.vueBroadcastMessages) { + initBroadcastMessages(); +} else { + initBroadcastMessagesForm(); + initDeprecatedRemoveRowBehavior(); +} diff --git a/app/assets/javascripts/pages/admin/dashboard/index.js b/app/assets/javascripts/pages/admin/dashboard/index.js new file mode 100644 index 00000000000..b63e612be47 --- /dev/null +++ b/app/assets/javascripts/pages/admin/dashboard/index.js @@ -0,0 +1,3 @@ +import initGitlabVersionCheck from '~/gitlab_version_check'; + +initGitlabVersionCheck(); diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js deleted file mode 100644 index 86b80a0ba5b..00000000000 --- a/app/assets/javascripts/pages/admin/groups/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import UsersSelect from '~/users_select'; - -new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js index a249864fa36..eaee625c047 100644 --- a/app/assets/javascripts/pages/admin/index.js +++ b/app/assets/javascripts/pages/admin/index.js @@ -1,10 +1,8 @@ -import initGitlabVersionCheck from '~/gitlab_version_check'; import initAdminStatisticsPanel from '~/admin/statistics_panel/index'; import initVueAlerts from '~/vue_alerts'; import initAdmin from './admin'; initVueAlerts(); -initGitlabVersionCheck(); const statisticsPanelContainer = document.getElementById('js-admin-statistics-container'); initAdmin(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index 63b98f4143b..4f42ef2892d 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,6 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -31,7 +31,7 @@ export default { redirectTo(response.request.responseURL); }) .catch((error) => { - createFlash({ + createAlert({ message: s__('AdminArea|Stopping jobs failed'), }); throw error; diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index b6f42a27002..2a7619da8cc 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { getGroups } from '~/api/groups_api'; import { getProjects } from '~/api/projects_api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isMetaClick } from '~/lib/utils/common_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; @@ -119,7 +119,7 @@ export default class Todos { }) .catch(() => { this.updateRowState(target, true); - return createFlash({ + return createAlert({ message: __('Error updating status of to-do item.'), }); }); @@ -168,7 +168,7 @@ export default class Todos { this.updateBadges(data); }) .catch(() => - createFlash({ + createAlert({ message: __('Error updating status for all to-do items.'), }), ); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index de28f027126..377ba0f13a9 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,6 +1,10 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar'; +import { + initBulkUpdateSidebar, + initStatusDropdown, + initSubscriptionsDropdown, +} from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; @@ -9,6 +13,8 @@ const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initBulkUpdateSidebar(ISSUABLE_BULK_UPDATE_PREFIX); +initStatusDropdown(); +initSubscriptionsDropdown(); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index 8ce73be6e74..fa111032b2e 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -1,6 +1,6 @@ import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import InputValidator from '~/validators/input_validator'; import { getGroupPathAvailability } from '~/rest_api'; @@ -62,7 +62,7 @@ export default class GroupPathValidator extends InputValidator { } }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while validating group path'), }), ); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 7dab5258b24..a555038ed5c 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import BindInOut from '~/behaviors/bind_in_out'; import initFilePickers from '~/file_pickers'; import Group from '~/group'; @@ -8,6 +10,8 @@ import NewGroupCreationApp from './components/app.vue'; import GroupPathValidator from './group_path_validator'; import initToggleInviteMembers from './toggle_invite_members'; +Vue.use(VueApollo); + new GroupPathValidator(); // eslint-disable-line no-new new Group(); // eslint-disable-line no-new initGroupNameAndPath(); @@ -31,8 +35,13 @@ function initNewGroupCreation(el) { hasErrors: parseBoolean(hasErrors), }; + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + return new Vue({ el, + apolloProvider, provide: { verificationRequired: parseBoolean(verificationRequired), verificationFormUrl, diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index bf77d968e7d..b1a1cc21764 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -2,9 +2,11 @@ import initStaleRunnerCleanupSetting from 'ee_else_ce/group_settings/stale_runne import initVariableList from '~/ci_variable_list'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; import initSettingsPanels from '~/settings_panels'; +import initDeployTokens from '~/deploy_tokens'; // Initialize expandable settings panels initSettingsPanels(); +initDeployTokens(); initSharedRunnersForm(); initStaleRunnerCleanupSetting(); diff --git a/app/assets/javascripts/pages/groups/settings/index.js b/app/assets/javascripts/pages/groups/settings/index.js index cb787c60002..7e97cd865b7 100644 --- a/app/assets/javascripts/pages/groups/settings/index.js +++ b/app/assets/javascripts/pages/groups/settings/index.js @@ -1,5 +1,7 @@ import initRevokeButton from '~/deploy_tokens/init_revoke_button'; import initSearchSettings from '~/search_settings'; +import initDeployTokens from '~/deploy_tokens'; +initDeployTokens(); initSearchSettings(); initRevokeButton(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 9cce6723bf7..6feb4c2188f 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -2,7 +2,7 @@ import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { getBulkImportsHistory } from '~/rest_api'; @@ -107,7 +107,7 @@ export default { this.pageInfo = parseIntPagination(normalizeHeaders(headers)); this.historyItems = historyItems; } catch (e) { - createFlash({ message: DEFAULT_ERROR, captureError: true, error: e }); + createAlert({ message: DEFAULT_ERROR, captureError: true, error: e }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue new file mode 100644 index 00000000000..20ce296bbec --- /dev/null +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue @@ -0,0 +1,95 @@ +<script> +import { GlAvatarLabeled, GlListbox } from '@gitlab/ui'; +import { __ } from '~/locale'; +import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +const USERS_PER_PAGE = 20; + +export default { + components: { + GlAvatarLabeled, + GlListbox, + }, + props: { + name: { + type: String, + required: true, + }, + }, + apollo: { + usersQuery: { + query: searchUsersQuery, + variables() { + return { + search: this.search, + first: USERS_PER_PAGE, + }; + }, + update(data) { + return data; + }, + debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + }, + }, + data() { + return { + user: '', + search: '', + }; + }, + computed: { + userId() { + return getIdFromGraphQLId(this.user); + }, + users() { + return [ + { text: __('(no user)'), value: '' }, + ...(this.usersQuery?.users.nodes || []).map((u) => ({ + username: `@${u.username}`, + avatarUrl: u.avatarUrl, + text: u.name, + value: u.id, + })), + ]; + }, + }, + methods: { + clearTransform() { + // FIXME: workaround for listbox issue + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1986 + const { listbox } = this.$refs; + if (listbox.querySelector('.dropdown-menu')) { + listbox.querySelector('.dropdown-menu').style.transform = ''; + } + }, + }, +}; +</script> +<template> + <div> + <gl-listbox + ref="listbox" + v-model="user" + :items="users" + searchable + is-check-centered + :searching="$apollo.loading" + @click.capture.native="clearTransform" + @search="search = $event" + > + <template #list-item="{ item }"> + <gl-avatar-labeled + shape="circle" + :size="32" + :src="item.avatarUrl" + :label="item.text" + :sub-label="item.username" + /> + </template> + </gl-listbox> + <input type="hidden" :name="name" :value="userId" /> + </div> +</template> diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js index 86b80a0ba5b..ef549f20cf3 100644 --- a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js @@ -1,3 +1,19 @@ -import UsersSelect from '~/users_select'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import UserSelect from './components/user_select.vue'; -new UsersSelect(); // eslint-disable-line no-new +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +Array.from(document.querySelectorAll('.js-gitlab-user')).forEach( + (node) => + new Vue({ + el: node, + apolloProvider, + render: (h) => h(UserSelect, { props: { name: node.dataset.name } }), + }), +); diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue index db6f0c23dbd..09b1b3a9c0f 100644 --- a/app/assets/javascripts/pages/import/history/components/import_history_app.vue +++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { getProjects } from '~/rest_api'; import ImportStatus from '~/import_entities/components/import_status.vue'; @@ -104,7 +104,7 @@ export default { this.pageInfo = parseIntPagination(normalizeHeaders(headers)); this.historyItems = historyItems; } catch (e) { - createFlash({ message: DEFAULT_ERROR, captureError: true, error: e }); + createAlert({ message: DEFAULT_ERROR, captureError: true, error: e }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js index 6afb3636998..91b20a05196 100644 --- a/app/assets/javascripts/pages/profiles/index.js +++ b/app/assets/javascripts/pages/profiles/index.js @@ -3,6 +3,7 @@ import '~/profile/gl_crop'; import Profile from '~/profile/profile'; import initSearchSettings from '~/search_settings'; import initPasswordPrompt from './password_prompt'; +import { initTimezoneDropdown } from './init_timezone_dropdown'; // eslint-disable-next-line func-names $(document).on('input.ssh_key', '#key_key', function () { @@ -21,3 +22,4 @@ new Profile(); // eslint-disable-line no-new initSearchSettings(); initPasswordPrompt(); +initTimezoneDropdown(); diff --git a/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js b/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js new file mode 100644 index 00000000000..80b911493a8 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/init_timezone_dropdown.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; + +export const initTimezoneDropdown = () => { + const el = document.querySelector('.js-timezone-dropdown'); + + if (!el) { + return null; + } + + const { timezoneData, initialValue } = el.dataset; + const timezones = JSON.parse(timezoneData); + + const timezoneDropdown = new Vue({ + el, + data() { + return { + value: initialValue, + }; + }, + render(h) { + return h(TimezoneDropdown, { + props: { + value: this.value, + timezoneData: timezones, + name: 'user[timezone]', + }, + class: 'gl-md-form-input-lg', + }); + }, + }); + + return timezoneDropdown; +}; diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js index fa22c11d1d7..1e4b9de90f2 100644 --- a/app/assets/javascripts/pages/projects/blame/show/index.js +++ b/app/assets/javascripts/pages/projects/blame/show/index.js @@ -1,3 +1,5 @@ import initBlob from '~/pages/projects/init_blob'; +import redirectToCorrectPage from '~/blame/blame_redirect'; +redirectToCorrectPage(); initBlob(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index eca3cf7ab13..af0097b415c 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -4,7 +4,7 @@ import Vue from 'vue'; import loadAwardsHandler from '~/awards_handler'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import initDeprecatedNotes from '~/init_deprecated_notes'; import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown'; import axios from '~/lib/utils/axios_utils'; @@ -69,7 +69,7 @@ if (filesContainer.length) { loadDiffStats(); }) .catch(() => { - createFlash({ message: __('An error occurred while retrieving diff files') }); + createAlert({ message: __('An error occurred while retrieving diff files') }); }); } else { new Diff(); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index b415e36bf09..30cefa3d717 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -12,7 +12,7 @@ import { } from '@gitlab/ui'; import { kebabCase } from 'lodash'; import { buildApiUrl } from '~/api/api_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -220,7 +220,7 @@ export default { redirectTo(data.web_url); return; } catch (error) { - createFlash({ + createAlert({ message: s__( 'ForkProject|An error occurred while forking the project. Please try again.', ), diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue index 2b3055ecd66..00e0649deed 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue @@ -9,7 +9,7 @@ import { GlSearchBoxByType, GlTruncate, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -41,7 +41,7 @@ export default { return length > 0 && length < MINIMUM_SEARCH_LENGTH; }, error(error) { - createFlash({ + createAlert({ message: s__( 'ForkProject|Something went wrong while loading data. Please refresh the page to try again.', ), diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js index 2a120a690ef..ed476d25f8b 100644 --- a/app/assets/javascripts/pages/projects/hooks/index.js +++ b/app/assets/javascripts/pages/projects/hooks/index.js @@ -1,3 +1,5 @@ import initSearchSettings from '~/search_settings'; +import initWebhookForm from '~/webhooks'; initSearchSettings(); +initWebhookForm(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js index 9a38c2cc765..65942464e2b 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; @@ -39,7 +39,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( } }) .catch(() => - createFlash({ + createAlert({ message: __('Error fetching refs'), }), ); 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 5179d1b31ab..406959c80ea 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -33,7 +33,7 @@ function initTargetBranchSelector() { callback(data); }) .catch(() => - createFlash({ + createAlert({ message: __('Error fetching branches'), }), ); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index e284e7b2c5e..2399aafc9b5 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -2,14 +2,19 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; -import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar'; +import { + initBulkUpdateSidebar, + initStatusDropdown, + initSubscriptionsDropdown, +} from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); -initIssueStatusSelect(); +initStatusDropdown(); +initSubscriptionsDropdown(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js index 6dd21380bec..0edce2db0a3 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js @@ -1,3 +1,8 @@ +import initPipelineSchedulesFormApp from '~/pipeline_schedules/mount_pipeline_schedules_form_app'; import initForm from '../shared/init_form'; -initForm(); +if (gon.features?.pipelineSchedulesVue) { + initPipelineSchedulesFormApp('#pipeline-schedules-form-edit'); +} else { + initForm(); +} diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index 9513f42d9c9..7d0930f6424 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,9 +1,10 @@ import Vue from 'vue'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import initPipelineSchedulesApp from '~/pipeline_schedules/mount_pipeline_schedules_app'; import PipelineSchedulesTakeOwnershipModal from '~/pipeline_schedules/components/take_ownership_modal.vue'; import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; -function initPipelineSchedules() { +function initPipelineSchedulesCallout() { const el = document.getElementById('pipeline-schedules-callout'); if (!el) { @@ -15,6 +16,7 @@ function initPipelineSchedules() { // eslint-disable-next-line no-new new Vue({ el, + name: 'PipelineSchedulesCalloutRoot', provide: { docsUrl, illustrationUrl, @@ -25,6 +27,8 @@ function initPipelineSchedules() { }); } +// TODO: move take ownership feature into new Vue app +// located in directory app/assets/javascripts/pipeline_schedules/components function initTakeownershipModal() { const modalId = 'pipeline-take-ownership-modal'; const buttonSelector = 'js-take-ownership-button'; @@ -63,5 +67,10 @@ function initTakeownershipModal() { }); } -initPipelineSchedules(); -initTakeownershipModal(); +initPipelineSchedulesCallout(); + +if (gon.features?.pipelineSchedulesVue) { + initPipelineSchedulesApp(); +} else { + initTakeownershipModal(); +} diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js index 6dd21380bec..06084fa729b 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js @@ -1,3 +1,8 @@ +import initPipelineSchedulesFormApp from '~/pipeline_schedules/mount_pipeline_schedules_form_app'; import initForm from '../shared/init_form'; -initForm(); +if (gon.features?.pipelineSchedulesVue) { + initPipelineSchedulesFormApp('#pipeline-schedules-form-new'); +} else { + initForm(); +} diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js index 277d2e0d30a..bc467952551 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js @@ -1,4 +1,5 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; const defaultTimezone = { identifier: 'Etc/UTC', name: 'UTC', offset: 0 }; const defaults = { @@ -17,8 +18,6 @@ export const formatUtcOffset = (offset) => { return `${prefix} ${Math.abs(offset / 3600)}`; }; -export const formatTimezone = (item) => `[UTC ${formatUtcOffset(item.offset)}] ${item.name}`; - export const findTimezoneByIdentifier = (tzList = [], identifier = null) => { if (tzList && tzList.length && identifier && identifier.length) { return tzList.find((tz) => tz.identifier === identifier) || null; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index ccabaad5b2e..d177c67f133 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import initClonePanel from '~/clone_panel'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { serializeForm } from '~/lib/utils/forms'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -80,7 +80,7 @@ export default class Project { }) .then(({ data }) => callback(data)) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while getting projects'), }), ); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 6a9bd34db22..8909ff1f221 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -10,6 +10,7 @@ import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_to import initSettingsPanels from '~/settings_panels'; import { initTokenAccess } from '~/token_access'; import { initCiSecureFiles } from '~/ci_secure_files'; +import initDeployTokens from '~/deploy_tokens'; // Initialize expandable settings panels initSettingsPanels(); @@ -34,6 +35,7 @@ document.querySelector('.js-toggle-extra-settings').addEventListener('click', (e }); registrySettingsApp(); +initDeployTokens(); initDeployFreeze(); initSettingsPipelinesTriggers(); diff --git a/app/assets/javascripts/pages/projects/settings/index.js b/app/assets/javascripts/pages/projects/settings/index.js index cb787c60002..7e97cd865b7 100644 --- a/app/assets/javascripts/pages/projects/settings/index.js +++ b/app/assets/javascripts/pages/projects/settings/index.js @@ -1,5 +1,7 @@ import initRevokeButton from '~/deploy_tokens/init_revoke_button'; import initSearchSettings from '~/search_settings'; +import initDeployTokens from '~/deploy_tokens'; +initDeployTokens(); initSearchSettings(); initRevokeButton(); 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 655243eee30..d2263fa815d 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,5 +1,7 @@ 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'; + import initForm from '../form'; initForm(); @@ -8,3 +10,4 @@ const mirrorReposContainer = document.querySelector('.js-mirror-settings'); if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init(); mountBranchRules(document.getElementById('js-branch-rules')); +mountDefaultBranchSelector(document.querySelector('.js-select-default-branch')); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index a82f485bf44..3e5c02bbf19 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -898,7 +898,9 @@ export default { :help-path="pagesHelpPath" :label="$options.i18n.pagesLabel" :help-text=" - s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab.') + s__( + 'ProjectSettings|With GitLab Pages you can host your static websites on GitLab. GitLab Pages uses a caching mechanism for efficiency. Your changes may not take effect until that cache is invalidated, which usually takes less than a minute.', + ) " > <project-feature-setting @@ -979,20 +981,20 @@ export default { name="project[project_feature_attributes][feature_flags_access_level]" /> </project-setting-row> - <project-setting-row - ref="releases-settings" - :label="$options.i18n.releasesLabel" - :help-text="$options.i18n.releasesHelpText" - :help-path="releasesHelpPath" - > - <project-feature-setting - v-model="releasesAccessLevel" - :label="$options.i18n.releasesLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][releases_access_level]" - /> - </project-setting-row> </template> + <project-setting-row + ref="releases-settings" + :label="$options.i18n.releasesLabel" + :help-text="$options.i18n.releasesHelpText" + :help-path="releasesHelpPath" + > + <project-feature-setting + v-model="releasesAccessLevel" + :label="$options.i18n.releasesLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][releases_access_level]" + /> + </project-setting-row> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 7ea744a68a6..1848aa70cf0 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -1,6 +1,6 @@ import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import InputValidator from '~/validators/input_validator'; @@ -51,7 +51,7 @@ export default class UsernameValidator extends InputValidator { ); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while validating username'), }), ); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index 10b95fd6f3c..b72579276e8 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -1,6 +1,6 @@ <script> import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { handleLocationHash } from '~/lib/utils/common_utils'; @@ -47,7 +47,7 @@ export default { handleLocationHash(); }) .catch(() => - createFlash({ + createAlert({ message: this.$options.i18n.renderingContentFailed, }), ); 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 9acc1cb62a1..7b9656de362 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -8,21 +8,19 @@ import { GlFormGroup, GlFormInput, GlFormSelect, - GlSegmentedControl, } from '@gitlab/ui'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import axios from '~/lib/utils/axios_utils'; +import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import csrf from '~/lib/utils/csrf'; import { setUrlFragment } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { - CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, WIKI_CONTENT_EDITOR_TRACKING_LABEL, WIKI_FORMAT_LABEL, WIKI_FORMAT_UPDATED_ACTION, + CONTENT_EDITOR_LOADED_ACTION, } from '../constants'; const trackingMixin = Tracking.mixin({ @@ -36,6 +34,29 @@ const MARKDOWN_LINK_TEXT = { org: '[[page-slug]]', }; +function getPagePath(pageInfo) { + return pageInfo.persisted ? pageInfo.path : pageInfo.createPath; +} + +const autosaveKey = (pageInfo, field) => { + const path = pageInfo.persisted ? pageInfo.path : pageInfo.createPath; + + return `${path}/${field}`; +}; + +const titleAutosaveKey = (pageInfo) => autosaveKey(pageInfo, 'title'); +const formatAutosaveKey = (pageInfo) => autosaveKey(pageInfo, 'format'); +const contentAutosaveKey = (pageInfo) => autosaveKey(pageInfo, 'content'); +const commitAutosaveKey = (pageInfo) => autosaveKey(pageInfo, 'commit'); + +const getTitle = (pageInfo) => getDraft(titleAutosaveKey(pageInfo)) || pageInfo.title?.trim() || ''; +const getFormat = (pageInfo) => + getDraft(formatAutosaveKey(pageInfo)) || pageInfo.format || 'markdown'; +const getContent = (pageInfo) => getDraft(contentAutosaveKey(pageInfo)) || pageInfo.content || ''; +const getCommitMessage = (pageInfo) => + getDraft(commitAutosaveKey(pageInfo)) || pageInfo.commitMessage || ''; +const getIsFormDirty = (pageInfo) => Boolean(getDraft(titleAutosaveKey(pageInfo))); + export default { i18n: { title: { @@ -74,10 +95,6 @@ export default { }, cancel: s__('WikiPage|Cancel'), }, - switchEditingControlOptions: [ - { text: s__('Wiki Page|Source'), value: 'source' }, - { text: s__('Wiki Page|Rich text'), value: 'richText' }, - ], components: { GlIcon, GlForm, @@ -87,26 +104,21 @@ export default { GlSprintf, GlLink, GlButton, - GlSegmentedControl, - MarkdownField, - LocalStorageSync, - ContentEditor: () => - import( - /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' - ), + MarkdownEditor, }, mixins: [trackingMixin], inject: ['formatOptions', 'pageInfo'], data() { return { editingMode: 'source', - title: this.pageInfo.title?.trim() || '', - format: this.pageInfo.format || 'markdown', - content: this.pageInfo.content || '', - commitMessage: '', - isDirty: false, + title: getTitle(this.pageInfo), + format: getFormat(this.pageInfo), + content: getContent(this.pageInfo), + commitMessage: getCommitMessage(this.pageInfo), contentEditorEmpty: false, + isContentEditorActive: false, switchEditingControlDisabled: false, + isFormDirty: getIsFormDirty(this.pageInfo), }; }, computed: { @@ -117,7 +129,7 @@ export default { return csrf.token; }, formAction() { - return this.pageInfo.persisted ? this.pageInfo.path : this.pageInfo.createPath; + return getPagePath(this.pageInfo); }, helpPath() { return setUrlFragment( @@ -162,15 +174,9 @@ export default { disableSubmitButton() { return this.noContent || !this.title; }, - isContentEditorActive() { - return this.isMarkdownFormat && this.useContentEditor; - }, - useContentEditor() { - return this.editingMode === 'richText'; - }, }, mounted() { - this.updateCommitMessage(); + if (!this.commitMessage) this.updateCommitMessage(); window.addEventListener('beforeunload', this.onPageUnload); }, @@ -178,51 +184,45 @@ export default { window.removeEventListener('beforeunload', this.onPageUnload); }, methods: { - renderMarkdown(content) { - return axios - .post(this.pageInfo.markdownPreviewPath, { text: content }) - .then(({ data }) => data.body); - }, - - setEditingMode(editingMode) { - this.editingMode = editingMode; - }, - async handleFormSubmit(e) { - e.preventDefault(); + this.isFormDirty = false; - if (this.useContentEditor) { - this.trackFormSubmit(); - } + e.preventDefault(); + this.trackFormSubmit(); this.trackWikiFormat(); // Wait until form field values are refreshed await this.$nextTick(); e.target.submit(); + }, - this.isDirty = false; + updateDrafts() { + updateDraft(titleAutosaveKey(this.pageInfo), this.title); + updateDraft(formatAutosaveKey(this.pageInfo), this.format); + updateDraft(contentAutosaveKey(this.pageInfo), this.content); + updateDraft(commitAutosaveKey(this.pageInfo), this.commitMessage); }, - handleContentChange() { - this.isDirty = true; + clearDrafts() { + clearDraft(titleAutosaveKey(this.pageInfo)); + clearDraft(formatAutosaveKey(this.pageInfo)); + clearDraft(contentAutosaveKey(this.pageInfo)); + clearDraft(commitAutosaveKey(this.pageInfo)); }, - handleContentEditorChange({ empty, markdown, changed }) { + handleContentEditorChange({ empty, markdown }) { this.contentEditorEmpty = empty; - this.isDirty = changed; this.content = markdown; }, - onPageUnload(event) { - if (!this.isDirty) return undefined; - - event.preventDefault(); - - // eslint-disable-next-line no-param-reassign - event.returnValue = ''; - return ''; + onPageUnload() { + if (this.isFormDirty) { + this.updateDrafts(); + } else { + this.clearDrafts(); + } }, updateCommitMessage() { @@ -235,8 +235,13 @@ export default { this.commitMessage = newCommitMessage; }, - trackContentEditorLoaded() { - this.track(CONTENT_EDITOR_LOADED_ACTION); + notifyContentEditorActive() { + this.isContentEditorActive = true; + this.trackContentEditorLoaded(); + }, + + notifyContentEditorInactive() { + this.isContentEditorActive = false; }, trackFormSubmit() { @@ -256,12 +261,12 @@ export default { }); }, - enableSwitchEditingControl() { - this.switchEditingControlDisabled = false; + trackContentEditorLoaded() { + this.track(CONTENT_EDITOR_LOADED_ACTION); }, - disableSwitchEditingControl() { - this.switchEditingControlDisabled = true; + submitFormWithShortcut() { + this.$refs.form.submit(); }, }, }; @@ -269,10 +274,12 @@ export default { <template> <gl-form + ref="form" :action="formAction" method="post" class="wiki-form common-note-form gl-mt-3 js-quick-submit" @submit="handleFormSubmit" + @input="isFormDirty = true" > <input :value="csrfToken" type="hidden" name="authenticity_token" /> <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" /> @@ -329,74 +336,23 @@ export default { <div class="row" data-testid="wiki-form-content-fieldset"> <div class="col-sm-12 row-sm-5"> <gl-form-group> - <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-start gl-mb-3"> - <gl-segmented-control - data-testid="toggle-editing-mode-button" - data-qa-selector="editing_mode_button" - class="gl-display-flex" - :checked="editingMode" - :options="$options.switchEditingControlOptions" - :disabled="switchEditingControlDisabled" - @input="setEditingMode" - /> - </div> - <local-storage-sync - storage-key="gl-wiki-content-editor-enabled" - :value="editingMode" - @input="setEditingMode" - /> - <markdown-field - v-if="!isContentEditorActive" - :markdown-preview-path="pageInfo.markdownPreviewPath" - :can-attach-file="true" - :enable-autocomplete="true" - :textarea-value="content" + <markdown-editor + v-model="content" + :render-markdown-path="pageInfo.markdownPreviewPath" :markdown-docs-path="pageInfo.markdownHelpPath" :uploads-path="pageInfo.uploadsPath" + :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" - class="bordered-box" - > - <template #textarea> - <textarea - id="wiki_content" - ref="textarea" - v-model="content" - name="wiki[content]" - class="note-textarea js-gfm-input js-autosize markdown-area" - dir="auto" - data-supports-quick-actions="false" - data-qa-selector="wiki_content_textarea" - :autofocus="pageInfo.persisted" - :aria-label="$options.i18n.content.label" - :placeholder="$options.i18n.content.placeholder" - @input="handleContentChange" - > - </textarea> - </template> - </markdown-field> - <div v-if="isContentEditorActive"> - <content-editor - :render-markdown="renderMarkdown" - :uploads-path="pageInfo.uploadsPath" - :markdown="content" - @initialized="trackContentEditorLoaded" - @change="handleContentEditorChange" - @loading="disableSwitchEditingControl" - @loadingSuccess="enableSwitchEditingControl" - @loadingError="enableSwitchEditingControl" - /> - <input - id="wiki_content" - v-model.trim="content" - type="hidden" - name="wiki[content]" - data-qa-selector="wiki_hidden_content" - /> - </div> - - <div class="clearfix"></div> - <div class="error-alert"></div> - + :init-on-autofocus="pageInfo.persisted" + :form-field-placeholder="$options.i18n.content.placeholder" + :form-field-aria-label="$options.i18n.content.label" + form-field-id="wiki_content" + form-field-name="wiki[content]" + @contentEditor="notifyContentEditorActive" + @markdownField="notifyContentEditorInactive" + @keydown.ctrl.enter="submitFormShortcut" + @keydown.meta.enter="submitFormShortcut" + /> <div class="form-text gl-text-gray-600"> <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" @@ -447,9 +403,14 @@ export default { :disabled="disableSubmitButton" >{{ submitButtonText }}</gl-button > - <gl-button data-testid="wiki-cancel-button" :href="cancelFormPath" class="float-right">{{ - $options.i18n.cancel - }}</gl-button> + <gl-button + data-testid="wiki-cancel-button" + :href="cancelFormPath" + class="float-right" + @click="isFormDirty = false" + > + {{ $options.i18n.cancel }}</gl-button + > </div> </gl-form> </template> diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 9e0af426f6e..fb761725c43 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -1,7 +1,7 @@ import { select } from 'd3-selection'; import $ from 'jquery'; import { last } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import dateFormat from '~/lib/dateformat'; import axios from '~/lib/utils/axios_utils'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; @@ -151,7 +151,7 @@ export default class ActivityCalendar { .select(container) .append('svg') .attr('width', width) - .attr('height', 167) + .attr('height', 169) .attr('class', 'contrib-calendar'); } @@ -302,7 +302,7 @@ export default class ActivityCalendar { }); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while retrieving calendar activity'), }), ); diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index ddc880db227..f35f9341fa1 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -1,9 +1,10 @@ <script> -import pdfjsLib from 'pdfjs-dist/build/pdf'; -import workerSrc from 'pdfjs-dist/build/pdf.worker.min'; +import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/legacy/build/pdf'; import Page from './page/index.vue'; +GlobalWorkerOptions.workerSrc = '/assets/webpack/pdfjs/pdf.worker.min.js'; + export default { components: { Page }, props: { @@ -30,18 +31,16 @@ export default { }, watch: { pdf: 'load' }, mounted() { - pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; if (this.hasPDF) this.load(); }, methods: { load() { this.pages = []; - return pdfjsLib - .getDocument({ - url: this.document, - cMapUrl: '/assets/webpack/cmaps/', - cMapPacked: true, - }) + return getDocument({ + url: this.document, + cMapUrl: '/assets/webpack/pdfjs/cmaps/', + cMapPacked: true, + }) .promise.then(this.renderPages) .then((pages) => { this.pages = pages; diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 7e331bdd91d..6ee33902a01 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -1,4 +1,4 @@ -import createFlash from './flash'; +import { createAlert } from '~/flash'; import axios from './lib/utils/axios_utils'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; @@ -73,7 +73,7 @@ export default class PersistentUserCallout { } }) .catch(() => { - createFlash({ + createAlert({ message: __( 'An error occurred while dismissing the alert. Refresh the page and try again.', ), @@ -94,7 +94,7 @@ export default class PersistentUserCallout { window.location.assign(href); }) .catch(() => { - createFlash({ + createAlert({ message: __( 'An error occurred while acknowledging the notification. Refresh the page and try again.', ), diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index 3e87088e77e..7d2b9cd3d42 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -15,11 +15,20 @@ export default { 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.', ), btnText: __('Configure pipeline'), + externalCiNote: __("This project's pipeline configuration is located outside this repository"), + externalCiInstructions: __( + 'To edit the pipeline configuration, you must go to the project or external site that hosts the file.', + ), }, inject: { emptyStateIllustrationPath: { default: '', }, + usesExternalConfig: { + default: false, + type: Boolean, + required: false, + }, }, methods: { createEmptyConfigFile() { @@ -33,22 +42,31 @@ export default { <pipeline-editor-file-nav v-on="$listeners" /> <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> <img :src="emptyStateIllustrationPath" /> - <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> - <p class="gl-mt-3"> - <gl-sprintf :message="$options.i18n.body"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - <gl-button - variant="confirm" - class="gl-mt-3" - data-qa-selector="create_new_ci_button" - @click="createEmptyConfigFile" + <div + v-if="usesExternalConfig" + class="gl-display-flex gl-flex-direction-column gl-align-items-center" > - {{ $options.i18n.btnText }} - </gl-button> + <h1 class="gl-font-size-h1">{{ $options.i18n.externalCiNote }}</h1> + <p class="gl-mt-3">{{ $options.i18n.externalCiInstructions }}</p> + </div> + <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center"> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <p class="gl-mt-3"> + <gl-sprintf :message="$options.i18n.body"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <gl-button + variant="confirm" + class="gl-mt-3" + data-qa-selector="create_new_ci_button" + @click="createEmptyConfigFile" + > + {{ $options.i18n.btnText }} + </gl-button> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 13dad0b2459..6d91c339833 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { EDITOR_APP_STATUS_LOADING } from './constants'; import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; @@ -42,6 +43,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectNamespace, simulatePipelineHelpPagePath, totalBranches, + usesExternalConfig, validateTabIllustrationPath, ymlHelpPagePath, } = el.dataset; @@ -133,6 +135,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectNamespace, simulatePipelineHelpPagePath, totalBranches: parseInt(totalBranches, 10), + usesExternalConfig: parseBoolean(usesExternalConfig), validateTabIllustrationPath, ymlHelpPagePath, }, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 548769eb214..ff848a973e3 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -40,7 +40,7 @@ export default { PipelineEditorHome, PipelineEditorMessages, }, - inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath'], + inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath', 'usesExternalConfig'], data() { return { ciConfigData: {}, @@ -397,7 +397,7 @@ export default { <div class="gl-mt-4 gl-relative" data-qa-selector="pipeline_editor_app"> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> <pipeline-editor-empty-state - v-else-if="showStartScreen" + v-else-if="showStartScreen || usesExternalConfig" @createEmptyConfigFile="setNewEmptyCiConfigFile" @refetchContent="refetchContent" /> diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 2d5c01a58b7..1972125ed56 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -162,7 +162,7 @@ export default { </div> </div> <commit-section - v-if="showCommitForm" + v-show="showCommitForm" :ref="$options.commitSectionRef" :ci-file-content="ciFileContent" :commit-sha="commitSha" diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue index 529ec4897b4..cd7cb7f8393 100644 --- a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue @@ -401,7 +401,7 @@ export default { <div v-for="(variable, index) in variables" :key="variable.uniqueId" - class="gl-mb-3 gl-ml-n3 gl-pb-2" + class="gl-mb-3 gl-pb-2" data-testid="ci-variable-row" data-qa-selector="ci_variable_row_container" > diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 529ec4897b4..a9af1181027 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -17,17 +17,11 @@ import { import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import { - VARIABLE_TYPE, - FILE_TYPE, - CONFIG_VARIABLES_TIMEOUT, - CC_VALIDATION_REQUIRED_ERROR, -} from '../constants'; +import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; +import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql'; +import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql'; import filterVariables from '../utils/filter_variables'; import RefsDropdown from './refs_dropdown.vue'; @@ -76,10 +70,6 @@ export default { type: String, required: true, }, - configVariablesPath: { - type: String, - required: true, - }, defaultBranch: { type: String, required: true, @@ -97,6 +87,10 @@ export default { required: false, default: () => ({}), }, + projectPath: { + type: String, + required: true, + }, refParam: { type: String, required: false, @@ -116,19 +110,77 @@ export default { return { refValue: { shortName: this.refParam, + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, }, form: {}, errorTitle: null, error: null, + predefinedValueOptions: {}, warnings: [], totalWarnings: 0, isWarningDismissed: false, - isLoading: false, submitted: false, ccAlertDismissed: false, }; }, + apollo: { + ciConfigVariables: { + query: ciConfigVariablesQuery, + // Skip when variables already cached in `form` + skip() { + return Object.keys(this.form).includes(this.refFullName); + }, + variables() { + return { + fullPath: this.projectPath, + ref: this.refQueryParam, + }; + }, + update({ project }) { + return project?.ciConfigVariables || []; + }, + result({ data }) { + const predefinedVars = data?.project?.ciConfigVariables || []; + const params = {}; + const descriptions = {}; + + predefinedVars.forEach(({ description, key, value, valueOptions }) => { + if (description) { + params[key] = value; + descriptions[key] = description; + this.predefinedValueOptions[key] = valueOptions; + } + }); + + Vue.set(this.form, this.refFullName, { descriptions, variables: [] }); + + // Add default variables from yml + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); + + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }, + error(error) { + Sentry.captureException(error); + }, + }, + }, computed: { + isLoading() { + return this.$apollo.queries.ciConfigVariables.loading; + }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; }, @@ -147,6 +199,9 @@ export default { refFullName() { return this.refValue.fullName; }, + refQueryParam() { + return this.refFullName || this.refShortName; + }, variables() { return this.form[this.refFullName]?.variables ?? []; }, @@ -157,21 +212,6 @@ export default { return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; }, }, - watch: { - refValue() { - this.loadConfigVariablesForm(); - }, - }, - created() { - // this is needed until we add support for ref type in url query strings - // ensure default branch is called with full ref on load - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - if (this.refValue.shortName === this.defaultBranch) { - this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; - } - - this.loadConfigVariablesForm(); - }, methods: { addEmptyVariable(refValue) { const { variables } = this.form[refValue]; @@ -204,132 +244,57 @@ export default { }); } }, - setVariableType(key, type) { + setVariableAttribute(key, attribute, value) { const { variables } = this.form[this.refFullName]; const variable = variables.find((v) => v.key === key); - variable.variable_type = type; + variable[attribute] = value; }, setVariableParams(refValue, type, paramsObj) { Object.entries(paramsObj).forEach(([key, value]) => { this.setVariable(refValue, type, key, value); }); }, + shouldShowValuesDropdown(key) { + return this.predefinedValueOptions[key]?.length > 1; + }, removeVariable(index) { this.variables.splice(index, 1); }, canRemove(index) { return index < this.variables.length - 1; }, - loadConfigVariablesForm() { - // Skip when variables already cached in `form` - if (this.form[this.refFullName]) { - return; - } - - this.fetchConfigVariables(this.refFullName || this.refShortName) - .then(({ descriptions, params }) => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions, - }); - - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - }) - .catch(() => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions: {}, - }); - }) - .finally(() => { - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); - } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); - } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); - }); - }, - fetchConfigVariables(refValue) { - this.isLoading = true; - - return backOff((next, stop) => { - axios - .get(this.configVariablesPath, { - params: { - sha: refValue, - }, - }) - .then(({ data, status }) => { - if (status === httpStatusCodes.NO_CONTENT) { - next(); - } else { - this.isLoading = false; - stop(data); - } - }) - .catch((error) => { - stop(error); - }); - }, CONFIG_VARIABLES_TIMEOUT) - .then((data) => { - const params = {}; - const descriptions = {}; - - Object.entries(data).forEach(([key, { value, description }]) => { - if (description) { - params[key] = value; - descriptions[key] = description; - } - }); - - return { params, descriptions }; - }) - .catch((error) => { - this.isLoading = false; - - Sentry.captureException(error); - - return { params: {}, descriptions: {} }; - }); - }, - createPipeline() { + async createPipeline() { this.submitted = true; this.ccAlertDismissed = false; - return axios - .post(this.pipelinesPath, { + const { data } = await this.$apollo.mutate({ + mutation: createPipelineMutation, + variables: { + endpoint: this.pipelinesPath, // send shortName as fall back for query params // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - ref: this.refValue.fullName || this.refShortName, - variables_attributes: filterVariables(this.variables), - }) - .then(({ data }) => { - redirectTo(`${this.pipelinesPath}/${data.id}`); - }) - .catch((err) => { - // always re-enable submit button - this.submitted = false; + ref: this.refQueryParam, + variablesAttributes: filterVariables(this.variables), + }, + }); - const { - errors = [], - warnings = [], - total_warnings: totalWarnings = 0, - } = err.response.data; - const [error] = errors; + const { id, errors, totalWarnings, warnings } = data.createPipeline; - this.reportError({ - title: i18n.submitErrorTitle, - error, - warnings, - totalWarnings, - }); - }); + if (id) { + redirectTo(`${this.pipelinesPath}/${id}`); + return; + } + + // always re-enable submit button + this.submitted = false; + const [error] = errors; + + this.reportError({ + title: i18n.submitErrorTitle, + error, + warnings, + totalWarnings, + }); }, onRefsLoadingError(error) { this.reportError({ title: i18n.refsLoadingErrorTitle }); @@ -401,7 +366,7 @@ export default { <div v-for="(variable, index) in variables" :key="variable.uniqueId" - class="gl-mb-3 gl-ml-n3 gl-pb-2" + class="gl-mb-3 gl-pb-2" data-testid="ci-variable-row" data-qa-selector="ci_variable_row_container" > @@ -416,7 +381,7 @@ export default { <gl-dropdown-item v-for="type in Object.keys($options.typeOptions)" :key="type" - @click="setVariableType(variable.key, type)" + @click="setVariableAttribute(variable.key, 'variable_type', type)" > {{ $options.typeOptions[type] }} </gl-dropdown-item> @@ -429,7 +394,24 @@ export default { data-qa-selector="ci_variable_key_field" @change="addEmptyVariable(refFullName)" /> + <gl-dropdown + v-if="shouldShowValuesDropdown(variable.key)" + :text="variable.value" + :class="$options.formElementClasses" + class="gl-flex-grow-1 gl-mr-0!" + data-testid="pipeline-form-ci-variable-value-dropdown" + > + <gl-dropdown-item + v-for="value in predefinedValueOptions[variable.key]" + :key="value" + data-testid="pipeline-form-ci-variable-value-dropdown-items" + @click="setVariableAttribute(variable.key, 'value', value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> <gl-form-textarea + v-else v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3" diff --git a/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql b/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql new file mode 100644 index 00000000000..a76e8f6b95b --- /dev/null +++ b/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql @@ -0,0 +1,9 @@ +mutation createPipeline($endpoint: String, $ref: String, $variablesAttributes: Array) { + createPipeline(endpoint: $endpoint, ref: $ref, variablesAttributes: $variablesAttributes) + @client { + id + errors + totalWarnings + warnings + } +} diff --git a/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql new file mode 100644 index 00000000000..648cd8b66b5 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql @@ -0,0 +1,11 @@ +query ciConfigVariables($fullPath: ID!, $ref: String!) { + project(fullPath: $fullPath) { + id + ciConfigVariables(sha: $ref) { + description + key + value + valueOptions + } + } +} diff --git a/app/assets/javascripts/pipeline_new/graphql/resolvers.js b/app/assets/javascripts/pipeline_new/graphql/resolvers.js new file mode 100644 index 00000000000..7b0f58e8cf9 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/graphql/resolvers.js @@ -0,0 +1,29 @@ +import axios from '~/lib/utils/axios_utils'; + +export const resolvers = { + Mutation: { + createPipeline: (_, { endpoint, ref, variablesAttributes }) => { + return axios + .post(endpoint, { ref, variables_attributes: variablesAttributes }) + .then((response) => { + const { id } = response.data; + return { + id, + errors: [], + totalWarnings: 0, + warnings: [], + }; + }) + .catch((err) => { + const { errors = [], totalWarnings = 0, warnings = [] } = err.response.data; + + return { + id: null, + errors, + totalWarnings, + warnings, + }; + }); + }, + }, +}; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index e3f363f4ada..60b4c93d1d5 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; +import { resolvers } from './graphql/resolvers'; const mountLegacyPipelineNewForm = (el) => { const { @@ -51,12 +54,12 @@ const mountPipelineNewForm = (el) => { projectRefsEndpoint, // props - configVariablesPath, defaultBranch, fileParam, maxWarnings, pipelinesPath, projectId, + projectPath, refParam, settingsLink, varParam, @@ -65,22 +68,27 @@ const mountPipelineNewForm = (el) => { const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); - // TODO: add apolloProvider + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); return new Vue({ el, + apolloProvider, provide: { projectRefsEndpoint, }, render(createElement) { return createElement(PipelineNewForm, { props: { - configVariablesPath, defaultBranch, fileParams, maxWarnings: Number(maxWarnings), pipelinesPath, projectId, + projectPath, refParam, settingsLink, variableParams, diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue new file mode 100644 index 00000000000..4a08a82275a --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules.vue @@ -0,0 +1,134 @@ +<script> +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; +import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; + +export default { + i18n: { + schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'), + scheduleDeleteError: s__( + 'PipelineSchedules|There was a problem deleting the pipeline schedule.', + ), + }, + modal: { + id: 'delete-pipeline-schedule-modal', + deleteConfirmation: s__( + 'PipelineSchedules|Are you sure you want to delete this pipeline schedule?', + ), + actionPrimary: { + text: s__('PipelineSchedules|Delete pipeline schedule'), + attributes: [{ variant: 'danger' }], + }, + actionCancel: { + text: __('Cancel'), + attributes: [], + }, + }, + components: { + GlAlert, + GlLoadingIcon, + GlModal, + PipelineSchedulesTable, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + schedules: { + query: getPipelineSchedulesQuery, + variables() { + return { + projectPath: this.fullPath, + }; + }, + update({ project }) { + return project?.pipelineSchedules?.nodes || []; + }, + error() { + this.reportError(this.$options.i18n.schedulesFetchError); + }, + }, + }, + data() { + return { + schedules: [], + hasError: false, + errorMessage: '', + scheduleToDeleteId: null, + showModal: false, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.schedules.loading; + }, + }, + methods: { + reportError(error) { + this.hasError = true; + this.errorMessage = error; + }, + showDeleteModal(id) { + this.showModal = true; + this.scheduleToDeleteId = id; + }, + hideModal() { + this.showModal = false; + this.scheduleToDeleteId = null; + }, + async deleteSchedule() { + try { + const { + data: { + pipelineScheduleDelete: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deletePipelineScheduleMutation, + variables: { id: this.scheduleToDeleteId }, + }); + + if (errors.length > 0) { + throw new Error(); + } else { + this.$apollo.queries.schedules.refetch(); + } + } catch { + this.reportError(this.$options.i18n.scheduleDeleteError); + } + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false"> + {{ errorMessage }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" size="lg" /> + + <!-- Tabs will be addressed in #371989 --> + + <template v-else> + <pipeline-schedules-table :schedules="schedules" @showDeleteModal="showDeleteModal" /> + + <gl-modal + :visible="showModal" + :title="$options.modal.actionPrimary.text" + :modal-id="$options.modal.id" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + size="sm" + @primary="deleteSchedule" + @hide="hideModal" + > + {{ $options.modal.deleteConfirmation }} + </gl-modal> + </template> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_form.vue new file mode 100644 index 00000000000..6e24ac6b8d4 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_form.vue @@ -0,0 +1,18 @@ +<script> +import { GlForm } from '@gitlab/ui'; + +export default { + components: { + GlForm, + }, + inject: { + fullPath: { + default: '', + }, + }, +}; +</script> + +<template> + <gl-form /> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue new file mode 100644 index 00000000000..76d118bf52d --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -0,0 +1,66 @@ +<script> +import { GlButton, GlButtonGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export const i18n = { + playTooltip: s__('PipelineSchedules|Run pipeline schedule'), + editTooltip: s__('PipelineSchedules|Edit pipeline schedule'), + deleteTooltip: s__('PipelineSchedules|Delete pipeline schedule'), + takeOwnershipTooltip: s__('PipelineSchedules|Take ownership of pipeline schedule'), +}; + +export default { + i18n, + components: { + GlButton, + GlButtonGroup, + }, + directives: { + GlTooltip, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + canPlay() { + return this.schedule.userPermissions.playPipelineSchedule; + }, + canTakeOwnership() { + return this.schedule.userPermissions.takeOwnershipPipelineSchedule; + }, + canUpdate() { + return this.schedule.userPermissions.updatePipelineSchedule; + }, + canRemove() { + return this.schedule.userPermissions.adminPipelineSchedule; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button-group> + <gl-button v-if="canPlay" v-gl-tooltip :title="$options.i18n.playTooltip" icon="play" /> + <gl-button + v-if="canTakeOwnership" + v-gl-tooltip + :title="$options.i18n.takeOwnershipTooltip" + icon="user" + /> + <gl-button v-if="canUpdate" v-gl-tooltip :title="$options.i18n.editTooltip" icon="pencil" /> + <gl-button + v-if="canRemove" + v-gl-tooltip + :title="$options.i18n.deleteTooltip" + icon="remove" + variant="danger" + data-testid="delete-pipeline-schedule-btn" + @click="$emit('showDeleteModal', schedule.id)" + /> + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue new file mode 100644 index 00000000000..216796b357c --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -0,0 +1,32 @@ +<script> +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; + +export default { + components: { + CiBadge, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + hasPipeline() { + return this.schedule.lastPipeline; + }, + lastPipelineStatus() { + return this.schedule?.lastPipeline?.detailedStatus; + }, + }, +}; +</script> + +<template> + <div> + <ci-badge v-if="hasPipeline" :status="lastPipelineStatus" class="gl-vertical-align-middle" /> + <span v-else data-testid="pipeline-schedule-status-text"> + {{ s__('PipelineSchedules|None') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue new file mode 100644 index 00000000000..48d59bf6e7c --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue @@ -0,0 +1,32 @@ +<script> +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + TimeAgoTooltip, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + showTimeAgo() { + return this.schedule.active && this.schedule.nextRunAt; + }, + realNextRunTime() { + return this.schedule.realNextRun; + }, + }, +}; +</script> + +<template> + <div> + <time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" /> + <span v-else data-testid="pipeline-schedule-inactive"> + {{ s__('PipelineSchedules|Inactive') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue new file mode 100644 index 00000000000..e7fa94eb7fc --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue @@ -0,0 +1,29 @@ +<script> +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlAvatarLink, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + owner() { + return this.schedule.owner; + }, + }, +}; +</script> + +<template> + <div> + <gl-avatar-link :href="owner.webPath" :title="owner.name" class="gl-ml-3"> + <gl-avatar :size="32" :src="owner.avatarUrl" /> + </gl-avatar-link> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue new file mode 100644 index 00000000000..08efa794bcc --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue @@ -0,0 +1,36 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + iconName() { + return this.schedule.forTag ? 'tag' : 'fork'; + }, + refPath() { + return this.schedule.refPath; + }, + refDisplay() { + return this.schedule.refForDisplay; + }, + }, +}; +</script> + +<template> + <div> + <gl-icon :name="iconName" /> + <span v-if="refPath"> + <gl-link :href="refPath" class="gl-text-gray-900">{{ refDisplay }}</gl-link> + </span> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue new file mode 100644 index 00000000000..d54008b81b2 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -0,0 +1,95 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import PipelineScheduleActions from './cells/pipeline_schedule_actions.vue'; +import PipelineScheduleLastPipeline from './cells/pipeline_schedule_last_pipeline.vue'; +import PipelineScheduleNextRun from './cells/pipeline_schedule_next_run.vue'; +import PipelineScheduleOwner from './cells/pipeline_schedule_owner.vue'; +import PipelineScheduleTarget from './cells/pipeline_schedule_target.vue'; + +export default { + fields: [ + { + key: 'description', + label: s__('PipelineSchedules|Description'), + columnClass: 'gl-w-40p', + }, + { + key: 'target', + label: s__('PipelineSchedules|Target'), + columnClass: 'gl-w-10p', + }, + { + key: 'pipeline', + label: s__('PipelineSchedules|Last Pipeline'), + columnClass: 'gl-w-10p', + }, + { + key: 'next', + label: s__('PipelineSchedules|Next Run'), + columnClass: 'gl-w-15p', + }, + { + key: 'owner', + label: s__('PipelineSchedules|Owner'), + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + columnClass: 'gl-w-15p', + }, + ], + components: { + GlTableLite, + PipelineScheduleActions, + PipelineScheduleLastPipeline, + PipelineScheduleNextRun, + PipelineScheduleOwner, + PipelineScheduleTarget, + }, + props: { + schedules: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md"> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(description)="{ item }"> + <span data-testid="pipeline-schedule-description"> + {{ item.description }} + </span> + </template> + + <template #cell(target)="{ item }"> + <pipeline-schedule-target :schedule="item" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-schedule-last-pipeline :schedule="item" /> + </template> + + <template #cell(next)="{ item }"> + <pipeline-schedule-next-run :schedule="item" /> + </template> + + <template #cell(owner)="{ item }"> + <pipeline-schedule-owner :schedule="item" /> + </template> + + <template #cell(actions)="{ item }"> + <pipeline-schedule-actions + :schedule="item" + @showDeleteModal="$emit('showDeleteModal', $event)" + /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql b/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..8aab0b3fbde --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation deletePipelineSchedule($id: CiPipelineScheduleID!) { + pipelineScheduleDelete(input: { id: $id }) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql new file mode 100644 index 00000000000..7d9d658b1b6 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -0,0 +1,40 @@ +query getPipelineSchedulesQuery($projectPath: ID!) { + project(fullPath: $projectPath) { + id + pipelineSchedules { + nodes { + id + description + forTag + refPath + refForDisplay + lastPipeline { + id + detailedStatus { + id + group + icon + label + text + detailsPath + } + } + active + nextRunAt + realNextRun + owner { + id + avatarUrl + name + webPath + } + userPermissions { + playPipelineSchedule + takeOwnershipPipelineSchedule + updatePipelineSchedule + adminPipelineSchedule + } + } + } + } +} diff --git a/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_app.js new file mode 100644 index 00000000000..8f77e06c19a --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_app.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineSchedules from './components/pipeline_schedules.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const containerEl = document.querySelector('#pipeline-schedules-app'); + + if (!containerEl) { + return false; + } + + const { fullPath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + name: 'PipelineSchedulesRoot', + apolloProvider, + provide: { + fullPath, + }, + render(createElement) { + return createElement(PipelineSchedules); + }, + }); +}; diff --git a/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_form_app.js new file mode 100644 index 00000000000..d83417ab84a --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineSchedulesForm from './components/pipeline_schedules_form.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default (selector) => { + const containerEl = document.querySelector(selector); + + if (!containerEl) { + return false; + } + + const { fullPath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + name: 'PipelineSchedulesFormRoot', + apolloProvider, + provide: { + fullPath, + }, + render(createElement) { + return createElement(PipelineSchedulesForm); + }, + }); +}; diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue index a5ce56daf07..bc62d6d4465 100644 --- a/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue @@ -176,7 +176,7 @@ export default { category="secondary" data-testid="remove-step-button" icon="remove" - @click="removeValue" + @click="() => removeValue(i)" /> </template> </gl-form-input-group> 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 9e886fd7a48..605d40eddee 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql'; import { prepareFailedJobs } from './utils'; @@ -47,7 +47,7 @@ export default { this.preparedFailedJobs = prepareFailedJobs(this.failedJobs, this.failedJobsSummary); }, error() { - createFlash({ message: s__('Jobs|There was a problem fetching the failed jobs.') }); + createAlert({ message: s__('Jobs|There was a problem fetching the failed jobs.') }); }, }, }, diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index 0c6b8b9ed2b..18607bfae1c 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlLink, GlSafeHtmlDirective, GlTableLite } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql'; @@ -49,7 +49,7 @@ export default { return job.retryable && job.userPermissions.updateBuild; }, showErrorMessage() { - createFlash({ message: s__('Job|There was a problem retrying the failed job.') }); + createAlert({ message: s__('Job|There was a problem retrying the failed job.') }); }, }, }; diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue index 18e9ffa23cf..f1ad312dcaa 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -1,7 +1,7 @@ <script> import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; import produce from 'immer'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/jobs/components/table/event_hub'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; @@ -42,7 +42,7 @@ export default { this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {}; }, error() { - createFlash({ message: __('An error occurred while fetching the pipelines jobs.') }); + createAlert({ message: __('An error occurred while fetching the pipelines jobs.') }); }, }, }, diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index ca2537ca4f4..7ee5ec48f44 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { dasherize } from '~/lib/utils/text_utility'; @@ -81,7 +81,7 @@ export default { reportToSentry('action_component', err); - createFlash({ + createAlert({ message: __('An error occurred while making the request.'), }); }); diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue index a68797a7235..f1c6c6633eb 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue @@ -14,7 +14,7 @@ import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import eventHub from '../../event_hub'; @@ -94,7 +94,7 @@ export default { this.$refs.dropdown.hide(); this.isLoading = false; - createFlash({ + createAlert({ message: __('Something went wrong on our end.'), }); }); diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue index df59962569e..2a78636261b 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue @@ -56,7 +56,12 @@ export default { <template> <gl-tabs> - <gl-tab ref="pipelineTab" :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab"> + <gl-tab + ref="pipelineTab" + :title="$options.i18n.tabs.pipelineTitle" + data-testid="pipeline-tab" + lazy + > <pipeline-graph-wrapper /> </gl-tab> <gl-tab @@ -64,6 +69,7 @@ export default { :title="$options.i18n.tabs.needsTitle" :active="isActive($options.tabNames.needs)" data-testid="dag-tab" + lazy > <dag /> </gl-tab> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index 2d2f649f651..73a255f392b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -55,6 +55,9 @@ export default { }; }, computed: { + hasArtifacts() { + return this.artifacts.length > 0; + }, filteredArtifacts() { return this.searchQuery.length > 0 ? fuzzaldrinPlus.filter(this.artifacts, this.searchQuery, { key: 'name' }) @@ -86,7 +89,9 @@ export default { }); }, handleDropdownShown() { - this.$refs.searchInput.focusInput(); + if (this.hasArtifacts) { + this.$refs.searchInput.focusInput(); + } }, }, }; @@ -112,12 +117,12 @@ export default { <gl-loading-icon v-else-if="isLoading" size="sm" /> - <gl-dropdown-item v-else-if="!artifacts.length" data-testid="artifacts-empty-message"> + <gl-dropdown-item v-else-if="!hasArtifacts" data-testid="artifacts-empty-message"> {{ $options.i18n.emptyArtifactsMessage }} </gl-dropdown-item> <template #header> - <gl-search-box-by-type v-if="artifacts.length" ref="searchInput" v-model.trim="searchQuery" /> + <gl-search-box-by-type v-if="hasArtifacts" ref="searchInput" v-model.trim="searchQuery" /> </template> <gl-dropdown-item diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index f9022be888a..30528ce8d17 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/flash'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -249,7 +249,7 @@ export default { this.updateContent(params); - this.track('click_filter_tabs', { label: TRACKING_CATEGORIES.tabs }); + this.track('click_filter_tabs', { label: TRACKING_CATEGORIES.tabs, property: scope }); }, successCallback(resp) { // Because we are polling & the user is interacting verify if the response received @@ -267,14 +267,14 @@ export default { .postAction(endpoint) .then(() => { this.isResetCacheButtonLoading = false; - createFlash({ + createAlert({ message: s__('Pipelines|Project cache successfully reset.'), - type: 'notice', + variant: VARIANT_INFO, }); }) .catch(() => { this.isResetCacheButtonLoading = false; - createFlash({ + createAlert({ message: s__('Pipelines|Something went wrong while cleaning runners cache.'), }); }); @@ -301,9 +301,9 @@ export default { } if (!filter.type) { - createFlash({ + createAlert({ message: RAW_TEXT_WARNING, - type: 'warning', + variant: VARIANT_WARNING, }); } }); 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 16a747f6165..f34b3f56c5b 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,6 +1,6 @@ <script> import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { s__, __, sprintf } from '~/locale'; @@ -66,7 +66,7 @@ export default { }) .catch(() => { this.isLoading = false; - createFlash({ message: __('An error occurred while making the request.') }); + createAlert({ message: __('An error occurred while making the request.') }); }); }, isActionDisabled(action) { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index 1db2898b72a..b57d0ac1fd7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; export default { @@ -45,7 +45,7 @@ export default { this.loading = false; }) .catch((err) => { - createFlash({ + createAlert({ message: FETCH_BRANCH_ERROR_MESSAGE, }); this.loading = false; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index afcdd63b664..5846a1f6ed9 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; export default { @@ -38,7 +38,7 @@ export default { this.loading = false; }) .catch((err) => { - createFlash({ + createAlert({ message: FETCH_TAG_ERROR_MESSAGE, }); this.loading = false; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index 746cf238646..73f7d3f52c3 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -8,7 +8,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { ANY_TRIGGER_AUTHOR, FETCH_AUTHOR_ERROR_MESSAGE, @@ -61,7 +61,7 @@ export default { this.loading = false; }) .catch((err) => { - createFlash({ + createAlert({ message: FETCH_AUTHOR_ERROR_MESSAGE, }); this.loading = false; diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index e8e49cc652e..9602ca1ba88 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import createFlash, { createAlert } from '~/flash'; +import { createAlert } from '~/flash'; import { helpPagePath } from '~/helpers/help_page_helper'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -172,7 +172,7 @@ export default { .postAction(endpoint) .then(() => this.updateTable()) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while making the request.'), }), ); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 8bdf18da348..3744649e9d5 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__ } from '~/locale'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; @@ -24,7 +24,7 @@ export default async function initPipelineDetailsBundle() { try { createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); } catch { - createFlash({ + createAlert({ message: __('An error occurred while loading a section of this page.'), }); } @@ -37,7 +37,7 @@ export default async function initPipelineDetailsBundle() { const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider); createPipelineTabs(appOptions); } catch { - createFlash({ + createAlert({ message: __('An error occurred while loading a section of this page.'), }); } @@ -45,7 +45,7 @@ export default async function initPipelineDetailsBundle() { try { createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); } catch { - createFlash({ + createAlert({ message: __('An error occurred while loading the pipeline.'), }); } @@ -53,7 +53,7 @@ export default async function initPipelineDetailsBundle() { try { createDagApp(apolloProvider); } catch { - createFlash({ + createAlert({ message: __('An error occurred while loading the Needs tab.'), }); } @@ -61,7 +61,7 @@ export default async function initPipelineDetailsBundle() { try { createTestDetails(SELECTORS.PIPELINE_TESTS); } catch { - createFlash({ + createAlert({ message: __('An error occurred while loading the Test Reports tab.'), }); } @@ -69,7 +69,7 @@ export default async function initPipelineDetailsBundle() { try { createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); } catch { - createFlash({ + createAlert({ message: __('An error occurred while loading the Jobs tab.'), }); } @@ -77,7 +77,7 @@ export default async function initPipelineDetailsBundle() { try { createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS); } catch { - createFlash({ + createAlert({ message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'), }); } diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index b785fd1753c..c77b4813e33 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -12,7 +12,7 @@ export const fetchSummary = ({ state, commit, dispatch }) => { commit(types.SET_SUMMARY, data); }) .catch(() => { - createFlash({ + createAlert({ message: s__('TestReports|There was an error fetching the summary.'), }); }) diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 68ee063dda7..bff30acfe36 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -21,7 +21,7 @@ export default { if (errorMessage) { state.errorMessage = errorMessage; } else { - createFlash({ + createAlert({ message: s__('TestReports|There was an error fetching the test suite.'), }); } diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index c99133fd251..b038b78088f 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,7 +1,7 @@ <script> import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { escape } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, s__, sprintf } from '~/locale'; @@ -85,12 +85,12 @@ Please update your Git repository remotes as soon as possible.`), return axios .put(this.actionUrl, putData) .then((result) => { - createFlash({ message: result.data.message, type: 'notice' }); + createAlert({ message: result.data.message, variant: VARIANT_INFO }); this.username = username; this.isRequestPending = false; }) .catch((error) => { - createFlash({ + createAlert({ message: error?.response?.data?.message || s__('Profiles|An error occurred while updating your username, please try again.'), diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 722f7d467a2..050b004f657 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -165,6 +165,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; setPreview() { const filename = this.fileInput.val().replace(FILENAMEREGEX, ''); this.previewImage.attr('src', this.dataURL); + this.previewImage.attr('srcset', this.dataURL); return this.filename.text(filename); } diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 7542f81a361..a33a20b49f6 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/flash'; import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants'; import IntegrationView from './integration_view.vue'; @@ -94,15 +94,15 @@ export default { return; } updateClasses(this.bodyClasses, this.getSelectedTheme().css_class, this.selectedLayout); - const { message = this.$options.i18n.defaultSuccess, type = FLASH_TYPES.NOTICE } = + const { message = this.$options.i18n.defaultSuccess, variant = VARIANT_INFO } = customEvent?.detail?.[0] || {}; - createFlash({ message, type }); + createAlert({ message, variant }); this.isSubmitEnabled = true; }, handleError(customEvent) { - const { message = this.$options.i18n.defaultError, type = FLASH_TYPES.ALERT } = + const { message = this.$options.i18n.defaultError, variant = VARIANT_DANGER } = customEvent?.detail?.[0] || {}; - createFlash({ message, type }); + createAlert({ message, variant }); this.isSubmitEnabled = true; }, }, @@ -110,7 +110,7 @@ export default { </script> <template> - <div class="row gl-mt-3 js-preferences-form"> + <div class="row gl-mt-3 js-preferences-form js-search-settings-section"> <div v-if="integrationViews.length" class="col-sm-12"> <hr data-testid="profile-preferences-integrations-rule" /> </div> @@ -131,9 +131,9 @@ export default { :message-url="view.message_url" :config="$options.integrationViewConfigs[view.name]" /> - </div> - <div class="col-sm-12"> <hr /> + </div> + <div class="col-sm-12 js-hide-when-nothing-matches-search"> <gl-button category="primary" variant="confirm" diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index af5beeb686c..93bc203d391 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -5,9 +5,6 @@ import axios from '~/lib/utils/axios_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import { parseRailsFormFields } from '~/lib/utils/forms'; import { Rails } from '~/lib/utils/rails_ujs'; -import TimezoneDropdown, { - formatTimezone, -} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; import UserProfileSetStatusWrapper from '~/set_status_modal/user_profile_set_status_wrapper.vue'; export default class Profile { @@ -17,21 +14,12 @@ export default class Profile { this.setRepoRadio(); this.bindEvents(); this.initAvatarGlCrop(); - - this.$inputEl = $('#user_timezone'); - - this.timezoneDropdown = new TimezoneDropdown({ - $inputEl: this.$inputEl, - $dropdownEl: $('.js-timezone-dropdown'), - displayFormat: (selectedItem) => formatTimezone(selectedItem), - allowEmpty: true, - }); } initAvatarGlCrop() { const cropOpts = { filename: '.js-avatar-filename', - previewImage: '.avatar-image .avatar', + previewImage: '.avatar-image .gl-avatar', modalCrop: '.modal-profile-crop', pickImageEl: '.js-choose-user-avatar-button', uploadImageBtn: '.js-upload-user-avatar', diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 09acf98001c..705234537a8 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,7 +1,7 @@ /* eslint-disable func-names */ import $ from 'jquery'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Api from './api'; import { loadCSSFile } from './lib/utils/css_utils'; import { s__ } from './locale'; @@ -67,7 +67,7 @@ const projectSelect = async () => { }, projectsCallback, ).catch(() => { - createFlash({ + createAlert({ message: s__('ProjectSelect|Something went wrong while fetching projects'), }); }); diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js index 2b25082eced..cfff93eac5a 100644 --- a/app/assets/javascripts/projects/commit/store/actions.js +++ b/app/assets/javascripts/projects/commit/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { PROJECT_BRANCHES_ERROR } from '../constants'; import * as types from './mutation_types'; @@ -26,7 +26,7 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => { commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches?.length ? data.Branches : data); }) .catch(() => { - createFlash({ message: PROJECT_BRANCHES_ERROR }); + createAlert({ message: PROJECT_BRANCHES_ERROR }); }); }; diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 4505dd1f85c..2802e4a90b9 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { getQueryHeaders, @@ -59,7 +59,7 @@ export default { return project?.pipeline; }, error() { - createFlash({ message: this.$options.i18n.linkedPipelinesFetchError }); + createAlert({ message: this.$options.i18n.linkedPipelinesFetchError }); }, }, pipelineStages: { @@ -78,7 +78,7 @@ export default { return project?.pipeline?.stages?.nodes || []; }, error() { - createFlash({ message: this.$options.i18n.stagesFetchError }); + createAlert({ message: this.$options.i18n.stagesFetchError }); }, }, }, @@ -108,7 +108,7 @@ export default { try { this.formattedStages = formatStages(this.pipelineStages, this.stages); } catch (error) { - createFlash({ + createAlert({ message: this.$options.i18n.stageConversionError, captureError: true, error, diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue index 5a9d3129809..62b1209131c 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getQueryHeaders, toggleQueryPollingByVisibility, @@ -44,7 +44,7 @@ export default { return project?.pipeline?.detailedStatus || {}; }, error() { - createFlash({ message: this.$options.PIPELINE_STATUS_FETCH_ERROR }); + createAlert({ message: this.$options.PIPELINE_STATUS_FETCH_ERROR }); }, }, }, diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 795c293d14b..603fdfdf80a 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -13,7 +13,7 @@ export default { commit(types.COMMITS_AUTHORS, authors); }, receiveAuthorsError() { - createFlash({ + createAlert({ message: __('An error occurred fetching the project authors.'), }); }, diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index 4ba7156b026..271694863e8 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -1,5 +1,5 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { joinPaths } from '~/lib/utils/url_utility'; import RevisionCard from './revision_card.vue'; @@ -9,6 +9,8 @@ export default { components: { RevisionCard, GlButton, + GlDropdown, + GlDropdownItem, }, props: { projectCompareIndexPath: { @@ -53,6 +55,10 @@ export default { type: Array, required: true, }, + straight: { + type: Boolean, + required: true, + }, }, data() { return { @@ -67,8 +73,27 @@ export default { revision: this.paramsTo, refsProjectPath: this.sourceProjectRefsPath, }, + isStraight: this.straight, }; }, + computed: { + straightModeDropdownItems() { + return [ + { + modeType: 'off', + isEnabled: false, + content: '..', + testId: 'disableStraightModeButton', + }, + { + modeType: 'on', + isEnabled: true, + content: '...', + testId: 'enableStraightModeButton', + }, + ]; + }, + }, methods: { onSubmit() { this.$refs.form.submit(); @@ -85,6 +110,9 @@ export default { onSwapRevision() { [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to' }, + setStraightMode(isStraight) { + this.isStraight = isStraight; + }, }, }; </script> @@ -112,10 +140,22 @@ export default { @selectRevision="onSelectRevision" /> <div - class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-align-self-end gl-my-4 gl-md-my-0" + class="gl-display-flex gl-justify-content-center gl-align-items-center gl-align-self-end gl-my-3 gl-md-my-0 gl-pl-3 gl-pr-3" data-testid="ellipsis" > - ... + <input :value="isStraight ? 'true' : 'false'" type="hidden" name="straight" /> + <gl-dropdown data-testid="modeDropdown" :text="isStraight ? '...' : '..'" size="small"> + <gl-dropdown-item + v-for="mode in straightModeDropdownItems" + :key="mode.modeType" + :is-check-item="true" + :is-checked="isStraight == mode.isEnabled" + :data-testid="mode.testId" + @click="setStraightMode(mode.isEnabled)" + > + <span class="dropdown-menu-inner-content"> {{ mode.content }} </span> + </gl-dropdown-item> + </gl-dropdown> </div> <revision-card data-testid="targetRevisionCard" diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue index f0b8e73e528..10531e950f9 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; @@ -76,7 +76,7 @@ export default { this.tags = data.Tags || []; }) .catch(() => { - createFlash({ + createAlert({ message: s__( 'CompareRevisions|There was an error while searching the branch/tag list. Please try again.', ), @@ -97,7 +97,7 @@ export default { this.tags = data.Tags || []; }) .catch(() => { - createFlash({ + createAlert({ message: s__( 'CompareRevisions|There was an error while loading the branch/tag list. Please try again.', ), diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue index 19cf4cda2be..1e1677e947c 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue @@ -1,6 +1,6 @@ <script> import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; @@ -74,7 +74,7 @@ export default { this.tags = data.Tags || []; }) .catch(() => { - createFlash({ + createAlert({ message: `${s__( 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.', )}`, diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index 074b8565c3c..284cee6d7f1 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CompareApp from './components/app.vue'; export default function init() { @@ -9,6 +10,7 @@ export default function init() { targetProjectRefsPath, paramsFrom, paramsTo, + straight, projectCompareIndexPath, projectMergeRequestPath, createMrPath, @@ -29,6 +31,7 @@ export default function init() { targetProjectRefsPath, paramsFrom, paramsTo, + straight: parseBoolean(straight), projectCompareIndexPath, projectMergeRequestPath, createMrPath, diff --git a/app/assets/javascripts/projects/project_find_file.js b/app/assets/javascripts/projects/project_find_file.js index d295c06928f..71329c4f461 100644 --- a/app/assets/javascripts/projects/project_find_file.js +++ b/app/assets/javascripts/projects/project_find_file.js @@ -2,7 +2,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import $ from 'jquery'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; @@ -89,7 +89,7 @@ export default class ProjectFindFile { this.element.find('.files-slider tr.tree-item').eq(0).addClass('selected').focus(); }) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while loading filenames'), }), ); diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 060178a3cfb..335545c802a 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle, class-methods-use-this */ import { escape, find, countBy } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { n__, s__, __, sprintf } from '~/locale'; import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api'; import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; @@ -326,12 +326,12 @@ export default class AccessDropdown { ); }) .catch(() => { - createFlash({ message: __('Failed to load groups, users and deploy keys.') }); + createAlert({ message: __('Failed to load groups, users and deploy keys.') }); }); } else { getDeployKeys(query) .then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data))) - .catch(() => createFlash({ message: __('Failed to load deploy keys.') })); + .catch(() => createAlert({ message: __('Failed to load deploy keys.') })); } } diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue index 6ba2ef7da99..f2b1c749abc 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue @@ -10,7 +10,7 @@ import { import { createAlert } from '~/flash'; import { s__, sprintf } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; -import branchesQuery from '../queries/branches.query.graphql'; +import branchesQuery from '../../queries/branches.query.graphql'; export const i18n = { fetchBranchesError: s__('BranchRules|An error occurred while fetching branches.'), diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/index.vue index ad3eb7d2899..ad3eb7d2899 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/index.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/index.vue index bcc0f64d667..bcc0f64d667 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/index.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/merge_protections.vue index 85f168af4a8..85f168af4a8 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/merge_protections.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue index 541923bb735..541923bb735 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js new file mode 100644 index 00000000000..264c2629433 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -0,0 +1,42 @@ +import { s__ } from '~/locale'; + +export const I18N = { + manageProtectionsLinkTitle: s__('BranchRules|Manage in Protected Branches'), + targetBranch: s__('BranchRules|Target Branch'), + branchNameOrPattern: s__('BranchRules|Branch name or pattern'), + branch: s__('BranchRules|Target Branch'), + allBranches: s__('BranchRules|All branches'), + protectBranchTitle: s__('BranchRules|Protect branch'), + protectBranchDescription: s__( + 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}', + ), + wildcardsHelpText: s__( + 'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/ are supported', + ), + forcePushTitle: s__('BranchRules|Force push'), + allowForcePushDescription: s__( + 'BranchRules|All users with push access are allowed to force push.', + ), + disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'), + approvalsTitle: s__('BranchRules|Approvals'), + manageApprovalsLinkTitle: s__('BranchRules|Manage in Merge Request Approvals'), + approvalsDescription: s__( + 'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Lean more.%{linkEnd}', + ), + statusChecksTitle: s__('BranchRules|Status checks'), + allowedToPushHeader: s__('BranchRules|Allowed to push (%{total})'), + allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'), + approvalsHeader: s__('BranchRules|Required approvals (%{total})'), + noData: s__('BranchRules|No data to display'), +}; + +export const BRANCH_PARAM_NAME = 'branch'; + +export const ALL_BRANCHES_WILDCARD = '*'; + +export const WILDCARDS_HELP_PATH = + 'user/project/protected_branches#configure-multiple-protected-branches-by-using-a-wildcard'; + +export const PROTECTED_BRANCHES_HELP_PATH = 'user/project/protected_branches'; + +export const APPROVALS_HELP_PATH = 'user/project/merge_requests/approvals/index.md'; 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 new file mode 100644 index 00000000000..318940478a8 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -0,0 +1,207 @@ +<script> +import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import branchRulesQuery from '../../queries/branch_rules_details.query.graphql'; +import Protection from './protection.vue'; +import { + I18N, + ALL_BRANCHES_WILDCARD, + BRANCH_PARAM_NAME, + WILDCARDS_HELP_PATH, + PROTECTED_BRANCHES_HELP_PATH, + APPROVALS_HELP_PATH, +} from './constants'; + +const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH); +const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH); +const approvalsHelpDocLink = helpPagePath(APPROVALS_HELP_PATH); + +export default { + name: 'RuleView', + i18n: I18N, + wildcardsHelpDocLink, + protectedBranchesHelpDocLink, + approvalsHelpDocLink, + components: { Protection, GlSprintf, GlLink, GlLoadingIcon }, + inject: { + projectPath: { + default: '', + }, + protectedBranchesPath: { + default: '', + }, + approvalRulesPath: { + default: '', + }, + }, + apollo: { + project: { + query: branchRulesQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update({ project: { branchRules } }) { + this.branchProtection = branchRules.nodes.find( + (rule) => rule.name === this.branch, + )?.branchProtection; + }, + }, + }, + data() { + return { + branch: getParameterByName(BRANCH_PARAM_NAME), + branchProtection: { + approvalRules: {}, + }, + }; + }, + computed: { + forcePushDescription() { + return this.branchProtection?.allowForcePush + ? this.$options.i18n.allowForcePushDescription + : this.$options.i18n.disallowForcePushDescription; + }, + mergeAccessLevels() { + const { mergeAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(mergeAccessLevels); + }, + pushAccessLevels() { + const { pushAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(pushAccessLevels); + }, + allowedToMergeHeader() { + return sprintf(this.$options.i18n.allowedToMergeHeader, { + total: this.mergeAccessLevels.total, + }); + }, + allowedToPushHeader() { + return sprintf(this.$options.i18n.allowedToPushHeader, { + total: this.pushAccessLevels.total, + }); + }, + approvalsHeader() { + const total = this.approvals.reduce( + (sum, { approvalsRequired }) => sum + approvalsRequired, + 0, + ); + return sprintf(this.$options.i18n.approvalsHeader, { + total, + }); + }, + allBranches() { + return this.branch === ALL_BRANCHES_WILDCARD; + }, + allBranchesLabel() { + return this.$options.i18n.allBranches; + }, + branchTitle() { + return this.allBranches + ? this.$options.i18n.targetBranch + : this.$options.i18n.branchNameOrPattern; + }, + approvals() { + return this.branchProtection?.approvalRules?.nodes || []; + }, + }, + methods: { + getAccessLevels(accessLevels = {}) { + const total = accessLevels.edges?.length; + const accessLevelTypes = { total, users: [], groups: [], roles: [] }; + + accessLevels.edges?.forEach(({ node }) => { + if (node.user) { + const src = node.user.avatarUrl; + accessLevelTypes.users.push({ src, ...node.user }); + } else if (node.group) { + accessLevelTypes.groups.push(node); + } else { + accessLevelTypes.roles.push(node); + } + }); + + return accessLevelTypes; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="$apollo.loading" /> + <div v-else-if="!branchProtection">{{ $options.i18n.noData }}</div> + <div v-else> + <strong data-testid="branch-title">{{ branchTitle }}</strong> + <p v-if="!allBranches" class="gl-mb-3 gl-text-gray-400"> + <gl-sprintf :message="$options.i18n.wildcardsHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.wildcardsHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <div v-if="allBranches" class="gl-mt-2" data-testid="branch"> + {{ allBranchesLabel }} + </div> + <code v-else class="gl-mt-2" data-testid="branch">{{ branch }}</code> + + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.protectBranchTitle }}</h4> + <gl-sprintf :message="$options.i18n.protectBranchDescription"> + <template #link="{ content }"> + <gl-link :href="$options.protectedBranchesHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + + <!-- Allowed to push --> + <protection + class="gl-mt-3" + :header="allowedToPushHeader" + :header-link-title="$options.i18n.manageProtectionsLinkTitle" + :header-link-href="protectedBranchesPath" + :roles="pushAccessLevels.roles" + :users="pushAccessLevels.users" + :groups="pushAccessLevels.groups" + /> + + <!-- Force push --> + <strong>{{ $options.i18n.forcePushTitle }}</strong> + <p>{{ forcePushDescription }}</p> + + <!-- Allowed to merge --> + <protection + :header="allowedToMergeHeader" + :header-link-title="$options.i18n.manageProtectionsLinkTitle" + :header-link-href="protectedBranchesPath" + :roles="mergeAccessLevels.roles" + :users="mergeAccessLevels.users" + :groups="mergeAccessLevels.groups" + /> + + <!-- Approvals --> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4> + <gl-sprintf :message="$options.i18n.approvalsDescription"> + <template #link="{ content }"> + <gl-link :href="$options.approvalsHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + + <protection + class="gl-mt-3" + :header="approvalsHeader" + :header-link-title="$options.i18n.manageApprovalsLinkTitle" + :header-link-href="approvalRulesPath" + :approvals="approvals" + /> + + <!-- Status checks --> + <!-- Follow-up: add status checks section (https://gitlab.com/gitlab-org/gitlab/-/issues/372362) --> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue new file mode 100644 index 00000000000..cfe2df0dbda --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection.vue @@ -0,0 +1,99 @@ +<script> +import { GlCard, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ProtectionRow from './protection_row.vue'; + +export const i18n = { + rolesTitle: s__('BranchRules|Roles'), + usersTitle: s__('BranchRules|Users'), + groupsTitle: s__('BranchRules|Groups'), +}; + +export default { + name: 'ProtectionDetail', + i18n, + components: { GlCard, GlLink, ProtectionRow }, + props: { + header: { + type: String, + required: true, + }, + headerLinkTitle: { + type: String, + required: true, + }, + headerLinkHref: { + type: String, + required: true, + }, + roles: { + type: Array, + required: false, + default: () => [], + }, + users: { + type: Array, + required: false, + default: () => [], + }, + groups: { + type: Array, + required: false, + default: () => [], + }, + approvals: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + showUsersDivider() { + return Boolean(this.roles.length); + }, + showGroupsDivider() { + return Boolean(this.roles.length || this.users.length); + }, + }, +}; +</script> + +<template> + <gl-card class="gl-mb-5" body-class="gl-py-0"> + <template #header> + <div class="gl-display-flex gl-justify-content-space-between"> + <strong>{{ header }}</strong> + <gl-link :href="headerLinkHref">{{ headerLinkTitle }}</gl-link> + </div> + </template> + + <!-- Roles --> + <protection-row v-if="roles.length" :title="$options.i18n.rolesTitle" :access-levels="roles" /> + + <!-- Users --> + <protection-row + v-if="users.length" + :show-divider="showUsersDivider" + :users="users" + :title="$options.i18n.usersTitle" + /> + + <!-- Groups --> + <protection-row + v-if="groups.length" + :show-divider="showGroupsDivider" + :title="$options.i18n.groupsTitle" + :access-levels="groups" + /> + + <!-- Approvals --> + <protection-row + v-for="(approval, index) in approvals" + :key="approval.name" + :show-divider="index !== 0" + :title="approval.name" + :users="approval.eligibleApprovers.nodes" + :approvals-required="approval.approvalsRequired" + /> + </gl-card> +</template> 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 new file mode 100644 index 00000000000..28a1c09fa82 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue @@ -0,0 +1,110 @@ +<script> +import { GlAvatarsInline, GlAvatar, GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +const AVATAR_TOOLTIP_MAX_CHARS = 100; +export const MAX_VISIBLE_AVATARS = 4; +export const AVATAR_SIZE = 32; + +export default { + name: 'ProtectionRow', + AVATAR_TOOLTIP_MAX_CHARS, + MAX_VISIBLE_AVATARS, + AVATAR_SIZE, + components: { GlAvatarsInline, GlAvatar, GlAvatarLink }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: false, + default: null, + }, + accessLevels: { + type: Array, + required: false, + default: () => [], + }, + showDivider: { + type: Boolean, + required: false, + default: false, + }, + users: { + type: Array, + required: false, + default: () => [], + }, + approvalsRequired: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + avatarBadgeSrOnlyText() { + return n__( + '%d additional user', + '%d additional users', + this.users.length - this.$options.MAX_VISIBLE_AVATARS, + ); + }, + commaSeparateList() { + return this.accessLevels.length > 1; + }, + approvalsRequiredTitle() { + return this.approvalsRequired + ? n__('%d approval required', '%d approvals required', this.approvalsRequired) + : null; + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-border-gray-100 gl-mb-4 gl-pt-4" + :class="{ 'gl-border-t-solid': showDivider }" + > + <div class="gl-display-flex gl-w-half gl-justify-content-space-between"> + <div class="gl-mr-7 gl-w-quarter">{{ title }}</div> + + <gl-avatars-inline + v-if="users.length" + class="gl-w-quarter!" + :avatars="users" + :collapsed="true" + :max-visible="$options.MAX_VISIBLE_AVATARS" + :avatar-size="$options.AVATAR_SIZE" + badge-tooltip-prop="name" + :badge-tooltip-max-chars="$options.AVATAR_TOOLTIP_MAX_CHARS" + :badge-sr-only-text="avatarBadgeSrOnlyText" + > + <template #avatar="{ avatar }"> + <gl-avatar-link + :key="avatar.username" + v-gl-tooltip + target="_blank" + :href="avatar.webUrl" + :title="avatar.name" + > + <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="$options.AVATAR_SIZE" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + + <div + v-for="(item, index) in accessLevels" + :key="index" + data-testid="access-level" + class="gl-w-quarter" + > + <span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span> + {{ item.accessLevelDescription }} + </div> + + <div class="gl-ml-7 gl-w-quarter">{{ approvalsRequiredTitle }}</div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 8452542540e..07fd0a7080f 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import RuleEdit from './components/rule_edit.vue'; +import View from './components/view/index.vue'; export default function mountBranchRules(el) { if (!el) { @@ -14,13 +14,18 @@ export default function mountBranchRules(el) { defaultClient: createDefaultClient(), }); - const { projectPath } = el.dataset; + const { projectPath, protectedBranchesPath, approvalRulesPath } = el.dataset; return new Vue({ el, apolloProvider, + provide: { + projectPath, + protectedBranchesPath, + approvalRulesPath, + }, render(h) { - return h(RuleEdit, { props: { projectPath } }); + return h(View); }, }); } diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql new file mode 100644 index 00000000000..3ac165498a1 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql @@ -0,0 +1,50 @@ +query getBranchRulesDetails($projectPath: ID!) { + project(fullPath: $projectPath) { + id + branchRules { + nodes { + name + branchProtection { + allowForcePush + codeOwnerApprovalRequired + mergeAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + group { + id + avatarUrl + } + user { + id + name + avatarUrl + webUrl + } + } + } + } + pushAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + group { + id + avatarUrl + } + user { + id + name + avatarUrl + webUrl + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 2209172c06d..cc47496971d 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -9,7 +9,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__, n__ } from '~/locale'; import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api'; import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants'; @@ -163,7 +163,7 @@ export default { this.setSelected({ initial }); }) .catch(() => - createFlash({ message: __('Failed to load groups, users and deploy keys.') }), + createAlert({ message: __('Failed to load groups, users and deploy keys.') }), ) .finally(() => { this.initialLoading = false; @@ -175,7 +175,7 @@ export default { this.consolidateData(deployKeysResponse.data); this.setSelected({ initial }); }) - .catch(() => createFlash({ message: __('Failed to load deploy keys.') })) + .catch(() => createAlert({ message: __('Failed to load deploy keys.') })) .finally(() => { this.initialLoading = false; this.loading = false; diff --git a/app/assets/javascripts/projects/settings/components/default_branch_selector.vue b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue new file mode 100644 index 00000000000..fee2f591216 --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue @@ -0,0 +1,38 @@ +<script> +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES } from '~/ref/constants'; +import { __ } from '~/locale'; + +export default { + components: { + RefSelector, + }, + props: { + persistedDefaultBranch: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + }, + refTypes: [REF_TYPE_BRANCHES], + i18n: { + dropdownHeader: __('Select default branch'), + searchPlaceholder: __('Search branch'), + }, +}; +</script> +<template> + <ref-selector + :value="persistedDefaultBranch" + class="gl-w-full" + :project-id="projectId" + :enabled-ref-types="$options.refTypes" + :translations="$options.i18n" + name="project[default_branch]" + data-testid="default-branch-dropdown" + data-qa-selector="default_branch_dropdown" + /> +</template> diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index c13753da00b..55420c9c732 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -1,13 +1,14 @@ <script> -import { GlFormGroup } from '@gitlab/ui'; -import produce from 'immer'; +import { GlFormGroup, GlAlert } from '@gitlab/ui'; +import { debounce } from 'lodash'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getTransferLocations } from '~/api/projects_api'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import searchNamespacesWhereUserCanTransferProjects from '../graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql'; - -const GROUPS_PER_PAGE = 25; +import { s__, __ } from '~/locale'; +import currentUserNamespace from '../graphql/queries/current_user_namespace.query.graphql'; export default { name: 'TransferProjectForm', @@ -15,7 +16,15 @@ export default { GlFormGroup, NamespaceSelect, ConfirmDanger, + GlAlert, + }, + i18n: { + errorMessage: s__( + 'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.', + ), + alertDismissAlert: __('Dismiss'), }, + inject: ['projectId'], props: { confirmationPhrase: { type: String, @@ -26,93 +35,131 @@ export default { required: true, }, }, - apollo: { - currentUser: { - query: searchNamespacesWhereUserCanTransferProjects, - debounce: DEBOUNCE_DELAY, - variables() { - return { - search: this.searchTerm, - after: null, - first: GROUPS_PER_PAGE, - }; - }, - result() { - this.isLoadingMoreGroups = false; - this.isSearchLoading = false; - }, - }, - }, data() { return { - currentUser: {}, + userNamespaces: [], + groupNamespaces: [], + initialNamespacesLoaded: false, selectedNamespace: null, - isLoadingMoreGroups: false, + hasError: false, + isLoading: false, isSearchLoading: false, searchTerm: '', + page: 1, + totalPages: 1, }; }, computed: { hasSelectedNamespace() { return Boolean(this.selectedNamespace?.id); }, - groupNamespaces() { - return this.currentUser.groups?.nodes?.map(this.formatNamespace) || []; - }, - userNamespaces() { - const { namespace } = this.currentUser; - - return namespace ? [this.formatNamespace(namespace)] : []; - }, hasNextPageOfGroups() { - return this.currentUser.groups?.pageInfo?.hasNextPage || false; + return this.page < this.totalPages; }, }, methods: { + async handleShow() { + if (this.initialNamespacesLoaded) { + return; + } + + this.isLoading = true; + + [this.groupNamespaces, this.userNamespaces] = await Promise.all([ + this.getGroupNamespaces(), + this.getUserNamespaces(), + ]); + + this.isLoading = false; + this.initialNamespacesLoaded = true; + }, handleSelect(selectedNamespace) { this.selectedNamespace = selectedNamespace; this.$emit('selectNamespace', selectedNamespace.id); }, - handleLoadMoreGroups() { - this.isLoadingMoreGroups = true; + async getGroupNamespaces() { + try { + const { data: groupNamespaces, headers } = await getTransferLocations(this.projectId, { + page: this.page, + search: this.searchTerm, + }); + + const { totalPages } = parseIntPagination(normalizeHeaders(headers)); + this.totalPages = totalPages; - this.$apollo.queries.currentUser.fetchMore({ - variables: { - after: this.currentUser.groups.pageInfo.endCursor, - first: GROUPS_PER_PAGE, - }, - updateQuery( - previousResult, + return groupNamespaces.map(({ id, full_name: humanName }) => ({ + id, + humanName, + })); + } catch (error) { + this.hasError = true; + + return []; + } + }, + async getUserNamespaces() { + try { + const { + data: { + currentUser: { namespace }, + }, + } = await this.$apollo.query({ + query: currentUserNamespace, + }); + + if (!namespace) { + return []; + } + + return [ { - fetchMoreResult: { - currentUser: { groups: newGroups }, - }, + id: getIdFromGraphQLId(namespace.id), + humanName: namespace.fullName, }, - ) { - const previousGroups = previousResult.currentUser.groups; + ]; + } catch (error) { + this.hasError = true; - return produce(previousResult, (draftData) => { - draftData.currentUser.groups.nodes = [...previousGroups.nodes, ...newGroups.nodes]; - draftData.currentUser.groups.pageInfo = newGroups.pageInfo; - }); - }, - }); + return []; + } }, - handleSearch(searchTerm) { + async handleLoadMoreGroups() { + this.isLoading = true; + this.page += 1; + + const groupNamespaces = await this.getGroupNamespaces(); + this.groupNamespaces.push(...groupNamespaces); + + this.isLoading = false; + }, + debouncedSearch: debounce(async function debouncedSearch() { this.isSearchLoading = true; + + this.groupNamespaces = await this.getGroupNamespaces(); + + this.isSearchLoading = false; + }, DEBOUNCE_DELAY), + handleSearch(searchTerm) { this.searchTerm = searchTerm; + this.page = 1; + + this.debouncedSearch(); }, - formatNamespace({ id, fullName }) { - return { - id: getIdFromGraphQLId(id), - humanName: fullName, - }; + handleAlertDismiss() { + this.hasError = false; }, }, }; </script> <template> <div> + <gl-alert + v-if="hasError" + variant="danger" + :dismiss-label="$options.i18n.alertDismissLabel" + @dismiss="handleAlertDismiss" + >{{ $options.i18n.errorMessage }}</gl-alert + > <gl-form-group> <namespace-select data-testid="transfer-project-namespace" @@ -121,12 +168,13 @@ export default { :user-namespaces="userNamespaces" :selected-namespace="selectedNamespace" :has-next-page-of-groups="hasNextPageOfGroups" - :is-loading-more-groups="isLoadingMoreGroups" + :is-loading="isLoading" :is-search-loading="isSearchLoading" :should-filter-namespaces="false" @select="handleSelect" @load-more-groups="handleLoadMoreGroups" @search="handleSearch" + @show="handleShow" /> </gl-form-group> <confirm-danger diff --git a/app/assets/javascripts/projects/settings/graphql/queries/current_user_namespace.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/current_user_namespace.query.graphql new file mode 100644 index 00000000000..7ae6ee1428b --- /dev/null +++ b/app/assets/javascripts/projects/settings/graphql/queries/current_user_namespace.query.graphql @@ -0,0 +1,9 @@ +query currentUserNamespace { + currentUser { + id + namespace { + id + fullName + } + } +} diff --git a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql deleted file mode 100644 index d4bcb8c869c..00000000000 --- a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql +++ /dev/null @@ -1,24 +0,0 @@ -#import "~/graphql_shared/fragments/page_info.fragment.graphql" - -query searchNamespacesWhereUserCanTransferProjects( - $search: String = "" - $after: String = "" - $first: Int = null -) { - currentUser { - id - groups(permissionScope: TRANSFER_PROJECTS, search: $search, after: $after, first: $first) { - nodes { - id - fullName - } - pageInfo { - ...PageInfo - } - } - namespace { - id - fullName - } - } -} diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js index bc1aff640d2..89c158a9ba8 100644 --- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -12,6 +12,7 @@ export default () => { Vue.use(VueApollo); const { + projectId, targetFormId = null, targetHiddenInputId = null, buttonText: confirmButtonText = '', @@ -26,6 +27,7 @@ export default () => { }), provide: { confirmDangerMessage, + projectId, }, render(createElement) { return createElement(TransferProjectForm, { diff --git a/app/assets/javascripts/projects/settings/mount_default_branch_selector.js b/app/assets/javascripts/projects/settings/mount_default_branch_selector.js new file mode 100644 index 00000000000..611561e38f2 --- /dev/null +++ b/app/assets/javascripts/projects/settings/mount_default_branch_selector.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import DefaultBranchSelector from './components/default_branch_selector.vue'; + +export default (el) => { + if (!el) { + return null; + } + + const { projectId, defaultBranch } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(DefaultBranchSelector, { + props: { + persistedDefaultBranch: defaultBranch, + projectId, + }, + }); + }, + }); +}; 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 e8eaf0a70b2..94793a535cc 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import branchRulesQuery from './graphql/queries/branch_rules.query.graphql'; import BranchRule from './components/branch_rule.vue'; @@ -31,14 +31,13 @@ export default { return data.project?.branchRules?.nodes || []; }, error() { - createFlash({ message: this.$options.i18n.queryError }); + createAlert({ message: this.$options.i18n.queryError }); }, }, }, - props: { + inject: { projectPath: { - type: String, - required: true, + default: '', }, }, data() { 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 68750318029..2b88f8561d7 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 @@ -1,10 +1,11 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; export const i18n = { defaultLabel: s__('BranchRules|default'), protectedLabel: s__('BranchRules|protected'), + detailsButtonLabel: s__('BranchRules|Details'), }; export default { @@ -12,6 +13,12 @@ export default { i18n, components: { GlBadge, + GlButton, + }, + inject: { + branchRulesPath: { + default: '', + }, }, props: { name: { @@ -38,24 +45,30 @@ export default { hasApprovalDetails() { return this.approvalDetails && this.approvalDetails.length; }, + detailsPath() { + return `${this.branchRulesPath}?branch=${this.name}`; + }, }, }; </script> <template> - <div class="gl-border-b gl-pt-5 gl-pb-5"> - <strong class="gl-font-monospace">{{ name }}</strong> + <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"> + <div> + <strong class="gl-font-monospace">{{ name }}</strong> - <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ - $options.i18n.defaultLabel - }}</gl-badge> + <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ + $options.i18n.defaultLabel + }}</gl-badge> - <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ - $options.i18n.protectedLabel - }}</gl-badge> + <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ + $options.i18n.protectedLabel + }}</gl-badge> - <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> - <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> - </ul> + <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> + <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> + </ul> + </div> + <gl-button :href="detailsPath"> {{ $options.i18n.detailsButtonLabel }}</gl-button> </div> </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js index 35322e2e466..042be089e09 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js @@ -12,17 +12,17 @@ const apolloProvider = new VueApollo({ export default function mountBranchRules(el) { if (!el) return null; - const { projectPath } = el.dataset; + const { projectPath, branchRulesPath } = el.dataset; return new Vue({ el, apolloProvider, + provide: { + projectPath, + branchRulesPath, + }, render(createElement) { - return createElement(BranchRulesApp, { - props: { - projectPath, - }, - }); + return createElement(BranchRulesApp); }, }); } diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js index e063064663b..55c3d68cd11 100644 --- a/app/assets/javascripts/projects/star.js +++ b/app/assets/javascripts/projects/star.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; @@ -7,7 +7,7 @@ export default class Star { constructor(containerSelector = '.project-home-panel') { const container = document.querySelector(containerSelector); const starToggle = container.querySelector('.toggle-star'); - starToggle.addEventListener('click', function toggleStarClickCallback() { + starToggle?.addEventListener('click', function toggleStarClickCallback() { const starSpan = starToggle.querySelector('span'); const starIcon = starToggle.querySelector('svg'); const iconClasses = Array.from(starIcon.classList.values()); @@ -34,7 +34,7 @@ export default class Star { } }) .catch(() => - createFlash({ + createAlert({ message: __('Star toggle failed. Try again later.'), }), ); diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index 6b14ebadacc..9f9b6424125 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -57,7 +57,7 @@ export default { group: 'notfound', }; this.isLoading = false; - createFlash({ + createAlert({ message: __('Something went wrong on our end'), }); }, diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 16eb5c3de32..120f75d4f0c 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import CreateItemDropdown from '~/create_item_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -74,7 +74,7 @@ export default class ProtectedBranchCreate { $allowedToPush.length ); - this.$form.find('input[type="submit"]').attr('disabled', toggle); + this.$form.find('button[type="submit"]').attr('disabled', toggle); } static getProtectedBranches(term, callback) { @@ -130,7 +130,7 @@ export default class ProtectedBranchCreate { window.location.reload(); }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to protect the branch'), }), ); diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 15e706e38c6..1693d869b54 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -1,5 +1,5 @@ import { find } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import AccessDropdown from '~/projects/settings/access_dropdown'; @@ -74,7 +74,7 @@ export default class ProtectedBranchEdit { }) .then(callback) .catch(() => { - createFlash({ message: __('Failed to update branch!') }); + createAlert({ message: __('Failed to update branch!') }); }); } @@ -141,7 +141,7 @@ export default class ProtectedBranchEdit { .catch(() => { this.$allowedToMergeDropdown.enable(); this.$allowedToPushDropdown.enable(); - createFlash({ message: __('Failed to update branch!') }); + createAlert({ message: __('Failed to update branch!') }); }); } diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js index 1fe9a753e1e..40c52eba99e 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '../lib/utils/axios_utils'; import { FAILED_TO_UPDATE_TAG_MESSAGE } from './constants'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; @@ -49,7 +49,7 @@ export default class ProtectedTagEdit { this.$allowedToCreateDropdownButton.enable(); window.scrollTo({ top: 0, behavior: 'smooth' }); - createFlash({ + createAlert({ message: FAILED_TO_UPDATE_TAG_MESSAGE, }); }); diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 1343ad8246c..b75958e2ced 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -29,6 +29,7 @@ export default { GlLoadingIcon, RefResultsSection, }, + inheritAttrs: false, props: { enabledRefTypes: { type: Array, @@ -70,6 +71,15 @@ export default { required: false, default: true, }, + + /* Underlying form field name for scenarios where ref_selector + * is used as part of submitting an HTML form + */ + name: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -213,89 +223,103 @@ export default { </script> <template> - <gl-dropdown - :header-text="i18n.dropdownHeader" - :toggle-class="toggleButtonClass" - :text="buttonText" - class="ref-selector" - v-bind="$attrs" - v-on="$listeners" - @shown="focusSearchBox" - > - <template #header> - <gl-search-box-by-type - ref="searchBox" - v-model.trim="query" - :placeholder="i18n.searchPlaceholder" - autocomplete="off" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> - </template> + <div> + <gl-dropdown + :header-text="i18n.dropdownHeader" + :toggle-class="toggleButtonClass" + :text="buttonText" + class="ref-selector gl-w-full" + v-bind="$attrs" + v-on="$listeners" + @shown="focusSearchBox" + > + <template #header> + <gl-search-box-by-type + ref="searchBox" + v-model.trim="query" + :placeholder="i18n.searchPlaceholder" + autocomplete="off" + data-qa-selector="ref_selector_searchbox" + @input="onSearchBoxInput" + @keydown.enter.prevent="onSearchBoxEnter" + /> + </template> - <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> - <div v-else-if="showNoResults" class="gl-text-center gl-mx-3 gl-py-3" data-testid="no-results"> - <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> - <template #query> - <b class="gl-word-break-all">{{ lastQuery }}</b> - </template> - </gl-sprintf> + <div + v-else-if="showNoResults" + class="gl-text-center gl-mx-3 gl-py-3" + data-testid="no-results" + > + <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> + <template #query> + <b class="gl-word-break-all">{{ lastQuery }}</b> + </template> + </gl-sprintf> - <span v-else>{{ i18n.noResults }}</span> - </div> + <span v-else>{{ i18n.noResults }}</span> + </div> - <template v-else> - <template v-if="showBranchesSection"> - <ref-results-section - :section-title="i18n.branches" - :total-count="matches.branches.totalCount" - :items="matches.branches.list" - :selected-ref="selectedRef" - :error="matches.branches.error" - :error-message="i18n.branchesErrorMessage" - :show-header="showSectionHeaders" - data-testid="branches-section" - data-qa-selector="branches_section" - @selected="selectRef($event)" - /> + <template v-else> + <template v-if="showBranchesSection"> + <ref-results-section + :section-title="i18n.branches" + :total-count="matches.branches.totalCount" + :items="matches.branches.list" + :selected-ref="selectedRef" + :error="matches.branches.error" + :error-message="i18n.branchesErrorMessage" + :show-header="showSectionHeaders" + data-testid="branches-section" + data-qa-selector="branches_section" + @selected="selectRef($event)" + /> - <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> - </template> + <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> + </template> - <template v-if="showTagsSection"> - <ref-results-section - :section-title="i18n.tags" - :total-count="matches.tags.totalCount" - :items="matches.tags.list" - :selected-ref="selectedRef" - :error="matches.tags.error" - :error-message="i18n.tagsErrorMessage" - :show-header="showSectionHeaders" - data-testid="tags-section" - @selected="selectRef($event)" - /> + <template v-if="showTagsSection"> + <ref-results-section + :section-title="i18n.tags" + :total-count="matches.tags.totalCount" + :items="matches.tags.list" + :selected-ref="selectedRef" + :error="matches.tags.error" + :error-message="i18n.tagsErrorMessage" + :show-header="showSectionHeaders" + data-testid="tags-section" + @selected="selectRef($event)" + /> - <gl-dropdown-divider v-if="showCommitsSection" /> - </template> + <gl-dropdown-divider v-if="showCommitsSection" /> + </template> - <template v-if="showCommitsSection"> - <ref-results-section - :section-title="i18n.commits" - :total-count="matches.commits.totalCount" - :items="matches.commits.list" - :selected-ref="selectedRef" - :error="matches.commits.error" - :error-message="i18n.commitsErrorMessage" - :show-header="showSectionHeaders" - data-testid="commits-section" - @selected="selectRef($event)" - /> + <template v-if="showCommitsSection"> + <ref-results-section + :section-title="i18n.commits" + :total-count="matches.commits.totalCount" + :items="matches.commits.list" + :selected-ref="selectedRef" + :error="matches.commits.error" + :error-message="i18n.commitsErrorMessage" + :show-header="showSectionHeaders" + data-testid="commits-section" + @selected="selectRef($event)" + /> + </template> </template> - </template> - <template #footer> - <slot name="footer" v-bind="footerSlotProps"></slot> - </template> - </gl-dropdown> + <template #footer> + <slot name="footer" v-bind="footerSlotProps"></slot> + </template> + </gl-dropdown> + <input + v-if="name" + data-testid="selected-ref-form-field" + type="hidden" + :value="selectedRef" + :name="name" + /> + </div> </template> 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 53f2dbbbbd7..1ab41ee2f0a 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -233,6 +233,7 @@ export default { <div v-if="isFormVisible" class="js-add-related-issues-form-area card-body bordered-box bg-white" + :class="{ 'gl-mb-5': shouldShowTokenBody }" > <add-issuable-form :show-categorized-issues="showCategorizedIssues" @@ -253,7 +254,7 @@ export default { </div> <template v-if="shouldShowTokenBody"> <related-issues-list - v-for="category in categorisedIssues" + v-for="(category, index) in categorisedIssues" :key="category.linkType" :list-link-type="category.linkType" :heading="$options.linkedIssueTypesTextMap[category.linkType]" @@ -263,6 +264,7 @@ export default { :issuable-type="issuableType" :path-id-separator="pathIdSeparator" :related-issues="category.issues" + :class="{ 'gl-mt-5': index > 0 }" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" @saveReorder="$emit('saveReorder', $event)" /> diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index ae40232df6f..38e1d6e9d4f 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -23,7 +23,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: and hide the `AddIssuableForm` area. */ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { @@ -141,11 +141,11 @@ export default { }) .catch((res) => { if (res && res.status !== 404) { - createFlash({ message: relatedIssuesRemoveErrorMap[this.issuableType] }); + createAlert({ message: relatedIssuesRemoveErrorMap[this.issuableType] }); } }); } else { - createFlash({ message: pathIndeterminateErrorMap[this.issuableType] }); + createAlert({ message: pathIndeterminateErrorMap[this.issuableType] }); } }, onToggleAddRelatedIssuesForm() { @@ -174,7 +174,7 @@ export default { if (response && response.data && response.data.message) { errorMessage = response.data.message; } - createFlash({ message: errorMessage }); + createAlert({ message: errorMessage }); }) .finally(() => { this.isSubmitting = false; @@ -195,7 +195,7 @@ export default { }) .catch(() => { this.store.setRelatedIssues([]); - createFlash({ message: __('An error occurred while fetching issues.') }); + createAlert({ message: __('An error occurred while fetching issues.') }); }) .finally(() => { this.isFetching = false; @@ -216,7 +216,7 @@ export default { } }) .catch(() => { - createFlash({ message: __('An error occurred while reordering issues.') }); + createAlert({ message: __('An error occurred while reordering issues.') }); }); } }, diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index d63a83d1a08..6dc8240e680 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { historyPushState } from '~/lib/utils/common_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; @@ -71,7 +71,7 @@ export default { error(error) { this.fullRequestError = true; - createFlash({ + createAlert({ message: this.$options.i18n.errorMessage, captureError: true, error, diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index fdb0f99b735..7147cfa01c8 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,5 +1,5 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import oneReleaseQuery from '../graphql/queries/one_release.query.graphql'; import { convertGraphQLRelease } from '../util'; @@ -51,7 +51,7 @@ export default { }, methods: { showFlash(error) { - createFlash({ + createAlert({ message: s__('Release|Something went wrong while getting the release details.'), captureError: true, error, diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 08b727dcca0..2ddab5dddea 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -1,8 +1,15 @@ <script> -import { GlFormGroup, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { + GlCollapse, + GlLink, + GlFormGroup, + GlFormTextarea, + GlDropdownItem, + GlSprintf, +} from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { mapState, mapActions, mapGetters } from 'vuex'; -import { __ } from '~/locale'; +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'; @@ -10,7 +17,10 @@ import FormFieldContainer from './form_field_container.vue'; export default { name: 'TagFieldNew', components: { + GlCollapse, GlFormGroup, + GlFormTextarea, + GlLink, RefSelector, FormFieldContainer, GlDropdownItem, @@ -41,6 +51,14 @@ export default { this.updateShowCreateFrom(false); }, }, + tagMessage: { + get() { + return this.release.tagMessage; + }, + set(tagMessage) { + this.updateReleaseTagMessage(tagMessage); + }, + }, createFromModel: { get() { return this.createFrom; @@ -70,6 +88,7 @@ export default { methods: { ...mapActions('editNew', [ 'updateReleaseTagName', + 'updateReleaseTagMessage', 'updateCreateFrom', 'fetchTagNotes', 'updateShowCreateFrom', @@ -113,9 +132,20 @@ export default { noRefSelected: __('No source selected'), searchPlaceholder: __('Search branches, tags, and commits'), dropdownHeader: __('Select source'), + label: __('Create from'), + description: __('Existing branch name, tag, or commit SHA'), + }, + 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}.', + ), }, }, + tagMessageId: uniqueId('tag-message-'), + tagNameEnabledRefTypes: [REF_TYPE_TAGS], + gitTagDocsLink: 'https://git-scm.com/book/en/v2/Git-Basics-Tagging/', }; </script> <template> @@ -156,23 +186,45 @@ export default { </ref-selector> </form-field-container> </gl-form-group> - <gl-form-group - v-if="showCreateFrom" - :label="__('Create from')" - :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> - {{ __('Existing branch name, tag, or commit SHA') }} - </template> - </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/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 669e5928143..42ceed81c00 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -1,5 +1,5 @@ import { getTag } from '~/rest_api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; @@ -48,7 +48,7 @@ export const fetchRelease = async ({ commit, state }) => { commit(types.RECEIVE_RELEASE_SUCCESS, release); } catch (error) { commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash({ + createAlert({ message: s__('Release|Something went wrong while getting the release details.'), }); } @@ -57,6 +57,9 @@ export const fetchRelease = async ({ commit, state }) => { export const updateReleaseTagName = ({ commit }, tagName) => commit(types.UPDATE_RELEASE_TAG_NAME, tagName); +export const updateReleaseTagMessage = ({ commit }, tagMessage) => + commit(types.UPDATE_RELEASE_TAG_MESSAGE, tagMessage); + export const updateCreateFrom = ({ commit }, createFrom) => commit(types.UPDATE_CREATE_FROM, createFrom); @@ -133,11 +136,11 @@ export const createRelease = async ({ commit, dispatch, getters }) => { } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); if (error instanceof GraphQLError) { - createFlash({ + createAlert({ message: error.message, }); } else { - createFlash({ + createAlert({ message: s__('Release|Something went wrong while creating a new release.'), }); } @@ -219,7 +222,7 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => { dispatch('receiveSaveReleaseSuccess', state.release._links.self); } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash({ + createAlert({ message: s__('Release|Something went wrong while saving the release details.'), }); } @@ -233,7 +236,7 @@ export const fetchTagNotes = ({ commit, state }, tagName) => { commit(types.RECEIVE_TAG_NOTES_SUCCESS, data); }) .catch((error) => { - createFlash({ + createAlert({ message: s__('Release|Unable to fetch the tag notes.'), }); @@ -266,7 +269,7 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => { }) .catch((error) => { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash({ + createAlert({ message: s__('Release|Something went wrong while deleting the release.'), }); }); diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index ccca9ca8250..0d77095d099 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -145,6 +145,7 @@ export const releaseCreateMutatationVariables = (state, getters) => { input: { ...getters.releaseUpdateMutatationVariables.input, ref: state.createFrom, + tagMessage: state.release.tagMessage, assets: { links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({ name: name.trim(), 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 0ef017f4eb4..e52eccd6a21 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 @@ -5,6 +5,7 @@ export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; export const UPDATE_RELEASE_TAG_NAME = 'UPDATE_RELEASE_TAG_NAME'; +export const UPDATE_RELEASE_TAG_MESSAGE = 'UPDATE_RELEASE_TAG_MESSAGE'; export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM'; export const UPDATE_SHOW_CREATE_FROM = 'UPDATE_SHOW_CREATE_FROM'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; 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 34361f84a5a..f80e75501c9 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -10,6 +10,7 @@ export default { [types.INITIALIZE_EMPTY_RELEASE](state) { state.release = { tagName: state.tagName, + tagMessage: '', name: '', description: '', milestones: [], @@ -40,6 +41,9 @@ export default { [types.UPDATE_RELEASE_TAG_NAME](state, tagName) { state.release.tagName = tagName; }, + [types.UPDATE_RELEASE_TAG_MESSAGE](state, tagMessage) { + state.release.tagMessage = tagMessage; + }, [types.UPDATE_CREATE_FROM](state, createFrom) { state.createFrom = createFrom; }, 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 11a2f9df59b..3112becfa9e 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -37,7 +37,7 @@ export default ({ * When creating a new release, this is the default from the URL */ tagName, - showCreateFrom: !tagName, + showCreateFrom: false, defaultBranch, createFrom: defaultBranch, diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue deleted file mode 100644 index 05ab5c2cc90..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -import { GlBadge, GlLink } from '@gitlab/ui'; - -export default { - name: 'AccessibilityIssueBody', - components: { - GlBadge, - GlLink, - }, - props: { - issue: { - type: Object, - required: true, - }, - isNew: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - parsedTECHSCode() { - /* - * In issue code looks like "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" - * or "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent" - * - * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. - * Here we simply split the string on `.` and get the code in the 5th position - */ - return this.issue.code?.split('.')[4]; - }, - learnMoreUrl() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`; - }, - }, -}; -</script> -<template> - <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> - <div ref="accessibility-issue-description" class="report-block-list-issue-description-text"> - <gl-badge v-if="isNew" class="gl-mr-2" variant="danger">{{ - s__('AccessibilityReport|New') - }}</gl-badge> - <div> - {{ - sprintf( - s__( - 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}', - ), - { code: issue.code }, - ) - }} - <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ - s__('AccessibilityReport|Learn more') - }}</gl-link> - </div> - {{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }} - </div> - </div> -</template> diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue deleted file mode 100644 index 99cdeae545e..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { mapActions, mapGetters } from 'vuex'; -import { componentNames } from '~/reports/components/issue_body'; -import IssuesList from '~/reports/components/issues_list.vue'; -import ReportSection from '~/reports/components/report_section.vue'; -import createStore from './store'; - -export default { - name: 'GroupedAccessibilityReportsApp', - store: createStore(), - components: { - ReportSection, - IssuesList, - }, - props: { - endpoint: { - type: String, - required: true, - }, - }, - componentNames, - computed: { - ...mapGetters([ - 'summaryStatus', - 'groupedSummaryText', - 'shouldRenderIssuesList', - 'unresolvedIssues', - 'resolvedIssues', - 'newIssues', - ]), - }, - created() { - this.setEndpoint(this.endpoint); - - this.fetchReport(); - }, - methods: { - ...mapActions(['fetchReport', 'setEndpoint']), - }, -}; -</script> -<template> - <report-section - :status="summaryStatus" - :success-text="groupedSummaryText" - :loading-text="groupedSummaryText" - :error-text="groupedSummaryText" - :has-issues="shouldRenderIssuesList" - track-action="users_expanding_testing_accessibility_report" - class="mr-widget-section grouped-security-reports mr-report" - > - <template #body> - <div class="mr-widget-grouped-section report-block"> - <issues-list - v-if="shouldRenderIssuesList" - :unresolved-issues="unresolvedIssues" - :new-issues="newIssues" - :resolved-issues="resolvedIssues" - :component="$options.componentNames.AccessibilityIssueBody" - class="report-block-group-list" - /> - </div> - </template> - </report-section> -</template> diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js deleted file mode 100644 index e0142a35291..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/store/actions.js +++ /dev/null @@ -1,76 +0,0 @@ -import Visibility from 'visibilityjs'; -import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; -import Poll from '~/lib/utils/poll'; -import * as types from './mutation_types'; - -let eTagPoll; - -export const clearEtagPoll = () => { - eTagPoll = null; -}; - -export const stopPolling = () => { - if (eTagPoll) eTagPoll.stop(); -}; - -export const restartPolling = () => { - if (eTagPoll) eTagPoll.restart(); -}; - -export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); - -/** - * We need to poll the report endpoint while they are being parsed in the Backend. - * This can take up to one minute. - * - * Poll.js will handle etag response. - * While http status code is 204, it means it's parsing, and we'll keep polling - * When http status code is 200, it means parsing is done, we can show the results & stop polling - * When http status code is 500, it means parsing went wrong and we stop polling - */ -export const fetchReport = ({ state, dispatch, commit }) => { - commit(types.REQUEST_REPORT); - - eTagPoll = new Poll({ - resource: { - getReport(endpoint) { - return axios.get(endpoint); - }, - }, - data: state.endpoint, - method: 'getReport', - successCallback: ({ status, data }) => dispatch('receiveReportSuccess', { status, data }), - errorCallback: () => dispatch('receiveReportError'), - }); - - if (!Visibility.hidden()) { - eTagPoll.makeRequest(); - } else { - axios - .get(state.endpoint) - .then(({ status, data }) => dispatch('receiveReportSuccess', { status, data })) - .catch(() => dispatch('receiveReportError')); - } - - Visibility.change(() => { - if (!Visibility.hidden() && state.isLoading) { - dispatch('restartPolling'); - } else { - dispatch('stopPolling'); - } - }); -}; - -export const receiveReportSuccess = ({ commit, dispatch }, { status, data }) => { - if (status === httpStatusCodes.OK) { - commit(types.RECEIVE_REPORT_SUCCESS, data); - // Stop polling since we have the information already parsed and it won't be changing - dispatch('stopPolling'); - } -}; - -export const receiveReportError = ({ commit, dispatch }) => { - commit(types.RECEIVE_REPORT_ERROR); - dispatch('stopPolling'); -}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/getters.js b/app/assets/javascripts/reports/accessibility_report/store/getters.js deleted file mode 100644 index 20506b1bfd1..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/store/getters.js +++ /dev/null @@ -1,45 +0,0 @@ -import { s__, n__ } from '~/locale'; -import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants'; - -export const groupedSummaryText = (state) => { - if (state.isLoading) { - return s__('Reports|Accessibility scanning results are being parsed'); - } - - if (state.hasError) { - return s__('Reports|Accessibility scanning failed loading results'); - } - - const numberOfResults = state.report?.summary?.errored || 0; - if (numberOfResults === 0) { - return s__('Reports|Accessibility scanning detected no issues for the source branch only'); - } - - return n__( - 'Reports|Accessibility scanning detected %d issue for the source branch only', - 'Reports|Accessibility scanning detected %d issues for the source branch only', - numberOfResults, - ); -}; - -export const summaryStatus = (state) => { - if (state.isLoading) { - return LOADING; - } - - if (state.hasError || state.status === STATUS_FAILED) { - return ERROR; - } - - return SUCCESS; -}; - -export const shouldRenderIssuesList = (state) => - Object.values(state.report).some((x) => Array.isArray(x) && x.length > 0); - -// We could just map state, but we're going to iterate in the future -// to add notes and warnings to these issue lists, so I'm going to -// keep these as getters -export const unresolvedIssues = (state) => state.report.existing_errors; -export const resolvedIssues = (state) => state.report.resolved_errors; -export const newIssues = (state) => state.report.new_errors; diff --git a/app/assets/javascripts/reports/accessibility_report/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js deleted file mode 100644 index 5bfcd69edec..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/store/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export const getStoreConfig = (initialState) => ({ - actions, - getters, - mutations, - state: state(initialState), -}); - -export default (initialState) => new Vuex.Store(getStoreConfig(initialState)); diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js deleted file mode 100644 index 22e2330e1ea..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js +++ /dev/null @@ -1,5 +0,0 @@ -export const SET_ENDPOINT = 'SET_ENDPOINT'; - -export const REQUEST_REPORT = 'REQUEST_REPORT'; -export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS'; -export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR'; diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js deleted file mode 100644 index 20d3e5be9a3..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/store/mutations.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_ENDPOINT](state, endpoint) { - state.endpoint = endpoint; - }, - [types.REQUEST_REPORT](state) { - state.isLoading = true; - }, - [types.RECEIVE_REPORT_SUCCESS](state, report) { - state.hasError = false; - state.isLoading = false; - state.report = report; - }, - [types.RECEIVE_REPORT_ERROR](state) { - state.isLoading = false; - state.hasError = true; - state.report = {}; - }, -}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js deleted file mode 100644 index 2a4cefea5e6..00000000000 --- a/app/assets/javascripts/reports/accessibility_report/store/state.js +++ /dev/null @@ -1,28 +0,0 @@ -export default (initialState = {}) => ({ - endpoint: initialState.endpoint || '', - - isLoading: initialState.isLoading || false, - hasError: initialState.hasError || false, - - /** - * Report will have the following format: - * { - * status: {String}, - * summary: { - * total: {Number}, - * resolved: {Number}, - * errored: {Number}, - * }, - * existing_errors: {Array.<Object>}, - * existing_notes: {Array.<Object>}, - * existing_warnings: {Array.<Object>}, - * new_errors: {Array.<Object>}, - * new_notes: {Array.<Object>}, - * new_warnings: {Array.<Object>}, - * resolved_errors: {Array.<Object>}, - * resolved_notes: {Array.<Object>}, - * resolved_warnings: {Array.<Object>}, - * } - */ - report: initialState.report || {}, -}); diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js index 04e72809e62..a76a6f45c07 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/reports/components/issue_body.js @@ -1,14 +1,11 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; export const components = { - AccessibilityIssueBody: () => - import('../accessibility_report/components/accessibility_issue_body.vue'), CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'), TestIssueBody: () => import('../grouped_test_report/components/test_issue_body.vue'), }; export const componentNames = { - AccessibilityIssueBody: 'AccessibilityIssueBody', CodequalityIssueBody: 'CodequalityIssueBody', TestIssueBody: 'TestIssueBody', }; diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 6061be465ca..bb86695b9a3 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -1,7 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; import api from '~/api'; -import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -115,9 +114,6 @@ export default { }, computed: { - collapseText() { - return this.isCollapsed ? __('Expand') : __('Collapse'); - }, isLoading() { return this.status === status.LOADING; }, @@ -172,6 +168,11 @@ export default { }, methods: { toggleCollapsed() { + // Because the top-level div is always clickable, we need to check if we can collapse. + if (!this.isCollapsible) { + return; + } + if (this.trackAction) { api.trackRedisHllUserEvent(this.trackAction); } @@ -186,10 +187,13 @@ export default { </script> <template> <section class="media-section"> - <div class="media"> + <div class="media" :class="{ 'gl-cursor-pointer': isCollapsible }" @click="toggleCollapsed"> <status-icon :status="statusIconName" :size="24" class="align-self-center" /> - <div class="media-body d-flex flex-align-self-center align-items-center"> - <div data-testid="report-section-code-text" class="js-code-text code-text"> + <div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!"> + <div + data-testid="report-section-code-text" + class="js-code-text code-text gl-align-self-center gl-flex-grow-1" + > <div class="gl-display-flex gl-align-items-center"> <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p> <slot :name="slotName"></slot> @@ -204,14 +208,19 @@ export default { <slot name="action-buttons" :is-collapsible="isCollapsible"></slot> - <gl-button + <div v-if="isCollapsible" - data-testid="report-section-expand-button" - data-qa-selector="expand_report_button" - @click="toggleCollapsed" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3" > - {{ collapseText }} - </gl-button> + <gl-button + data-testid="report-section-expand-button" + data-qa-selector="expand_report_button" + category="tertiary" + size="small" + :icon="isExpanded ? 'chevron-lg-up' : 'chevron-lg-down'" + @click.stop="toggleCollapsed" + /> + </div> </div> </div> diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index 5fd9cfd4e53..f009c0310c5 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { normalizeData } from 'ee_else_ce/repository/utils/commit'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants'; let requestedOffsets = []; @@ -43,7 +43,7 @@ const fetchData = (projectPath, path, ref, offset) => { return axios .get(url, { params: { format: 'json', offset } }) .then(({ data }) => normalizeData(data, path)) - .catch(() => createFlash({ message: I18N_COMMIT_DATA_FETCH_ERROR })); + .catch(() => createAlert({ message: I18N_COMMIT_DATA_FETCH_ERROR })); }; export const loadCommits = async (projectPath, path, ref, offset) => { diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 902077ba3e4..bf1667d8734 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -4,7 +4,7 @@ import { uniqueId } from 'lodash'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -271,7 +271,7 @@ export default { .catch(() => this.displayError()); }, displayError() { - createFlash({ message: __('An error occurred while loading the file. Please try again.') }); + createAlert({ message: __('An error occurred while loading the file. Please try again.') }); }, switchViewer(newViewer) { this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER; diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue index fb1227f0df9..29c2c3762fc 100644 --- a/app/assets/javascripts/repository/components/blob_controls.vue +++ b/app/assets/javascripts/repository/components/blob_controls.vue @@ -1,9 +1,11 @@ <script> import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import getRefMixin from '~/repository/mixins/get_ref'; import initSourcegraph from '~/sourcegraph'; +import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; +import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; import { updateElementsVisibility } from '../utils/dom'; import blobControlsQuery from '../queries/blob_controls.query.graphql'; @@ -34,7 +36,7 @@ export default { return !this.filePath; }, error() { - createFlash({ message: this.$options.i18n.errorMessage }); + createAlert({ message: this.$options.i18n.errorMessage }); }, }, }, @@ -84,6 +86,33 @@ export default { }, blobInfo() { initSourcegraph(); + this.$nextTick(() => { + this.initShortcuts(); + this.initLinksUpdate(); + }); + }, + }, + methods: { + initShortcuts() { + const fileBlobPermalinkUrlElement = document.querySelector( + '.js-data-file-blob-permalink-url', + ); + const fileBlobPermalinkUrl = + fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); + // eslint-disable-next-line no-new + new ShortcutsBlob({ + skipResetBindings: true, + fileBlobPermalinkUrl, + fileBlobPermalinkUrlElement, + }); + }, + initLinksUpdate() { + // eslint-disable-next-line no-new + new BlobLinePermalinkUpdater( + document.querySelector('.tree-holder'), + '.file-line-num[data-line-number], .file-line-num[data-line-number] *', + document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), + ); }, }, }; @@ -99,6 +128,7 @@ export default { data-testid="blame" :href="blobInfo.blamePath" :class="$options.buttonClassList" + class="js-blob-blame-link" > {{ $options.i18n.blame }} </gl-button> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 22fe3fe440e..05d64077866 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -122,14 +122,12 @@ export default { :link-href="commit.author.webPath" :img-src="commit.author.avatarUrl" :img-size="32" - :img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */" class="gl-my-2 gl-mr-4" /> <user-avatar-image v-else class="gl-my-2 gl-mr-4" :img-src="commit.authorGravatar || $options.defaultAvatarUrl" - :css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */" :size="32" /> <div @@ -171,7 +169,7 @@ export default { v-if="commitDescription" v-safe-html:[$options.safeHtmlConfig]="commitDescription" :class="{ 'd-block': showDescription }" - class="commit-row-description gl-mb-3" + class="commit-row-description gl-mb-3 gl-white-space-pre-line" ></pre> </div> <div class="gl-flex-grow-1"></div> diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue index 6c5797bf5b2..b28ebe7bb1e 100644 --- a/app/assets/javascripts/repository/components/new_directory_modal.vue +++ b/app/assets/javascripts/repository/components/new_directory_modal.vue @@ -8,7 +8,7 @@ import { GlFormTextarea, GlToggle, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -140,7 +140,7 @@ export default { }) .catch(() => { this.loading = false; - createFlash({ message: ERROR_MESSAGE }); + createAlert({ message: ERROR_MESSAGE }); }); }, }, diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index c8cd64b5311..f3c5ace75fc 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -260,19 +260,19 @@ export default { class="ml-1" /> </td> - <td class="d-none d-sm-table-cell tree-commit cursor-default"> + <td class="d-none d-sm-table-cell tree-commit cursor-default gl-text-secondary"> <gl-link v-if="commitData" v-safe-html:[$options.safeHtmlConfig]="commitData.titleHtml" :href="commitData.commitPath" :title="commitData.message" - class="str-truncated-100 tree-commit-link" + class="str-truncated-100 tree-commit-link gl-text-secondary" /> <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared"> <gl-skeleton-loader v-if="showSkeletonLoader" :lines="1" /> </gl-intersection-observer> </td> - <td class="tree-time-ago text-right cursor-default"> + <td class="tree-time-ago text-right cursor-default gl-text-secondary"> <timeago-tooltip v-if="commitData" :time="commitData.committedDate" /> <gl-skeleton-loader v-if="showSkeletonLoader" :lines="1" /> </td> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 2200e999c75..8a45a351c35 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,6 +1,6 @@ <script> import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '~/locale'; import { @@ -142,7 +142,7 @@ export default { } }) .catch((error) => { - createFlash({ + createAlert({ message: __('An error occurred while fetching folder content.'), }); throw error; diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index 7fcaf772aac..4603ea2710d 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -9,7 +9,7 @@ import { GlButton, GlAlert, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -171,7 +171,7 @@ export default { }) .catch(() => { this.loading = false; - createFlash({ message: ERROR_MESSAGE }); + createAlert({ message: ERROR_MESSAGE }); }); }, formData() { diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index f5620876783..dbaabb35cde 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -17,8 +17,6 @@ import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_cou import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; -import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; -import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; @@ -30,7 +28,12 @@ import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; -import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; +import { + ADMIN_FILTERED_SEARCH_NAMESPACE, + INSTANCE_TYPE, + I18N_FETCH_ERROR, + FILTER_CSS_CLASSES, +} from '../constants'; import { captureException } from '../sentry_utils'; export default { @@ -40,8 +43,6 @@ export default { RegistrationDropdown, RunnerStackedLayoutBanner, RunnerFilteredSearchBar, - RunnerBulkDelete, - RunnerBulkDeleteCheckbox, RunnerList, RunnerListEmptyState, RunnerName, @@ -51,7 +52,7 @@ export default { RunnerActionsCell, }, mixins: [glFeatureFlagMixin()], - inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'], + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { registrationToken: { type: String, @@ -114,11 +115,6 @@ export default { upgradeStatusTokenConfig, ]; }, - isBulkDeleteEnabled() { - // Feature flag: admin_runners_bulk_delete - // Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981 - return this.glFeatures.adminRunnersBulkDelete; - }, isSearchFiltered() { return isSearchFiltered(this.search); }, @@ -155,18 +151,13 @@ export default { reportToSentry(error) { captureException({ error, component: this.$options.name }); }, - onChecked({ runner, isChecked }) { - this.localMutations.setRunnerChecked({ - runner, - isChecked, - }); - }, onPaginationInput(value) { this.search.pagination = value; }, }, filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, + FILTER_CSS_CLASSES, }; </script> <template> @@ -195,6 +186,7 @@ export default { <runner-filtered-search-bar v-model="search" + :class="$options.FILTER_CSS_CLASSES" :tokens="searchTokens" :namespace="$options.filteredSearchNamespace" /> @@ -209,20 +201,12 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-bulk-delete - v-if="isBulkDeleteEnabled" - :runners="runners.items" - @deleted="onDeleted" - /> <runner-list :runners="runners.items" :loading="runnersLoading" - :checkable="isBulkDeleteEnabled" - @checked="onChecked" + :checkable="true" + @deleted="onDeleted" > - <template v-if="isBulkDeleteEnabled" #head-checkbox> - <runner-bulk-delete-checkbox :runners="runners.items" /> - </template> <template #runner-name="{ runner }"> <gl-link :href="runner.adminUrl"> <runner-name :runner="runner" /> diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index 7a4760f81ee..13f520c4edb 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -52,11 +52,6 @@ export default { :compact="true" @toggledPaused="onToggledPaused" /> - <runner-delete-button - :disabled="!canDelete" - :runner="runner" - :compact="true" - @deleted="onDeleted" - /> + <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_owner_cell.vue b/app/assets/javascripts/runner/components/cells/runner_owner_cell.vue new file mode 100644 index 00000000000..cb43760b2d6 --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_owner_cell.vue @@ -0,0 +1,63 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, I18N_ADMIN } from '../../constants'; + +export default { + components: { + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + cell() { + switch (this.runner?.runnerType) { + case INSTANCE_TYPE: + return { + text: I18N_ADMIN, + }; + case GROUP_TYPE: { + const { name, fullName, webUrl } = this.runner?.groups?.nodes[0] || {}; + + return { + text: name, + href: webUrl, + tooltip: fullName !== name ? fullName : '', + }; + } + case PROJECT_TYPE: { + const { name, nameWithNamespace, webUrl } = this.runner?.ownerProject || {}; + + return { + text: name, + href: webUrl, + tooltip: nameWithNamespace !== name ? nameWithNamespace : '', + }; + } + default: + return {}; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-link + v-if="cell.href" + v-gl-tooltip="cell.tooltip" + :href="cell.href" + class="gl-text-body gl-text-decoration-underline" + > + {{ cell.text }} + </gl-link> + <span v-else>{{ cell.text }}</span> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue index dde5a5a4a05..75afb7a00bc 100644 --- a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue +++ b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue @@ -1,5 +1,6 @@ <script> import { GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; export default { @@ -25,14 +26,20 @@ export default { }, }, computed: { + deletableRunners() { + return this.runners.filter((runner) => runner.userPermissions?.deleteRunner); + }, disabled() { - return !this.runners.length; + return !this.deletableRunners.length; }, checked() { - return Boolean(this.runners.length) && this.runners.every(this.isChecked); + return Boolean(this.deletableRunners.length) && this.deletableRunners.every(this.isChecked); }, indeterminate() { - return !this.checked && this.runners.some(this.isChecked); + return !this.checked && this.deletableRunners.some(this.isChecked); + }, + label() { + return this.checked ? s__('Runners|Unselect all') : s__('Runners|Select all'); }, }, methods: { @@ -41,7 +48,7 @@ export default { }, onChange($event) { this.localMutations.setRunnersChecked({ - runners: this.runners, + runners: this.deletableRunners, isChecked: $event, }); }, @@ -51,6 +58,7 @@ export default { <template> <gl-form-checkbox + :aria-label="label" :indeterminate="indeterminate" :checked="checked" :disabled="disabled" diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue index 62382891df0..b4f022a7d14 100644 --- a/app/assets/javascripts/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/runner/components/runner_delete_button.vue @@ -5,12 +5,7 @@ import { createAlert } from '~/flash'; import { sprintf } from '~/locale'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { - I18N_DELETE_DISABLED_MANY_PROJECTS, - I18N_DELETE_DISABLED_UNKNOWN_REASON, - I18N_DELETE_RUNNER, - I18N_DELETED_TOAST, -} from '../constants'; +import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; import RunnerDeleteModal from './runner_delete_modal.vue'; export default { @@ -31,11 +26,6 @@ export default { return runner?.id && runner?.shortSha; }, }, - disabled: { - type: Boolean, - required: false, - default: false, - }, compact: { type: Boolean, required: false, @@ -85,29 +75,14 @@ export default { return null; }, tooltip() { - if (this.disabled && this.runner.projectCount > 1) { - return I18N_DELETE_DISABLED_MANY_PROJECTS; - } - if (this.disabled) { - return I18N_DELETE_DISABLED_UNKNOWN_REASON; - } - // Only show basic "delete" tooltip when compact. // Also prevent a "sticky" tooltip: If this button is - // disabled, mouseout listeners don't run leaving the tooltip stuck + // loading, mouseout listeners don't run leaving the tooltip stuck if (this.compact && !this.deleting) { return I18N_DELETE_RUNNER; } return ''; }, - wrapperTabindex() { - if (this.disabled) { - // Trigger tooltip on keyboard-focusable wrapper - // See https://bootstrap-vue.org/docs/directives/tooltip - return '0'; - } - return null; - }, }, methods: { async onDelete() { @@ -156,14 +131,13 @@ export default { </script> <template> - <div v-gl-tooltip="tooltip" class="btn-group" :tabindex="wrapperTabindex"> + <div v-gl-tooltip="tooltip" class="btn-group"> <gl-button v-gl-modal="runnerDeleteModalId" :aria-label="ariaLabel" :icon="icon" :class="buttonClass" :loading="deleting" - :disabled="disabled" variant="danger" category="secondary" v-bind="$attrs" diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index 79f934764c6..3d72abcd393 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -4,7 +4,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; import RunnerDetail from './runner_detail.vue'; @@ -29,7 +28,6 @@ export default { RunnerTags, TimeAgo, }, - mixins: [glFeatureFlagMixin()], props: { runner: { type: Object, @@ -117,10 +115,7 @@ export default { </template> </runner-detail> <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> - <runner-detail - v-if="glFeatures.enforceRunnerTokenExpiresAt" - :empty-value="s__('Runners|Never expires')" - > + <runner-detail :empty-value="s__('Runners|Never expires')"> <template #label> {{ s__('Runners|Token expiry') }} <help-popover :options="tokenExpirationHelpPopoverOptions"> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index 5a9ab21a457..da59de9a9eb 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -85,7 +85,6 @@ export default { </script> <template> <filtered-search - class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1" v-bind="$attrs" :namespace="namespace" recent-searches-storage-key="runners-search" diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 26f1f3ce08c..e895537dcdc 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -2,15 +2,20 @@ import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; import { formatJobCount, tableField } from '../utils'; +import RunnerBulkDelete from './runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; +import RunnerOwnerCell from './cells/runner_owner_cell.vue'; const defaultFields = [ tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }), tableField({ key: 'summary', label: s__('Runners|Runner') }), + tableField({ key: 'owner', label: s__('Runners|Owner'), thClasses: ['gl-w-20p'] }), tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }), ]; @@ -19,9 +24,13 @@ export default { GlFormCheckbox, GlTableLite, GlSkeletonLoader, + HelpPopover, + RunnerBulkDelete, + RunnerBulkDeleteCheckbox, RunnerStatusPopover, RunnerStackedSummaryCell, RunnerStatusCell, + RunnerOwnerCell, }, directives: { GlTooltip: GlTooltipDirective, @@ -34,6 +43,7 @@ export default { }, }, }, + inject: ['localMutations'], props: { checkable: { type: Boolean, @@ -50,7 +60,7 @@ export default { required: true, }, }, - emits: ['checked'], + emits: ['deleted'], data() { return { checkedRunnerIds: [] }; }, @@ -79,6 +89,12 @@ export default { }, }, methods: { + canDelete(runner) { + return runner.userPermissions?.deleteRunner; + }, + onDeleted(event) { + this.$emit('deleted', event); + }, formatJobCount(jobCount) { return formatJobCount(jobCount); }, @@ -91,7 +107,7 @@ export default { return {}; }, onCheckboxChange(runner, isChecked) { - this.$emit('checked', { + this.localMutations.setRunnerChecked({ runner, isChecked, }); @@ -104,6 +120,7 @@ export default { </script> <template> <div> + <runner-bulk-delete v-if="checkable" :runners="runners" @deleted="onDeleted" /> <gl-table-lite :aria-busy="loading" :class="tableClass" @@ -116,11 +133,15 @@ export default { fixed > <template #head(checkbox)> - <slot name="head-checkbox"></slot> + <runner-bulk-delete-checkbox :runners="runners" /> </template> <template #cell(checkbox)="{ item }"> - <gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" /> + <gl-form-checkbox + v-if="canDelete(item)" + :checked="isChecked(item)" + @change="onCheckboxChange(item, $event)" + /> </template> <template #head(status)="{ label }"> @@ -140,6 +161,21 @@ export default { </runner-stacked-summary-cell> </template> + <template #head(owner)="{ label }"> + {{ label }} + <help-popover> + {{ + s__( + 'Runners|The project, group or instance where the runner was registered. Instance runners are always owned by Administrator.', + ) + }} + </help-popover> + </template> + + <template #cell(owner)="{ item }"> + <runner-owner-cell :runner="item" /> + </template> + <template #cell(actions)="{ item }"> <slot name="runner-actions-cell" :runner="item"></slot> </template> diff --git a/app/assets/javascripts/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/runner/components/runner_list_empty_state.vue index ab9cde6a401..e6576c83e69 100644 --- a/app/assets/javascripts/runner/components/runner_list_empty_state.vue +++ b/app/assets/javascripts/runner/components/runner_list_empty_state.vue @@ -53,7 +53,7 @@ export default { :svg-path="svgPath" :svg-height="$options.svgHeight" > - <template #description> + <template v-if="registrationToken" #description> <gl-sprintf :message=" s__( @@ -71,5 +71,12 @@ export default { :registration-token="registrationToken" /> </template> + <template v-else #description> + {{ + s__( + 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', + ) + }} + </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/runner/components/runner_membership_toggle.vue b/app/assets/javascripts/runner/components/runner_membership_toggle.vue new file mode 100644 index 00000000000..2b37b1cc797 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_membership_toggle.vue @@ -0,0 +1,42 @@ +<script> +import { GlToggle } from '@gitlab/ui'; +import { + I18N_SHOW_ONLY_INHERITED, + MEMBERSHIP_DESCENDANTS, + MEMBERSHIP_ALL_AVAILABLE, +} from '../constants'; + +export default { + components: { + GlToggle, + }, + props: { + value: { + type: String, + default: MEMBERSHIP_DESCENDANTS, + required: false, + }, + }, + computed: { + toggle() { + return this.value === MEMBERSHIP_DESCENDANTS; + }, + }, + methods: { + onChange(value) { + this.$emit('input', value ? MEMBERSHIP_DESCENDANTS : MEMBERSHIP_ALL_AVAILABLE); + }, + }, + I18N_SHOW_ONLY_INHERITED, +}; +</script> + +<template> + <gl-toggle + data-testid="runner-membership-toggle" + :value="toggle" + :label="$options.I18N_SHOW_ONLY_INHERITED" + label-position="left" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index 59230bb809e..6e7c41885f8 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -7,6 +7,12 @@ import { s__ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { RUNNER_TAG_BG_CLASS } from '../../constants'; +// TODO This should be implemented via a GraphQL API +// The API should +// 1) scope to the rights of the user +// 2) stay up to date to the removal of old tags +// 3) consider the scope of search, like searching within the tags of a group +// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json'; export default { @@ -29,12 +35,6 @@ export default { }, methods: { getTagsOptions(search) { - // TODO This should be implemented via a GraphQL API - // The API should - // 1) scope to the rights of the user - // 2) stay up to date to the removal of old tags - // 3) consider the scope of search, like searching within the tags of a group - // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 return axios .get(TAG_SUGGESTIONS_PATH, { params: { @@ -46,6 +46,12 @@ export default { }); }, async fetchTags(searchTerm) { + // Note: Suggestions should only be enabled for admin users + if (this.config.suggestionsDisabled) { + this.tags = []; + return; + } + this.loading = true; try { this.tags = await this.getTagsOptions(searchTerm); diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 3009577599f..dfc5f0c4152 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -11,6 +11,9 @@ export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); +export const FILTER_CSS_CLASSES = + 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1'; + // Type export const I18N_ALL_TYPES = s__('Runners|All'); @@ -76,12 +79,6 @@ export const I18N_RESUME = __('Resume'); export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs'); export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); -export const I18N_DELETE_DISABLED_MANY_PROJECTS = s__( - 'Runners|Multi-project runners cannot be deleted', -); -export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__( - 'Runners|Runner cannot be deleted, please contact your administrator', -); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); // List @@ -91,6 +88,8 @@ 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_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited'); +export const I18N_ADMIN = s__('Runners|Administrator'); // Runner details @@ -116,6 +115,7 @@ export const PARAM_KEY_PAUSED = 'paused'; export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; export const PARAM_KEY_TAG = 'tag'; export const PARAM_KEY_SEARCH = 'search'; +export const PARAM_KEY_MEMBERSHIP = 'membership'; export const PARAM_KEY_SORT = 'sort'; export const PARAM_KEY_AFTER = 'after'; @@ -148,6 +148,13 @@ export const CONTACTED_ASC = 'CONTACTED_ASC'; export const DEFAULT_SORT = CREATED_DESC; +// CiRunnerMembershipFilter + +export const MEMBERSHIP_DESCENDANTS = 'DESCENDANTS'; +export const MEMBERSHIP_ALL_AVAILABLE = 'ALL_AVAILABLE'; + +export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS; + // Local storage namespaces export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners'; diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql index 4c519b9b867..95f9dd1beb9 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql @@ -2,6 +2,7 @@ query getGroupRunners( $groupFullPath: ID! + $membership: CiRunnerMembershipFilter $before: String $after: String $first: Int @@ -9,13 +10,14 @@ query getGroupRunners( $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType + $tagList: [String!] $search: String $sort: CiRunnerSort ) { group(fullPath: $groupFullPath) { id # Apollo required runners( - membership: DESCENDANTS + membership: $membership before: $before after: $after first: $first @@ -23,6 +25,7 @@ query getGroupRunners( paused: $paused status: $status type: $type + tagList: $tagList search: $search sort: $sort ) { diff --git a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql index 958b4ea0dd3..e88a2c2e7e6 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql @@ -1,5 +1,6 @@ query getGroupRunnersCount( $groupFullPath: ID! + $membership: CiRunnerMembershipFilter $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType @@ -9,7 +10,7 @@ query getGroupRunnersCount( group(fullPath: $groupFullPath) { id # Apollo required runners( - membership: DESCENDANTS + membership: $membership paused: $paused status: $status type: $type diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql index a12ba7a751a..0dff011daaa 100644 --- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql @@ -16,4 +16,18 @@ fragment ListItemShared on CiRunner { updateRunner deleteRunner } + groups(first: 1) { + nodes { + id + name + fullName + webUrl + } + } + ownerProject { + id + name + nameWithNamespace + webUrl + } } diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js index 154af261bba..e0477c660b4 100644 --- a/app/assets/javascripts/runner/graphql/list/local_state.js +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -20,10 +20,6 @@ import typeDefs from './typedefs.graphql'; * localMutations.setRunnerChecked( ... ) * ``` * - * Note: Currently only in use behind a feature flag: - * admin_runners_bulk_delete for the admin list, rollout issue: - * https://gitlab.com/gitlab-org/gitlab/-/issues/353981 - * * @returns {Object} An object to configure an Apollo client: * contains cacheConfig, typeDefs, localMutations. */ @@ -52,16 +48,18 @@ export const createLocalState = () => { const localMutations = { setRunnerChecked({ runner, isChecked }) { - checkedRunnerIdsVar({ - ...checkedRunnerIdsVar(), - [runner.id]: isChecked, - }); + const { id, userPermissions } = runner; + if (userPermissions?.deleteRunner) { + checkedRunnerIdsVar({ + ...checkedRunnerIdsVar(), + [id]: isChecked, + }); + } }, setRunnersChecked({ runners, isChecked }) { - const newVal = runners.reduce( - (acc, { id }) => ({ ...acc, [id]: isChecked }), - checkedRunnerIdsVar(), - ); + const newVal = runners + .filter(({ userPermissions }) => userPermissions?.deleteRunner) + .reduce((acc, { id }) => ({ ...acc, [id]: isChecked }), checkedRunnerIdsVar()); checkedRunnerIdsVar(newVal); }, clearChecked() { diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 70826a6bfa1..7f56d895682 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -10,7 +10,9 @@ import { fromSearchToVariables, isSearchFiltered, } from 'ee_else_ce/runner/runner_search_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue'; @@ -22,14 +24,17 @@ import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import RunnerMembershipToggle from '../components/runner_membership_toggle.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; +import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; import { GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_TYPE, PROJECT_TYPE, I18N_FETCH_ERROR, + FILTER_CSS_CLASSES, } from '../constants'; import { captureException } from '../sentry_utils'; @@ -43,11 +48,13 @@ export default { RunnerList, RunnerListEmptyState, RunnerName, + RunnerMembershipToggle, RunnerStats, RunnerPagination, RunnerTypeTabs, RunnerActionsCell, }, + mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { registrationToken: { @@ -126,12 +133,20 @@ export default { noRunnersFound() { return !this.runnersLoading && !this.runners.items.length; }, - searchTokens() { - return [pausedTokenConfig, statusTokenConfig, upgradeStatusTokenConfig]; - }, filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; }, + searchTokens() { + return [ + pausedTokenConfig, + statusTokenConfig, + { + ...tagTokenConfig, + suggestionsDisabled: true, + }, + upgradeStatusTokenConfig, + ]; + }, isSearchFiltered() { return isSearchFiltered(this.search); }, @@ -159,13 +174,17 @@ export default { editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, + refetchCounts() { + this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] }); + }, onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.$refs['runner-type-tabs'].refetch(); + this.refetchCounts(); }, onDeleted({ message }) { this.$root.$toast?.show(message); + this.refetchCounts(); }, reportToSentry(error) { captureException({ error, component: this.$options.name }); @@ -176,6 +195,7 @@ export default { }, TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE], GROUP_TYPE, + FILTER_CSS_CLASSES, }; </script> @@ -204,11 +224,21 @@ export default { /> </div> - <runner-filtered-search-bar - v-model="search" - :tokens="searchTokens" - :namespace="filteredSearchNamespace" - /> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3" + :class="$options.FILTER_CSS_CLASSES" + > + <runner-filtered-search-bar + v-model="search" + :tokens="searchTokens" + :namespace="filteredSearchNamespace" + class="gl-flex-grow-1 gl-align-self-stretch" + /> + <runner-membership-toggle + v-model="search.membership" + class="gl-align-self-end gl-md-align-self-center" + /> + </div> <runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" /> @@ -220,7 +250,7 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-list :runners="runners.items" :loading="runnersLoading"> + <runner-list :runners="runners.items" :loading="runnersLoading" @deleted="onDeleted"> <template #runner-name="{ runner }"> <gl-link :href="webUrl(runner)"> <runner-name :runner="runner" /> diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index feed6b0ceb7..0e7efd2b8a1 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -2,6 +2,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { createLocalState } from '../graphql/list/local_state'; import GroupRunnersApp from './group_runners_app.vue'; Vue.use(GlToast); @@ -26,8 +27,10 @@ export const initGroupRunners = (selector = '#js-group-runners') => { emptyStateFilteredSvgPath, } = el.dataset; + const { cacheConfig, typeDefs, localMutations } = createLocalState(); + const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }), }); return new Vue({ @@ -35,6 +38,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { apolloProvider, provide: { runnerInstallHelpPage, + localMutations, groupId, onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10), staleTimeoutSecs: parseInt(staleTimeoutSecs, 10), diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index dc582ccbac1..adc832b0600 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -13,10 +13,12 @@ import { PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG, PARAM_KEY_SEARCH, + PARAM_KEY_MEMBERSHIP, PARAM_KEY_SORT, PARAM_KEY_AFTER, PARAM_KEY_BEFORE, DEFAULT_SORT, + DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE, } from './constants'; import { getPaginationVariables } from './utils'; @@ -57,9 +59,10 @@ import { getPaginationVariables } from './utils'; * @param {Object} search * @returns {boolean} True if the value follows the search format. */ -export const searchValidator = ({ runnerType, filters, sort }) => { +export const searchValidator = ({ runnerType, membership, filters, sort }) => { return ( (runnerType === null || typeof runnerType === 'string') && + (membership === null || typeof membership === 'string') && Array.isArray(filters) && typeof sort === 'string' ); @@ -140,9 +143,11 @@ export const updateOutdatedUrl = (url = window.location.href) => { export const fromUrlQueryToSearch = (query = window.location.search) => { const params = queryToObject(query, { gatherArrays: true }); const runnerType = params[PARAM_KEY_RUNNER_TYPE]?.[0] || null; + const membership = params[PARAM_KEY_MEMBERSHIP]?.[0] || null; return { runnerType, + membership: membership || DEFAULT_MEMBERSHIP, filters: prepareTokens( urlQueryToFilter(query, { filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG], @@ -162,13 +167,14 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { * @returns {String} New URL for the page */ export const fromSearchToUrl = ( - { runnerType = null, filters = [], sort = null, pagination = {} }, + { runnerType = null, membership = null, filters = [], sort = null, pagination = {} }, url = window.location.href, ) => { const filterParams = { // Defaults [PARAM_KEY_STATUS]: [], [PARAM_KEY_RUNNER_TYPE]: [], + [PARAM_KEY_MEMBERSHIP]: [], [PARAM_KEY_TAG]: [], // Current filters ...filterToQueryObject(processFilters(filters), { @@ -180,6 +186,10 @@ export const fromSearchToUrl = ( filterParams[PARAM_KEY_RUNNER_TYPE] = [runnerType]; } + if (membership && membership !== DEFAULT_MEMBERSHIP) { + filterParams[PARAM_KEY_MEMBERSHIP] = [membership]; + } + if (!filterParams[PARAM_KEY_SEARCH]) { filterParams[PARAM_KEY_SEARCH] = null; } @@ -203,6 +213,7 @@ export const fromSearchToUrl = ( */ export const fromSearchToVariables = ({ runnerType = null, + membership = null, filters = [], sort = null, pagination = {}, @@ -226,6 +237,9 @@ export const fromSearchToVariables = ({ if (runnerType) { filterVariables.type = runnerType; } + if (membership) { + filterVariables.membership = membership; + } if (sort) { filterVariables.sort = sort; } diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 446ab7f433c..ba12f31ef87 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,7 +1,7 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import { queryToObject } from '~/lib/utils/url_utility'; import refreshCounts from '~/pages/search/show/refresh_counts'; -import { initSidebar } from './sidebar'; +import { initSidebar, sidebarInitState } from './sidebar'; import { initSearchSort } from './sort'; import createStore from './store'; import { initTopbar } from './topbar'; @@ -9,14 +9,18 @@ import { initBlobRefSwitcher } from './under_topbar'; export const initSearchApp = () => { const query = queryToObject(window.location.search); + const navigation = sidebarInitState(); - const store = createStore({ query }); + const store = createStore({ query, navigation }); initTopbar(store); initSidebar(store); initSearchSort(store); setHighlightClass(query.search); // Code Highlighting - refreshCounts(); // Other Scope Tab Counts initBlobRefSwitcher(); // Code Search Branch Picker + + if (!gon.features?.searchPageVerticalNav) { + refreshCounts(); // Other Scope Tab Counts + } }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 5c7cbeac5b2..789efc8f09d 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -17,6 +17,9 @@ export default { showReset() { return this.urlQuery.state || this.urlQuery.confidential; }, + showSidebar() { + return this.urlQuery.scope === 'issues' || this.urlQuery.scope === 'merge_requests'; + }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -29,15 +32,17 @@ export default { class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5" @submit.prevent="applyQuery" > - <status-filter /> - <confidentiality-filter /> - <div class="gl-display-flex gl-align-items-center gl-mt-3"> - <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> - {{ __('Apply') }} - </gl-button> - <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ - __('Reset filters') - }}</gl-link> - </div> + <template v-if="showSidebar"> + <status-filter /> + <confidentiality-filter /> + <div class="gl-display-flex gl-align-items-center gl-mt-3"> + <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> + {{ __('Apply') }} + </gl-button> + <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ + __('Reset filters') + }}</gl-link> + </div> + </template> </form> </template> diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js index 1414adcac27..c6b1257c4ef 100644 --- a/app/assets/javascripts/search/sidebar/index.js +++ b/app/assets/javascripts/search/sidebar/index.js @@ -4,6 +4,15 @@ import GlobalSearchSidebar from './components/app.vue'; Vue.use(Translate); +export const sidebarInitState = () => { + const el = document.getElementById('js-search-sidebar'); + + if (!el) return {}; + + const { navigation } = el.dataset; + return JSON.parse(navigation); +}; + export const initSidebar = (store) => { const el = document.getElementById('js-search-sidebar'); diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index dc8b6201953..be5742e5949 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; @@ -13,7 +13,7 @@ export const fetchGroups = ({ commit }, search) => { commit(types.RECEIVE_GROUPS_SUCCESS, data); }) .catch(() => { - createFlash({ message: __('There was a problem fetching groups.') }); + createAlert({ message: __('There was a problem fetching groups.') }); commit(types.RECEIVE_GROUPS_ERROR); }); }; @@ -23,7 +23,7 @@ export const fetchProjects = ({ commit, state }, search) => { const groupId = state.query?.group_id; const handleCatch = () => { - createFlash({ message: __('There was an error fetching projects') }); + createAlert({ message: __('There was an error fetching projects') }); commit(types.RECEIVE_PROJECTS_ERROR); }; const handleSuccess = ({ data }) => { @@ -59,7 +59,7 @@ export const loadFrequentGroups = async ({ commit, state }) => { const inflatedData = mergeById(await Promise.all(promises), storedData); commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: inflatedData }); } catch { - createFlash({ message: __('There was a problem fetching recent groups.') }); + createAlert({ message: __('There was a problem fetching recent groups.') }); } }; @@ -70,7 +70,7 @@ export const loadFrequentProjects = async ({ commit, state }) => { const inflatedData = mergeById(await Promise.all(promises), storedData); commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: inflatedData }); } catch { - createFlash({ message: __('There was a problem fetching recent projects.') }); + createAlert({ message: __('There was a problem fetching recent projects.') }); } }; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index f27dae8249d..d0fcbb0d83b 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,7 +1,9 @@ <script> import { GlSearchBoxByClick } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; import GroupFilter from './group_filter.vue'; import ProjectFilter from './project_filter.vue'; @@ -16,6 +18,7 @@ export default { GroupFilter, ProjectFilter, }, + mixins: [glFeatureFlagsMixin()], props: { groupInitialData: { type: Object, @@ -39,7 +42,10 @@ export default { }, }, showFilters() { - return !this.query.snippets || this.query.snippets === 'false'; + return !parseBoolean(this.query.snippets); + }, + hasVerticalNav() { + return this.glFeatures.searchPageVerticalNav; }, }, created() { @@ -52,24 +58,27 @@ export default { </script> <template> - <section class="search-page-form gl-lg-display-flex gl-align-items-flex-end"> - <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> - <label>{{ $options.i18n.searchLabel }}</label> - <gl-search-box-by-click - id="dashboard_search" - v-model="search" - name="search" - :placeholder="$options.i18n.searchPlaceholder" - @submit="applyQuery" - /> - </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Group') }}</label> - <group-filter :initial-data="groupInitialData" /> - </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Project') }}</label> - <project-filter :initial-data="projectInitialData" /> + <section class="search-page-form gl-lg-display-flex gl-flex-direction-column"> + <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end"> + <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> + <label>{{ $options.i18n.searchLabel }}</label> + <gl-search-box-by-click + id="dashboard_search" + v-model="search" + name="search" + :placeholder="$options.i18n.searchPlaceholder" + @submit="applyQuery" + /> + </div> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> + <label class="gl-display-block">{{ __('Group') }}</label> + <group-filter :initial-data="groupInitialData" /> + </div> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> + <label class="gl-display-block">{{ __('Project') }}</label> + <project-filter :initial-data="projectInitialData" /> + </div> </div> + <hr v-if="hasVerticalNav" class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> </section> </template> diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue index e9cc9616fd0..3fc279f363a 100644 --- a/app/assets/javascripts/search_settings/components/search_settings.vue +++ b/app/assets/javascripts/search_settings/components/search_settings.vue @@ -1,5 +1,5 @@ <script> -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlEmptyState, GlSearchBoxByType } from '@gitlab/ui'; import { escapeRegExp } from 'lodash'; import { EXCLUDED_NODES, @@ -96,6 +96,8 @@ const displayResults = ({ sectionSelector, expandSection, searchTerm }, matching hideSectionsExcept(sectionSelector, sections); sections.forEach(expandSection); highlightText(matchingTextNodes, searchTerm); + + return sections.length > 0; }; const clearResults = (params) => { @@ -126,6 +128,7 @@ const search = (root, searchTerm) => { export default { components: { + GlEmptyState, GlSearchBoxByType, }, props: { @@ -137,6 +140,11 @@ export default { type: String, required: true, }, + hideWhenEmptySelector: { + type: String, + required: true, + default: null, + }, isExpandedFn: { type: Function, required: false, @@ -147,8 +155,16 @@ export default { data() { return { searchTerm: '', + hasMatches: true, }; }, + watch: { + hasMatches(newHasMatches) { + document.querySelectorAll(this.hideWhenEmptySelector).forEach((section) => { + section.classList.toggle(HIDE_CLASS, !newHasMatches); + }); + }, + }, methods: { search(value) { this.searchTerm = value; @@ -161,11 +177,12 @@ export default { }; clearResults(displayOptions); + this.hasMatches = true; if (value.length) { saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions); - displayResults(displayOptions, search(this.searchRoot, this.searchTerm)); + this.hasMatches = displayResults(displayOptions, search(this.searchRoot, this.searchTerm)); } else { restoreExpansionState(displayOptions); } @@ -181,10 +198,18 @@ export default { }; </script> <template> - <gl-search-box-by-type - :value="searchTerm" - :debounce="$options.TYPING_DELAY" - :placeholder="__('Search page')" - @input="search" - /> + <div> + <gl-search-box-by-type + :value="searchTerm" + :debounce="$options.TYPING_DELAY" + :placeholder="__('Search page')" + @input="search" + /> + + <gl-empty-state + v-if="!hasMatches" + :title="__('No results found')" + :description="__('Edit your search and try again')" + /> + </div> </template> diff --git a/app/assets/javascripts/search_settings/mount.js b/app/assets/javascripts/search_settings/mount.js index b1086f9ca1f..b727b55781a 100644 --- a/app/assets/javascripts/search_settings/mount.js +++ b/app/assets/javascripts/search_settings/mount.js @@ -11,6 +11,7 @@ const mountSearch = ({ el }) => props: { searchRoot: document.querySelector('#content-body'), sectionSelector: '.js-search-settings-section, section.settings', + hideWhenEmptySelector: '.js-hide-when-nothing-matches-search', isExpandedFn: isExpanded, }, on: { diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index ecde9235e93..7828efc358a 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -21,7 +21,9 @@ export const i18n = { ), description: s__( `SecurityConfiguration|Once you've enabled a scan for the default branch, - any subsequent feature branch you create will include the scan.`, + any subsequent feature branch you create will include the scan. An enabled + scanner will not be reflected as such until the pipeline has been + successfully executed and it has generated valid artifacts.`, ), securityConfiguration: __('Security Configuration'), vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), @@ -165,7 +167,12 @@ export default { </template> </user-callout-dismisser> - <gl-tabs content-class="gl-pt-0" sync-active-tab-with-query-params lazy> + <gl-tabs + content-class="gl-pt-0" + data-qa-selector="security_configuration_container" + sync-active-tab-with-query-params + lazy + > <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting" diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue index 891d7bf2eb0..eaff1ce6055 100644 --- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue +++ b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue @@ -22,7 +22,7 @@ export default { s__('SecurityConfiguration|High-level vulnerability statistics across projects and groups'), s__('SecurityConfiguration|Runtime security metrics for application environments'), s__( - 'SecurityConfiguration|More scan types, including Container Scanning, DAST, Dependency Scanning, Fuzzing, and Licence Compliance', + 'SecurityConfiguration|More scan types, including DAST, Dependency Scanning, Fuzzing, and Licence Compliance', ), ], buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'), diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js index f145a1b30db..f2c3f28cefa 100644 --- a/app/assets/javascripts/service_ping_consent.js +++ b/app/assets/javascripts/service_ping_consent.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import createFlash, { hideFlash } from './flash'; +import { createAlert, hideFlash } from './flash'; import axios from './lib/utils/axios_utils'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; @@ -27,7 +27,7 @@ export default () => { }) .catch(() => { hideConsentMessage(); - createFlash({ + createAlert({ message: __('Something went wrong. Try again later.'), }); }); diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue index 7f9a30b7ff1..86049a2b781 100644 --- a/app/assets/javascripts/set_status_modal/set_status_form.vue +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -131,9 +131,9 @@ export default { i18n: { statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`), clearStatusButtonLabel: s__('SetStatusModal|Clear status'), - availabilityCheckboxLabel: s__('SetStatusModal|Busy'), + availabilityCheckboxLabel: s__('SetStatusModal|Set yourself as busy'), availabilityCheckboxHelpText: s__( - 'SetStatusModal|An indicator appears next to your name and avatar', + 'SetStatusModal|Displays that you are busy or not able to respond', ), clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'), clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'), @@ -161,11 +161,7 @@ export default { @click="handleEmojiClick" > <template #button-content> - <span - v-if="noEmoji" - class="no-emoji-placeholder position-relative" - data-testid="no-emoji-placeholder" - > + <span v-if="noEmoji" class="gl-relative" data-testid="no-emoji-placeholder"> <gl-icon name="slight-smile" class="award-control-icon-neutral" /> <gl-icon name="smiley" class="award-control-icon-positive" /> <gl-icon name="smile" class="award-control-icon-super-positive" /> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 80b1cb8c4d5..80158c55dbc 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -1,7 +1,7 @@ <script> import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import { updateUserStatus } from '~/rest_api'; @@ -89,7 +89,7 @@ export default { window.location.reload(); }, onUpdateFail() { - createFlash({ + createAlert({ message: s__( "SetStatusModal|Sorry, we weren't able to set your status. Please try again later.", ), diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 18b26c7d8bd..15fd365b4da 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,6 +1,6 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; @@ -113,7 +113,7 @@ export default { }) .catch(() => { this.loading = false; - return createFlash({ + return createAlert({ message: __('Error occurred when saving assignees'), }); }); diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 26fda2a823c..395dcf73693 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { __, n__ } from '~/locale'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; @@ -111,7 +111,7 @@ export default { } }, error() { - createFlash({ message: __('An error occurred while fetching participants.') }); + createAlert({ message: __('An error occurred while fetching participants.') }); }, }, }, @@ -191,7 +191,7 @@ export default { return data; }) .catch(() => { - createFlash({ message: __('An error occurred while updating assignees.') }); + createAlert({ message: __('An error occurred while updating assignees.') }); }) .finally(() => { this.isSettingAssignees = false; @@ -220,7 +220,7 @@ export default { this.$refs.userSelect.showDropdown(); }, showError() { - createFlash({ message: __('An error occurred while fetching participants.') }); + createAlert({ message: __('An error occurred while fetching participants.') }); }, setDirtyState() { this.isDirty = true; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 0ed40f56bea..29298ef7627 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -58,7 +58,7 @@ export default { v-if="hasCannotMergeIcon" name="warning-solid" aria-hidden="true" - class="merge-icon gl-left-6 gl-bottom-0" + class="merge-icon" :size="12" /> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index c44ce8b0057..3532b75b6e7 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,6 +1,6 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '~/sidebar/constants'; @@ -92,7 +92,7 @@ export default { }, }) => { if (errors.length) { - createFlash({ + createAlert({ message: errors[0], }); } else { @@ -101,7 +101,7 @@ export default { }, ) .catch(() => { - createFlash({ + createAlert({ message: sprintf( __('Something went wrong while setting %{issuableType} confidentiality.'), { diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index f234c5ea3c9..f3bd58c11d4 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -1,7 +1,7 @@ <script> import produce from 'immer'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { confidentialityQueries, Tracking } from '~/sidebar/constants'; @@ -72,7 +72,7 @@ export default { this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential); }, error() { - createFlash({ + createAlert({ message: sprintf( __('Something went wrong while setting %{issuableType} confidentiality.'), { diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue index 67f36f65b5d..81090bfa062 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql'; @@ -41,7 +41,7 @@ export default { return data?.issue?.customerRelationsContacts?.nodes; }, error(error) { - createFlash({ + createAlert({ message: __('Something went wrong trying to load issue contacts.'), error, captureError: true, 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 ef99d540c86..98468583992 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; @@ -92,7 +92,7 @@ export default { this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]); }, error() { - createFlash({ + createAlert({ message: sprintf( __('Something went wrong while setting %{issuableType} %{dateType} date.'), { @@ -205,7 +205,7 @@ export default { }, }) => { if (errors.length) { - createFlash({ + createAlert({ message: errors[0], }); } else { @@ -214,7 +214,7 @@ export default { }, ) .catch(() => { - createFlash({ + createAlert({ message: sprintf( __('Something went wrong while setting %{issuableType} %{dateType} date.'), { diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index 8145506f32c..df03af346c0 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import $ from 'jquery'; import { mapActions } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; import eventHub from '../../event_hub'; @@ -52,7 +52,7 @@ export default { const flashMessage = __( 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', ); - createFlash({ + createAlert({ message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }), }); }) diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 286bd50f6dd..d32d8a7b044 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -3,7 +3,7 @@ import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitl import { mapGetters, mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import eventHub from '~/sidebar/event_hub'; import toast from '~/vue_shared/plugins/global_toast'; import EditForm from './edit_form.vue'; @@ -95,7 +95,7 @@ export default { const flashMessage = __( 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', ); - createFlash({ + createAlert({ message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }), }); }) diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index 933b9b11b40..55bb214aa65 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -43,6 +43,7 @@ export default { data-track-action="click_edit_button" data-track-label="right_sidebar" data-track-property="reviewer" + data-qa-selector="reviewers_edit_button" > {{ __('Edit') }} </a> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index b0d820ddd15..ad061dd2e6b 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -3,7 +3,7 @@ // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import Vue from 'vue'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; @@ -64,7 +64,7 @@ export default { this.initialLoading = false; }, error() { - createFlash({ message: __('An error occurred while fetching reviewers.') }); + createAlert({ message: __('An error occurred while fetching reviewers.') }); }, }, }, @@ -85,7 +85,7 @@ export default { return this.loading || this.$apollo.queries.issuable.loading; }, canUpdate() { - return this.issuable.userPermissions?.updateMergeRequest || false; + return this.issuable.userPermissions?.adminMergeRequest || false; }, }, created() { @@ -120,7 +120,7 @@ export default { }) .catch(() => { this.loading = false; - return createFlash({ + return createAlert({ message: __('Error occurred when saving reviewers'), }); }); diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index a562df4ecd6..f02e0c783e1 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -7,7 +7,7 @@ import { GlSprintf, GlButton, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants'; import updateIssuableSeverity from './graphql/mutations/update_issuable_severity.mutation.graphql'; import SeverityToken from './severity.vue'; @@ -123,7 +123,7 @@ export default { this.severity = severity; }) .catch(() => - createFlash({ + createAlert({ message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`, }), ) diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 6c615109bb8..c33b1468ca4 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -13,7 +13,7 @@ import { GlButton, } from '@gitlab/ui'; import { kebabCase, snakeCase } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; @@ -25,6 +25,8 @@ import { Tracking, IssuableAttributeState, IssuableAttributeType, + LocalizedIssuableAttributeType, + IssuableAttributeTypeKeyMap, issuableAttributesQueries, noAttributeId, defaultEpicSort, @@ -125,7 +127,7 @@ export default { return data?.workspace?.issuable.attribute; }, error(error) { - createFlash({ + createAlert({ message: this.i18n.currentFetchError, captureError: true, error, @@ -179,7 +181,7 @@ export default { return []; }, error(error) { - createFlash({ message: this.i18n.listFetchError, captureError: true, error }); + createAlert({ message: this.i18n.listFetchError, captureError: true, error }); }, }, }, @@ -229,7 +231,9 @@ export default { return timeFor(this.currentAttribute?.dueDate); }, i18n() { - return dropdowni18nText(this.issuableAttribute, this.issuableType); + const localizedAttribute = + LocalizedIssuableAttributeType[IssuableAttributeTypeKeyMap[this.issuableAttribute]]; + return dropdowni18nText(localizedAttribute, this.issuableType); }, isEpic() { // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 @@ -280,7 +284,7 @@ export default { }) .then(({ data }) => { if (data.issuableSetAttribute?.errors?.length) { - createFlash({ + createAlert({ message: data.issuableSetAttribute.errors[0], captureError: true, error: data.issuableSetAttribute.errors[0], @@ -290,7 +294,7 @@ export default { } }) .catch((error) => { - createFlash({ message: this.i18n.updateError, captureError: true, error }); + createAlert({ message: this.i18n.updateError, captureError: true, error }); }) .finally(() => { this.updating = false; diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index cc88812c7b0..1680e42e5e4 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -89,7 +89,9 @@ export default { return; } - this.edit = true; + if (this.canEdit && this.canUpdate) { + this.edit = true; + } this.$emit('open'); window.addEventListener('click', this.collapseWhenOffClick); window.addEventListener('keyup', this.collapseOnEscape); @@ -125,7 +127,7 @@ export default { <template> <div> <div - class="gl-display-flex gl-align-items-center gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold" + class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold" @click.self="collapse" > <span class="hide-collapsed" data-testid="title" @click="collapse"> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index e5bee4df9b8..99e7c825b72 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,6 +1,6 @@ <script> -import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; @@ -22,6 +22,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { + GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, @@ -73,7 +74,7 @@ export default { this.$emit('subscribedUpdated', data.workspace?.issuable?.subscribed); }, error() { - createFlash({ + createAlert({ message: sprintf( __('Something went wrong while setting %{issuableType} notifications.'), { @@ -137,7 +138,7 @@ export default { }, }) => { if (errors.length) { - createFlash({ + createAlert({ message: errors[0], }); } @@ -148,7 +149,7 @@ export default { }, ) .catch(() => { - createFlash({ + createAlert({ message: sprintf( __('Something went wrong while setting %{issuableType} notifications.'), { @@ -181,7 +182,7 @@ export default { </script> <template> - <div v-if="isMergeRequest" class="gl-new-dropdown-item"> + <gl-dropdown-form v-if="isMergeRequest" class="gl-new-dropdown-item"> <div class="gl-px-5 gl-pb-2 gl-pt-1"> <gl-toggle :value="subscribed" @@ -192,7 +193,7 @@ export default { @change="toggleSubscribed" /> </div> - </div> + </gl-dropdown-form> <sidebar-editable-item v-else ref="editable" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index d751816bd94..124464088cf 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; @@ -47,7 +47,7 @@ export default { return this.extractTimelogs(data); }, error() { - createFlash({ message: __('Something went wrong. Please try again.') }); + createAlert({ message: __('Something went wrong. Please try again.') }); }, }, }, @@ -105,7 +105,7 @@ export default { } }) .catch((error) => { - createFlash({ + createAlert({ message: s__('TimeTracking|An error occurred while removing the timelog.'), captureError: true, error, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index d472b67d976..62b05421884 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -61,7 +61,7 @@ export default { </script> <template> - <div class="block"> + <div class="block time-tracking"> <issuable-time-tracker :full-path="fullPath" :issuable-id="issuableId" diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index 42e16aae312..5da2d65723a 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { produce } from 'immer'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils'; @@ -73,7 +73,7 @@ export default { this.$emit('todoUpdated', currentUserTodos.length > 0); }, error() { - createFlash({ + createAlert({ message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), { issuableType: this.issuableType, }), @@ -155,7 +155,7 @@ export default { }, }) => { if (errors.length) { - createFlash({ + createAlert({ message: errors[0], }); } @@ -166,7 +166,7 @@ export default { }, ) .catch(() => { - createFlash({ + createAlert({ message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), { issuableType: this.issuableType, }), diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 60cb4cff727..6248bcb8e2d 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,3 +1,4 @@ +import { invert } from 'lodash'; import { s__, __, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; @@ -251,6 +252,12 @@ export const IssuableAttributeType = { Milestone: 'milestone', }; +export const LocalizedIssuableAttributeType = { + Milestone: s__('Issuable|milestone'), +}; + +export const IssuableAttributeTypeKeyMap = invert(IssuableAttributeType); + export const IssuableAttributeState = { [IssuableAttributeType.Milestone]: 'active', }; diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js index 5a3122e83d0..2cce27df598 100644 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { escape } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; function isValidProjectId(id) { @@ -44,7 +44,7 @@ class SidebarMoveIssue { .fetchAutocompleteProjects(searchTerm) .then(callback) .catch(() => - createFlash({ + createAlert({ message: __('An error occurred while fetching projects autocomplete.'), }), ); @@ -79,7 +79,7 @@ class SidebarMoveIssue { this.$confirmButton.disable().addClass('is-loading'); this.mediator.moveIssue().catch(() => { - createFlash({ message: __('An error occurred while moving the issue.') }); + createAlert({ message: __('An error occurred while moving the issue.') }); this.$confirmButton.enable().removeClass('is-loading'); }); } diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 1cb3c30b9e0..9b5bad710dd 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -161,7 +161,7 @@ function mountAssigneesComponent() { fullPath, issuableType, issuableId: id, - allowMultipleAssignees: !el.dataset.maxAssignees, + allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1, editable, }, scopedSlots: { diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql index d665ca1e084..71ce58fb9cc 100644 --- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql @@ -1,5 +1,4 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" -#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query epicParticipants($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { @@ -9,7 +8,6 @@ query epicParticipants($fullPath: ID!, $iid: ID) { participants { nodes { ...User - ...UserAvailability } } } diff --git a/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql b/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql deleted file mode 100644 index 90d1a7794ea..00000000000 --- a/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query sidebarDetails($fullPath: ID!, $iid: String!) { - project(fullPath: $fullPath) { - id - issue(iid: $iid) { - id - iid - } - } -} diff --git a/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql b/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql deleted file mode 100644 index 0505f88773d..00000000000 --- a/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) { - project(fullPath: $fullPath) { - id - mergeRequest(iid: $iid) { - id - iid # currently unused. - } - } -} diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index beacdeb559c..00d3177b75a 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,15 +1,8 @@ -import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebar_details.query.graphql'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; -import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql'; - -const queries = { - merge_request: sidebarDetailsMRQuery, - issue: sidebarDetailsIssueQuery, -}; export const gqClient = createGqClient( {}, @@ -36,37 +29,13 @@ export default class SidebarService { } get() { - return Promise.all([ - axios.get(this.endpoint), - gqClient.query({ - query: this.sidebarDetailsQuery(), - variables: { - fullPath: this.fullPath, - iid: this.iid.toString(), - }, - }), - ]); - } - - sidebarDetailsQuery() { - return queries[this.issuableType]; + return axios.get(this.endpoint); } update(key, data) { return axios.put(this.endpoint, { [key]: data }); } - updateWithGraphQl(mutation, variables) { - return gqClient.mutate({ - mutation, - variables: { - ...variables, - projectPath: this.fullPath, - iid: this.iid.toString(), - }, - }); - } - getProjectsAutocomplete(searchTerm) { return axios.get(this.projectsAutocompleteEndpoint, { params: { diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index f7c93b6903c..912f0fdcbef 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,5 +1,5 @@ import Store from '~/sidebar/stores/sidebar_store'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import { visitUrl } from '../lib/utils/url_utility'; @@ -93,11 +93,11 @@ export default class SidebarMediator { fetch() { return this.service .get() - .then(([restResponse, graphQlResponse]) => { - this.processFetchedData(restResponse.data, graphQlResponse.data); + .then(({ data }) => { + this.processFetchedData(data); }) .catch(() => - createFlash({ + createAlert({ message: __('Error occurred when fetching sidebar data'), }), ); diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 26838682fc8..6e5b2ce4dbe 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,10 +1,10 @@ /* eslint-disable consistent-return */ import $ from 'jquery'; +import { createAlert } from '~/flash'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { spriteIcon } from '~/lib/utils/common_utils'; import FilesCommentButton from './files_comment_button'; -import createFlash from './flash'; import initImageDiffHelper from './image_diff/helpers/init_image_diff'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; @@ -96,7 +96,7 @@ export default class SingleFileDiff { if (cb) cb(); }) .catch(() => { - createFlash({ + createAlert({ message: __('An error occurred while retrieving diff'), }); }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 2537ec78850..4a7528d9c8e 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -2,7 +2,7 @@ import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui'; import eventHub from '~/blob/components/eventhub'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { redirectTo, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -145,7 +145,7 @@ export default { const defaultErrorMsg = this.newSnippet ? SNIPPET_CREATE_MUTATION_ERROR : SNIPPET_UPDATE_MUTATION_ERROR; - createFlash({ + createAlert({ message: sprintf(defaultErrorMsg, { err }), }); this.isUpdating = false; diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index fe169775f96..7e80928cbea 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; @@ -63,7 +63,7 @@ export default { .catch((e) => this.flashAPIFailure(e)); }, flashAPIFailure(err) { - createFlash({ message: sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }) }); + createAlert({ message: sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }) }); }, }, }; diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 86cbc2c31b3..360ffdd34e0 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -53,7 +53,9 @@ export default { return { blobContent: '', activeViewerType: - this.blob?.richViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, + this.blob?.richViewer && !window.location.hash?.startsWith('#LC') + ? RICH_BLOB_VIEWER + : SIMPLE_BLOB_VIEWER, }; }, computed: { diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index dd8f2897018..759a3f31a05 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -19,7 +19,7 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/flash'; import DeleteSnippetMutation from '../mutations/delete_snippet.mutation.graphql'; @@ -196,12 +196,12 @@ export default { try { this.isSubmittingSpam = true; await axios.post(this.reportAbusePath); - createFlash({ + createAlert({ message: this.$options.i18n.snippetSpamSuccess, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); } catch (error) { - createFlash({ message: this.$options.i18n.snippetSpamFailure }); + createAlert({ message: this.$options.i18n.snippetSpamFailure, variant: VARIANT_DANGER }); } finally { this.isSubmittingSpam = false; } diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 6e72d95c8e6..a7760ad5d0b 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import 'deckar01-task_list'; import { __ } from '~/locale'; -import createFlash from './flash'; +import { createAlert } from '~/flash'; import axios from './lib/utils/axios_utils'; export default class TaskList { @@ -23,7 +23,7 @@ export default class TaskList { errorMessages = e.response.data.errors.join(' '); } - return createFlash({ + return createAlert({ message: errorMessages || __('Update failed'), }); }; diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue index 363a9d58d65..4b91872d80d 100644 --- a/app/assets/javascripts/token_access/components/token_access.vue +++ b/app/assets/javascripts/token_access/components/token_access.vue @@ -1,5 +1,6 @@ <script> import { + GlAlert, GlButton, GlCard, GlFormInput, @@ -8,7 +9,7 @@ import { GlSprintf, GlToggle, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; @@ -25,6 +26,9 @@ export default { `CICD|Select the projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`, ), cardHeaderTitle: s__('CICD|Add an existing project to the scope'), + settingDisabledMessage: s__( + 'CICD|Enable feature to limit job token access to the following projects.', + ), addProject: __('Add project'), cancel: __('Cancel'), addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'), @@ -32,6 +36,7 @@ export default { scopeFetchError: __('There was a problem fetching the job token scope value'), }, components: { + GlAlert, GlButton, GlCard, GlFormInput, @@ -58,7 +63,7 @@ export default { return data.project.ciCdSettings.jobTokenScopeEnabled; }, error() { - createFlash({ message: this.$options.i18n.scopeFetchError }); + createAlert({ message: this.$options.i18n.scopeFetchError }); }, }, projects: { @@ -72,7 +77,7 @@ export default { return data.project?.ciJobTokenScope?.projects?.nodes ?? []; }, error() { - createFlash({ message: this.$options.i18n.projectsFetchError }); + createAlert({ message: this.$options.i18n.projectsFetchError }); }, }, }, @@ -112,7 +117,7 @@ export default { throw new Error(errors[0]); } } catch (error) { - createFlash({ message: error }); + createAlert({ message: error }); } }, async addProject() { @@ -135,7 +140,7 @@ export default { throw new Error(errors[0]); } } catch (error) { - createFlash({ message: error }); + createAlert({ message: error }); } finally { this.clearTargetProjectPath(); this.getProjects(); @@ -161,7 +166,7 @@ export default { throw new Error(errors[0]); } } catch (error) { - createFlash({ message: error }); + createAlert({ message: error }); } finally { this.getProjects(); } @@ -195,8 +200,8 @@ export default { </template> </gl-toggle> - <div data-testid="token-section"> - <gl-card class="gl-mt-5"> + <div> + <gl-card class="gl-mt-5 gl-mb-3"> <template #header> <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> </template> @@ -213,7 +218,16 @@ export default { <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> </template> </gl-card> - + <gl-alert + v-if="!jobTokenScopeEnabled" + class="gl-mb-3" + variant="warning" + :dismissible="false" + :show-icon="false" + data-testid="token-disabled-alert" + > + {{ $options.i18n.settingDisabledMessage }} + </gl-alert> <token-projects-table :projects="projects" @removeProject="removeProject" /> </div> </template> diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 94b4ee77e7e..bd425bdc2a8 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -50,6 +50,7 @@ function UsersSelect(currentUser, els, options = {}) { options.iid = $dropdown.data('iid'); options.issuableType = $dropdown.data('issuableType'); options.targetBranch = $dropdown.data('targetBranch'); + options.showSuggested = $dropdown.data('showSuggested'); const showNullUser = $dropdown.data('nullUser'); const defaultNullUser = $dropdown.data('nullUserDefault'); const showMenuAbove = $dropdown.data('showMenuAbove'); @@ -340,6 +341,16 @@ function UsersSelect(currentUser, els, options = {}) { if ($dropdown.hasClass('js-multiselect')) { const selected = getSelected().filter((i) => i !== 0); + if ($dropdown.data('showSuggested')) { + const suggested = this.suggestedUsers(users); + if (suggested.length) { + users = users.filter( + (u) => !u.suggested || (u.suggested && selected.indexOf(u.id) !== -1), + ); + users.splice(showDivider + 1, 0, ...suggested); + } + } + if (selected.length > 0) { if ($dropdown.data('dropdownHeader')) { showDivider += 1; @@ -370,6 +381,19 @@ function UsersSelect(currentUser, els, options = {}) { $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove(); } }, + suggestedUsers(users) { + const selected = getSelected().filter((i) => i !== 0); + const suggestedUsers = users.filter((u) => u.suggested && selected.indexOf(u.id) === -1); + + if (!suggestedUsers.length) return []; + + const items = [ + { type: 'header', content: $dropdown.data('suggestedReviewersHeader') }, + ...suggestedUsers, + { type: 'header', content: $dropdown.data('allMembersHeader') }, + ]; + return items; + }, filterable: true, filterRemote: true, search: { @@ -760,6 +784,10 @@ UsersSelect.prototype.users = function (query, options, callback) { params.approval_rules = true; } + if (isMergeRequest && options.showSuggested) { + params.show_suggested = true; + } + if (isNewMergeRequest) { params.target_branch = options.targetBranch || null; } @@ -791,13 +819,14 @@ UsersSelect.prototype.renderRow = function ( const tooltipAttributes = tooltip ? `data-container="body" data-placement="left" data-title="${tooltip}"` : ''; + const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : ''; const name = user?.availability && isUserBusy(user.availability) ? sprintf(__('%{name} (Busy)'), { name: user.name }) : user.name; return ` - <li data-user-id=${user.id}> + <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"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index 30a0e7c383c..5339d7faf85 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -74,9 +74,11 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-align-items-flex-start"> <gl-dropdown v-if="tertiaryButtons.length" + v-gl-tooltip + :title="__('Options')" :text="dropdownLabel" icon="ellipsis_v" no-caret 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 f782c28ea19..2cfeb7a4bcb 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,6 +1,6 @@ <script> import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __ } from '~/locale'; @@ -139,7 +139,7 @@ export default { this.fetchingApprovals = false; }) .catch(() => - createFlash({ + createAlert({ message: FETCH_ERROR, }), ); @@ -154,7 +154,7 @@ export default { this.updateApproval( () => this.service.approveMergeRequest(), () => - createFlash({ + createAlert({ message: APPROVE_ERROR, }), ); @@ -167,7 +167,7 @@ export default { this.hasApprovalAuthError = true; return; } - createFlash({ + createAlert({ message: APPROVE_ERROR, }); }, @@ -177,7 +177,7 @@ export default { this.updateApproval( () => this.service.unapproveMergeRequest(), () => - createFlash({ + createAlert({ message: UNAPPROVE_ERROR, }), ); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 7ba387c79b1..d6d1cae4029 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -1,9 +1,10 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import eventHub from '../../event_hub'; import MRWidgetService from '../../services/mr_widget_service'; import { MANUAL_DEPLOY, @@ -129,11 +130,12 @@ export default { } }) .catch(() => { - createFlash({ + createAlert({ message: errorMessage, }); }) .finally(() => { + eventHub.$emit('FetchDeployments'); this.actionInProgress = null; }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 1e363b0f5fb..5efa0e2879e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -7,7 +7,10 @@ import { GlLink, GlSearchBoxByType, } from '@gitlab/ui'; +import { isSafeURL } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import ReviewAppLink from '../review_app_link.vue'; export default { @@ -19,6 +22,7 @@ export default { GlIcon, GlLink, GlSearchBoxByType, + ModalCopyButton, ReviewAppLink, }, directives: { @@ -50,6 +54,13 @@ export default { filteredChanges() { return this.deployment?.changes?.filter((change) => change.path.includes(this.searchTerm)); }, + isSafeUrl() { + return isSafeURL(this.deploymentExternalUrl); + }, + }, + i18n: { + copy: __('Copy URL'), + copyTitle: s__('Environments|Copy live environment URL'), }, }; </script> @@ -57,11 +68,20 @@ export default { <span class="gl-display-inline-flex"> <gl-button-group v-if="shouldRenderDropdown" size="small"> <review-app-link + v-if="isSafeUrl" :display="appButtonText" :link="deploymentExternalUrl" size="small" css-class="deploy-link js-deploy-url inline gl-ml-3" /> + <modal-copy-button + v-else + :title="$options.i18n.copyTitle" + :text="deploymentExternalUrl" + size="small" + > + {{ $options.i18n.copy }} + </modal-copy-button> <gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown"> <template #button-content> <gl-icon @@ -90,12 +110,22 @@ export default { </gl-dropdown-item> </gl-dropdown> </gl-button-group> - <review-app-link - v-else - :display="appButtonText" - :link="deploymentExternalUrl" - size="small" - css-class="js-deploy-url deploy-link btn btn-default btn-sm inline gl-ml-3" - /> + <template v-else> + <review-app-link + v-if="isSafeUrl" + :display="appButtonText" + :link="deploymentExternalUrl" + size="small" + css-class="deploy-link js-deploy-url inline gl-ml-3" + /> + <modal-copy-button + v-else + :title="$options.i18n.copyTitle" + :text="deploymentExternalUrl" + size="small" + > + {{ $options.i18n.copy }} + </modal-copy-button> + </template> </span> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 300e2a672cb..3d03dbd9db3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -315,7 +315,6 @@ export default { data-qa-selector="mr_widget_extension" > <state-container - :mr="mr" :status="statusIconName" :is-loading="isLoadingSummary" :class="{ 'gl-cursor-pointer': isCollapsible }" @@ -324,7 +323,7 @@ export default { @mouseup="onRowMouseUp" > <div - class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" + class="media-body gl-display-flex gl-flex-direction-row! gl-w-full" data-testid="widget-extension-top-level" > <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> 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 d67ff11f297..e3f87c08ad4 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 @@ -28,7 +28,7 @@ const nonStandardEvents = { }, counter: {}, }, - testReport: { + testSummary: { uniqueUser: { expand: ['i_testing_summary_widget_total'], }, 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 94a1b805b99..870972156c5 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 @@ -26,16 +26,6 @@ export default { required: false, default: true, }, - divergedCommitsCount: { - type: Number, - required: false, - default: 0, - }, - targetBranchPath: { - type: String, - required: false, - default: '', - }, }, computed: { closesText() { 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 822c5a68093..932659f3c89 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 @@ -16,7 +16,8 @@ export default { props: { mr: { type: Object, - required: true, + required: false, + default: null, }, isLoading: { type: Boolean, @@ -80,6 +81,7 @@ 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" > <gl-button 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 e2a9caf5419..2b22033514f 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 @@ -1,6 +1,7 @@ <script> import { s__ } from '~/locale'; import StatusIcon from '../mr_widget_status_icon.vue'; +import { DETAILED_MERGE_STATUS } from '../../constants'; export default { i18n: { @@ -22,7 +23,7 @@ export default { failedText() { if (this.mr.approvals && !this.mr.isApproved) { return this.$options.i18n.approvalNeeded; - } else if (this.mr.blockingMergeRequests?.total_count > 0) { + } else if (this.mr.detailedMergeStatus === DETAILED_MERGE_STATUS.BLOCKED_STATUS) { return this.$options.i18n.blockingMergeRequests; } 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 3c6c2a44e70..92a7fa39cdc 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 @@ -2,7 +2,7 @@ import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; @@ -113,7 +113,7 @@ export default { }) .catch(() => { this.isCancellingAutoMerge = false; - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); }); @@ -141,7 +141,7 @@ export default { }) .catch(() => { this.isRemovingSourceBranch = false; - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); }); 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 e9298b0c856..46392565088 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 @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__, __ } from '~/locale'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; import modalEventHub from '~/projects/commit/event_hub'; @@ -131,7 +131,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); }); 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 37c8d5d15f3..f6843c1f3d3 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 @@ -1,6 +1,6 @@ <script> import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import toast from '~/vue_shared/plugins/global_toast'; @@ -111,7 +111,7 @@ export default { if (error.response && error.response.data && error.response.data.merge_error) { this.rebasingError = error.response.data.merge_error; } else { - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); } @@ -142,7 +142,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); stopPolling(); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index 3cbd171a035..853895a4296 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -11,6 +11,12 @@ export default { GlSprintf, StatusIcon, }, + props: { + mr: { + type: Object, + required: true, + }, + }, computed: { troubleshootingDocsPath() { return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' }); @@ -29,7 +35,14 @@ export default { <status-icon status="failed" /> <div class="media-body space-children"> <span class="gl-font-weight-bold"> - <gl-sprintf :message="$options.i18n.failedMessage"> + <span v-if="mr.isPipelineBlocked"> + {{ + s__( + `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, + ) + }} + </span> + <gl-sprintf v-else :message="$options.i18n.failedMessage"> <template #link="{ content }"> <gl-link :href="troubleshootingDocsPath" target="_blank"> {{ content }} 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 78430abcfe9..1298c1316e2 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 @@ -14,10 +14,10 @@ import { import { isEmpty } from 'lodash'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; -import { __, s__ } from '~/locale'; +import { __, s__, n__ } from '~/locale'; import SmartInterval from '~/smart_interval'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -325,15 +325,20 @@ export default { ); }, sourceBranchDeletedText() { - if (this.removeSourceBranch) { - return this.mr.state === 'merged' - ? __('Deleted the source branch.') - : __('Source branch will be deleted.'); + const isPreMerge = this.mr.state !== 'merged'; + + if (isPreMerge) { + return this.mr.shouldRemoveSourceBranch + ? __('Source branch will be deleted.') + : __('Source branch will not be deleted.'); } - return this.mr.state === 'merged' - ? __('Did not delete the source branch.') - : __('Source branch will not be deleted.'); + return this.mr.sourceBranchRemoved + ? __('Deleted the source branch.') + : __('Did not delete the source branch.'); + }, + sourceHasDivergedFromTarget() { + return this.mr.divergedCommitsCount > 0; }, showMergeDetailsHeader() { return ['readyToMerge'].indexOf(this.mr.state) >= 0; @@ -439,7 +444,7 @@ export default { .catch(() => { this.isMakingRequest = false; this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); }); @@ -483,7 +488,7 @@ export default { } }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong while deleting the source branch. Please try again.'), }); }); @@ -507,6 +512,8 @@ export default { mergeAndSquashCommitTemplatesHintText: s__( 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more.%{linkEnd}', ), + sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch'), + divergedCommits: (count) => n__('%d commit behind', '%d commits behind', count), }, }; </script> @@ -530,130 +537,148 @@ export default { <div class="mr-widget-body mr-widget-body-ready-merge media mr-widget-body-line-height-1"> <div class="media-body"> <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> - <gl-button-group v-if="shouldShowMergeControls" class="gl-align-self-start"> - <gl-button - size="medium" - category="primary" - class="accept-merge-request" - data-testid="merge-button" - variant="confirm" - :disabled="isMergeButtonDisabled" - :loading="isMakingRequest" - data-qa-selector="merge_button" - @click="handleMergeButtonClick(isAutoMergeAvailable)" - >{{ mergeButtonText }}</gl-button - > - <gl-dropdown - v-if="shouldShowMergeImmediatelyDropdown" - v-gl-tooltip.hover.focus="__('Select merge moment')" - :disabled="isMergeButtonDisabled" - variant="confirm" - data-qa-selector="merge_moment_dropdown" - toggle-class="btn-icon js-merge-moment" - > - <template #button-content> - <gl-icon name="chevron-down" class="mr-0" /> - <span class="sr-only">{{ __('Select merge moment') }}</span> - </template> - <gl-dropdown-item - icon-name="warning" - button-class="accept-merge-request js-merge-immediately-button" - data-qa-selector="merge_immediately_menu_item" - @click="handleMergeImmediatelyButtonClick" + <template v-if="shouldShowMergeControls"> + <div class="gl-display-flex gl-align-items-center gl-flex-wrap gl-w-full gl-mb-5"> + <gl-form-checkbox + v-if="canRemoveSourceBranch" + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5" > - {{ __('Merge immediately') }} - </gl-dropdown-item> - <merge-immediately-confirmation-dialog - ref="confirmationDialog" - :docs-url="mr.mergeImmediatelyDocsPath" - @mergeImmediately="onMergeImmediatelyConfirmation" + {{ __('Delete source branch') }} + </gl-form-checkbox> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + v-model="squashBeforeMerge" + :help-path="mr.squashBeforeMergeHelpPath" + :is-disabled="isSquashReadOnly" + class="gl-mr-5" /> - </gl-dropdown> - <merge-train-failed-pipeline-confirmation-dialog - :visible="isPipelineFailedModalVisibleMergeTrain" - @startMergeTrain="onStartMergeTrainConfirmation" - @cancel="isPipelineFailedModalVisibleMergeTrain = false" - /> - <merge-failed-pipeline-confirmation-dialog - :visible="isPipelineFailedModalVisibleNormalMerge" - @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation" - @cancel="isPipelineFailedModalVisibleNormalMerge = false" - /> - </gl-button-group> - <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> - <div - v-if="shouldShowMergeControls" - class="gl-display-flex gl-align-items-center gl-flex-wrap gl-w-full gl-order-n1 gl-mb-5" - > - <gl-form-checkbox - v-if="canRemoveSourceBranch" - id="remove-source-branch-input" - v-model="removeSourceBranch" - :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5" - > - {{ __('Delete source branch') }} - </gl-form-checkbox> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - v-model="squashBeforeMerge" - :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isSquashReadOnly" - class="gl-mr-5" - /> - - <gl-form-checkbox - v-if="shouldShowSquashEdit || shouldShowMergeEdit" - v-model="editCommitMessage" - data-testid="widget_edit_commit_message" - class="gl-display-flex gl-align-items-center" - > - {{ __('Edit commit message') }} - </gl-form-checkbox> - </div> - <div - v-if="editCommitMessage" - class="gl-w-full gl-order-n1" - data-testid="edit_commit_message" - > - <ul class="border-top commits-list flex-list gl-list-style-none gl-p-0 gl-pt-4"> - <commit-edit - v-if="shouldShowSquashEdit" - :value="squashCommitMessage" - :label="__('Squash commit message')" - input-id="squash-message-edit" - class="gl-m-0! gl-p-0!" - @input="setSquashCommitMessage" + + <gl-form-checkbox + v-if="shouldShowSquashEdit || shouldShowMergeEdit" + v-model="editCommitMessage" + data-testid="widget_edit_commit_message" + class="gl-display-flex gl-align-items-center" > - <template #header> - <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> + {{ __('Edit commit message') }} + </gl-form-checkbox> + </div> + <div class="gl-w-full gl-text-gray-500 gl-mb-5"> + <added-commit-message + :is-squash-enabled="squashBeforeMerge" + :is-fast-forward-enabled="!shouldShowMergeEdit" + :commits-count="commitsCount" + :target-branch="stateData.targetBranch" + /> + <template v-if="mr.relatedLinks"> + · + <related-links + :state="mr.state" + :related-links="mr.relatedLinks" + :show-assign-to-me="false" + :diverged-commits-count="mr.divergedCommitsCount" + :target-branch-path="mr.targetBranchPath" + class="mr-ready-merge-related-links gl-display-inline" + /> + </template> + </div> + <div v-if="editCommitMessage" class="gl-w-full" data-testid="edit_commit_message"> + <ul class="border-top commits-list flex-list gl-list-style-none gl-p-0 gl-pt-4"> + <commit-edit + v-if="shouldShowSquashEdit" + :value="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + class="gl-m-0! gl-p-0!" + @input="setSquashCommitMessage" + > + <template #header> + <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> + </template> + </commit-edit> + <commit-edit + v-if="shouldShowMergeEdit" + :value="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + class="gl-m-0! gl-p-0!" + @input="setCommitMessage" + /> + <li class="gl-m-0! gl-p-0!"> + <p class="form-text text-muted"> + <gl-sprintf :message="commitTemplateHintText"> + <template #link="{ content }"> + <gl-link + :href="commitTemplateHelpPage" + class="inline-link" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </li> + </ul> + </div> + <gl-button-group class="gl-align-self-start"> + <gl-button + size="medium" + category="primary" + class="accept-merge-request" + data-testid="merge-button" + variant="confirm" + :disabled="isMergeButtonDisabled" + :loading="isMakingRequest" + data-qa-selector="merge_button" + @click="handleMergeButtonClick(isAutoMergeAvailable)" + >{{ mergeButtonText }}</gl-button + > + <gl-dropdown + v-if="shouldShowMergeImmediatelyDropdown" + v-gl-tooltip.hover.focus="__('Select merge moment')" + :disabled="isMergeButtonDisabled" + variant="confirm" + data-qa-selector="merge_moment_dropdown" + toggle-class="btn-icon js-merge-moment" + > + <template #button-content> + <gl-icon name="chevron-down" class="mr-0" /> + <span class="sr-only">{{ __('Select merge moment') }}</span> </template> - </commit-edit> - <commit-edit - v-if="shouldShowMergeEdit" - :value="commitMessage" - :label="__('Merge commit message')" - input-id="merge-message-edit" - class="gl-m-0! gl-p-0!" - @input="setCommitMessage" + <gl-dropdown-item + icon-name="warning" + button-class="accept-merge-request js-merge-immediately-button" + data-qa-selector="merge_immediately_menu_item" + @click="handleMergeImmediatelyButtonClick" + > + {{ __('Merge immediately') }} + </gl-dropdown-item> + <merge-immediately-confirmation-dialog + ref="confirmationDialog" + :docs-url="mr.mergeImmediatelyDocsPath" + @mergeImmediately="onMergeImmediatelyConfirmation" + /> + </gl-dropdown> + <merge-train-failed-pipeline-confirmation-dialog + :visible="isPipelineFailedModalVisibleMergeTrain" + @startMergeTrain="onStartMergeTrainConfirmation" + @cancel="isPipelineFailedModalVisibleMergeTrain = false" /> - <li class="gl-m-0! gl-p-0!"> - <p class="form-text text-muted"> - <gl-sprintf :message="commitTemplateHintText"> - <template #link="{ content }"> - <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </li> - </ul> - </div> + <merge-failed-pipeline-confirmation-dialog + :visible="isPipelineFailedModalVisibleNormalMerge" + @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation" + @cancel="isPipelineFailedModalVisibleNormalMerge = false" + /> + </gl-button-group> + <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> + </template> <div - v-if="!shouldShowMergeControls" + v-else class="gl-w-full gl-order-n1 mr-widget-merge-details" data-qa-selector="merged_status_content" > @@ -661,13 +686,11 @@ export default { {{ __('Merge details') }} </p> <ul class="gl-pl-4 gl-mb-0 gl-ml-3 gl-text-gray-600"> - <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal"> - <gl-sprintf - :message="s__('mrWidget|The source branch is %{link} the target branch')" - > + <li v-if="sourceHasDivergedFromTarget" class="gl-line-height-normal"> + <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> <template #link> <gl-link :href="mr.targetBranchPath">{{ - n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount) + $options.i18n.divergedCommits(mr.divergedCommitsCount) }}</gl-link> </template> </gl-sprintf> @@ -696,29 +719,6 @@ export default { </li> </ul> </div> - <div - v-else - :class="{ 'gl-mb-5': shouldShowMergeControls }" - class="gl-w-full gl-order-n1 gl-text-gray-500" - > - <added-commit-message - :is-squash-enabled="squashBeforeMerge" - :is-fast-forward-enabled="!shouldShowMergeEdit" - :commits-count="commitsCount" - :target-branch="stateData.targetBranch" - /> - <template v-if="mr.relatedLinks"> - · - <related-links - :state="mr.state" - :related-links="mr.relatedLinks" - :show-assign-to-me="false" - :diverged-commits-count="mr.divergedCommitsCount" - :target-branch-path="mr.targetBranchPath" - class="mr-ready-merge-related-links gl-display-inline" - /> - </template> - </div> </div> </div> </div> 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 0458e9dfaf5..dee27a5d5b5 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 @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; import $ from 'jquery'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import MergeRequest from '~/merge_request'; @@ -77,7 +77,7 @@ export default { }, ) { if (errors?.length) { - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); @@ -130,7 +130,7 @@ export default { }, ) .catch(() => - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }), ) @@ -152,7 +152,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }); }); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue deleted file mode 100644 index 18fdb29ba54..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue +++ /dev/null @@ -1,137 +0,0 @@ -<script> -import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import Poll from '~/lib/utils/poll'; -import { n__ } from '~/locale'; -import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; -import TerraformPlan from './terraform_plan.vue'; - -export default { - name: 'MRWidgetTerraformContainer', - components: { - GlSkeletonLoader, - GlSprintf, - MrWidgetExpanableSection, - TerraformPlan, - }, - props: { - endpoint: { - type: String, - required: true, - }, - }, - data() { - return { - loading: true, - plansObject: {}, - poll: null, - }; - }, - computed: { - inValidPlanCountText() { - if (this.numberOfInvalidPlans === 0) { - return null; - } - - return n__( - 'Terraform|%{number} Terraform report failed to generate', - 'Terraform|%{number} Terraform reports failed to generate', - this.numberOfInvalidPlans, - ); - }, - numberOfInvalidPlans() { - return Object.values(this.plansObject).filter((plan) => plan.tf_report_error).length; - }, - numberOfPlans() { - return Object.keys(this.plansObject).length; - }, - numberOfValidPlans() { - return this.numberOfPlans - this.numberOfInvalidPlans; - }, - validPlanCountText() { - if (this.numberOfValidPlans === 0) { - return null; - } - - return n__( - 'Terraform|%{number} Terraform report was generated in your pipelines', - 'Terraform|%{number} Terraform reports were generated in your pipelines', - this.numberOfValidPlans, - ); - }, - }, - created() { - this.fetchPlans(); - }, - beforeDestroy() { - this.poll.stop(); - }, - methods: { - fetchPlans() { - this.loading = true; - - this.poll = new Poll({ - resource: { - fetchPlans: () => axios.get(this.endpoint), - }, - data: this.endpoint, - method: 'fetchPlans', - successCallback: ({ data }) => { - this.plansObject = data; - - if (this.numberOfPlans > 0) { - this.loading = false; - this.poll.stop(); - } - }, - errorCallback: () => { - this.plansObject = { bad_plan: { tf_report_error: 'api_error' } }; - this.loading = false; - this.poll.stop(); - }, - }); - - this.poll.makeRequest(); - }, - }, -}; -</script> - -<template> - <section class="mr-widget-section"> - <div v-if="loading" class="mr-widget-body"> - <gl-skeleton-loader /> - </div> - - <mr-widget-expanable-section v-else> - <template #header> - <div - data-testid="terraform-header-text" - class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column" - > - <p v-if="validPlanCountText" class="gl-m-0"> - <gl-sprintf :message="validPlanCountText"> - <template #number> - <strong>{{ numberOfValidPlans }}</strong> - </template> - </gl-sprintf> - </p> - - <p v-if="inValidPlanCountText" class="gl-m-0"> - <gl-sprintf :message="inValidPlanCountText"> - <template #number> - <strong>{{ numberOfInvalidPlans }}</strong> - </template> - </gl-sprintf> - </p> - </div> - </template> - - <template #content> - <div class="mr-widget-body gl-pb-1"> - <terraform-plan v-for="(plan, key) in plansObject" :key="key" :plan="plan" /> - </div> - </template> - </mr-widget-expanable-section> - </section> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue deleted file mode 100644 index 1e5f7361966..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue +++ /dev/null @@ -1,119 +0,0 @@ -<script> -import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - name: 'TerraformPlan', - components: { - GlIcon, - GlLink, - GlSprintf, - }, - props: { - plan: { - required: true, - type: Object, - }, - }, - i18n: { - changes: s__( - 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', - ), - generationErrored: s__('Terraform|Generating the report caused an error.'), - namedReportFailed: s__('Terraform|The job %{name} failed to generate a report.'), - namedReportGenerated: s__('Terraform|The job %{name} generated a report.'), - reportFailed: s__('Terraform|A report failed to generate.'), - reportGenerated: s__('Terraform|A report was generated in your pipelines.'), - }, - computed: { - addNum() { - return Number(this.plan.create); - }, - changeNum() { - return Number(this.plan.update); - }, - deleteNum() { - return Number(this.plan.delete); - }, - iconType() { - return this.validPlanValues ? 'doc-changes' : 'warning'; - }, - reportChangeText() { - if (this.validPlanValues) { - return this.$options.i18n.changes; - } - - return this.$options.i18n.generationErrored; - }, - reportHeaderText() { - if (this.validPlanValues) { - return this.plan.job_name - ? this.$options.i18n.namedReportGenerated - : this.$options.i18n.reportGenerated; - } - - return this.plan.job_name - ? this.$options.i18n.namedReportFailed - : this.$options.i18n.reportFailed; - }, - validPlanValues() { - return this.addNum + this.changeNum + this.deleteNum >= 0; - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-pb-3"> - <span - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-px-2" - > - <gl-icon :name="iconType" :size="16" data-testid="change-type-icon" /> - </span> - - <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column flex-md-row gl-pl-3"> - <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-pr-3"> - <p class="gl-mb-3 gl-line-height-normal"> - <gl-sprintf :message="reportHeaderText"> - <template #name> - <strong>{{ plan.job_name }}</strong> - </template> - </gl-sprintf> - </p> - - <p class="gl-mb-3 gl-line-height-normal"> - <gl-sprintf :message="reportChangeText"> - <template #addNum> - <strong>{{ addNum }}</strong> - </template> - - <template #changeNum> - <strong>{{ changeNum }}</strong> - </template> - - <template #deleteNum> - <strong>{{ deleteNum }}</strong> - </template> - </gl-sprintf> - </p> - </div> - - <div> - <gl-link - v-if="plan.job_path" - :href="plan.job_path" - target="_blank" - data-testid="terraform-report-link" - data-track-action="click_terraform_mr_plan_button" - data-track-label="mr_widget_terraform_mr_plan_button" - data-track-property="terraform_mr_plan_button" - class="btn btn-sm" - rel="noopener" - > - {{ __('View full log') }} - <gl-icon name="external-link" /> - </gl-link> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue new file mode 100644 index 00000000000..d1ade2886f4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -0,0 +1,98 @@ +<script> +import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import Actions from '../action_buttons.vue'; +import { generateText } from '../extensions/utils'; +import ContentRow from './widget_content_row.vue'; + +export default { + name: 'DynamicContent', + components: { + GlBadge, + GlLink, + Actions, + ContentRow, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + data: { + type: Object, + required: true, + }, + widgetName: { + type: String, + required: true, + }, + level: { + type: Number, + required: false, + default: 2, + }, + }, + computed: { + statusIcon() { + return this.data.icon?.name || undefined; + }, + generatedText() { + return generateText(this.data.text); + }, + generatedSubtext() { + return generateText(this.data.subtext); + }, + generatedSupportingText() { + return generateText(this.data.supportingText); + }, + }, + methods: { + onClickedAction(action) { + this.$emit('clickedAction', action); + }, + }, +}; +</script> + +<template> + <content-row + :level="level" + :status-icon-name="statusIcon" + :widget-name="widgetName" + :header="data.header" + > + <template #body> + <div class="gl-display-flex gl-flex-direction-column"> + <div> + <p v-safe-html="generatedText" class="gl-mb-0"></p> + <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link> + <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> + <actions + :widget="widgetName" + :tertiary-buttons="data.actions" + class="gl-ml-auto gl-pl-3" + @clickedAction="onClickedAction" + /> + <p v-if="data.subtext" v-safe-html="generatedSubtext" class="gl-m-0 gl-font-sm"></p> + </div> + <ul + v-if="data.children && data.children.length > 0 && level === 2" + class="gl-m-0 gl-p-0 gl-list-style-none" + > + <li> + <dynamic-content + v-for="(childData, index) in data.children" + :key="childData.id || index" + :data="childData" + :widget-name="widgetName" + :level="3" + data-qa-selector="child_content" + @clickedAction="onClickedAction" + /> + </li> + </ul> + </div> + </template> + </content-row> +</template> 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 new file mode 100644 index 00000000000..ff17de343d6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue @@ -0,0 +1,67 @@ +<script> +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants'; + +export default { + components: { + GlLoadingIcon, + GlIcon, + }, + props: { + level: { + type: Number, + required: false, + default: 1, + }, + name: { + type: String, + required: false, + default: '', + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + iconName: { + type: String, + required: false, + default: null, + }, + }, + computed: { + iconAriaLabel() { + return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`; + }, + iconSize() { + return this.level === 1 ? 16 : 12; + }, + }, + EXTENSION_ICON_NAMES, + EXTENSION_ICON_CLASS, +}; +</script> + +<template> + <div :class="[$options.EXTENSION_ICON_CLASS[iconName]]" class="gl-mr-3"> + <gl-loading-icon v-if="isLoading" size="md" inline /> + <div + v-else + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-rounded-full gl-bg-gray-10" + :class="{ + 'gl-p-2': level === 1, + }" + > + <div class="gl-rounded-full gl-bg-white"> + <gl-icon + :name="$options.EXTENSION_ICON_NAMES[iconName]" + :size="iconSize" + :aria-label="iconAriaLabel" + :data-qa-selector="`status_${iconName}_icon`" + class="gl-display-block" + /> + </div> + </div> + </div> +</template> 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 c9fc2dde0bd..94359d7d6ac 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 @@ -4,10 +4,11 @@ import * as Sentry from '@sentry/browser'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { sprintf, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; -import StatusIcon from '../extensions/status_icon.vue'; import ActionButtons from '../action_buttons.vue'; import { EXTENSION_ICONS } from '../../constants'; -import ContentSection from './widget_content_section.vue'; +import ContentRow from './widget_content_row.vue'; +import DynamicContent from './dynamic_content.vue'; +import StatusIcon from './status_icon.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; const FETCH_TYPE_EXPANDED = 'expanded'; @@ -18,7 +19,8 @@ export default { StatusIcon, GlButton, GlLoadingIcon, - ContentSection, + ContentRow, + DynamicContent, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,7 +61,7 @@ export default { }, // If the content slot is not used, this value will be used as a fallback. content: { - type: Object, + type: Array, required: false, default: undefined, }, @@ -187,7 +189,7 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="media gl-p-5"> + <div class="gl-p-5 gl-align-items-center gl-display-flex"> <status-icon :level="1" :name="widgetName" @@ -227,23 +229,34 @@ export default { </div> <div v-if="!isCollapsed || contentError" - class="mr-widget-grouped-section gl-relative" + class="gl-relative gl-bg-gray-10" data-testid="widget-extension-collapsed-section" > <div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center"> - <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} + <gl-loading-icon size="sm" inline /> {{ loadingText }} + </div> + <div v-else class="gl-px-5 gl-display-flex"> + <content-row + v-if="contentError" + :level="2" + :status-icon-name="$options.failedStatusIcon" + :widget-name="widgetName" + > + <template #body> + {{ contentError }} + </template> + </content-row> + <div v-else class="gl-w-full"> + <slot name="content"> + <dynamic-content + v-for="(data, index) in content" + :key="data.id || index" + :data="data" + :widget-name="widgetName" + /> + </slot> + </div> </div> - <content-section - v-else-if="contentError" - class="report-block-container" - :status-icon-name="$options.failedStatusIcon" - :widget-name="widgetName" - > - {{ contentError }} - </content-section> - <slot v-else name="content"> - {{ content }} - </slot> </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue new file mode 100644 index 00000000000..ee81f0950a8 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue @@ -0,0 +1,68 @@ +<script> +import { GlSafeHtmlDirective } from '@gitlab/ui'; +import { EXTENSION_ICONS } from '../../constants'; +import { generateText } from '../extensions/utils'; +import StatusIcon from './status_icon.vue'; + +export default { + components: { + StatusIcon, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + level: { + type: Number, + required: true, + validator: (value) => value === 2 || value === 3, + }, + statusIconName: { + type: String, + default: '', + required: false, + validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value), + }, + widgetName: { + type: String, + required: true, + }, + header: { + type: [String, Array], + default: '', + required: false, + }, + }, + computed: { + generatedHeader() { + return generateText(Array.isArray(this.header) ? this.header[0] : this.header); + }, + generatedSubheader() { + return Array.isArray(this.header) && this.header[1] ? generateText(this.header[1]) : ''; + }, + }, +}; +</script> +<template> + <div + class="gl-w-full gl-display-flex mr-widget-content-row gl-align-items-baseline" + :class="{ 'gl-border-t gl-py-3 gl-pl-7': level === 2 }" + > + <status-icon v-if="statusIconName" :level="2" :name="widgetName" :icon-name="statusIconName" /> + <div> + <slot name="header"> + <div v-if="header" class="gl-mb-2"> + <strong v-safe-html="generatedHeader" class="gl-display-block"></strong + ><span + v-if="generatedSubheader" + v-safe-html="generatedSubheader" + class="gl-display-block" + ></span> + </div> + </slot> + <div class="gl-display-flex gl-align-items-baseline gl-w-full"> + <slot name="body"></slot> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue deleted file mode 100644 index 61e3744b5dc..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { EXTENSION_ICONS } from '../../constants'; -import StatusIcon from '../extensions/status_icon.vue'; - -export default { - components: { - StatusIcon, - }, - props: { - statusIconName: { - type: String, - default: '', - required: false, - validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value), - }, - widgetName: { - type: String, - required: true, - }, - }, -}; -</script> -<template> - <div class="gl-px-7"> - <div class="gl-pl-4 gl-display-flex"> - <status-icon - v-if="statusIconName" - :level="2" - :name="widgetName" - :icon-name="statusIconName" - /> - <slot name="default"></slot> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index be4e34ffff0..c6baf3b46ff 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -180,3 +180,16 @@ export const INVALID_RULES_DOCS_PATH = helpPagePath( anchor: 'invalid-rules', }, ); + +export const DETAILED_MERGE_STATUS = { + MERGEABLE: 'MERGEABLE', + CHECKING: 'CHECKING', + NOT_OPEN: 'NOT_OPEN', + DISCUSSIONS_NOT_RESOLVED: 'DISCUSSIONS_NOT_RESOLVED', + NOT_APPROVED: 'NOT_APPROVED', + DRAFT_STATUS: 'DRAFT_STATUS', + BLOCKED_STATUS: 'BLOCKED_STATUS', + POLICIES_DENIED: 'POLICIES_DENIED', + CI_MUST_PASS: 'CI_MUST_PASS', + CI_STILL_RUNNING: 'CI_STILL_RUNNING', +}; 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 c8a2a8d119b..a3f70b551bf 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 @@ -6,7 +6,7 @@ import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/ap import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; 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 createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; @@ -86,9 +86,6 @@ export default { import('../reports/codequality_report/grouped_codequality_reports_app.vue'), GroupedTestReportsApp: () => import('../reports/grouped_test_report/grouped_test_reports_app.vue'), - TerraformPlan: () => import('./components/terraform/mr_widget_terraform_container.vue'), - GroupedAccessibilityReportsApp: () => - import('../reports/accessibility_report/grouped_accessibility_reports_app.vue'), MrWidgetApprovals, SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'), MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'), @@ -218,12 +215,6 @@ export default { hasAlerts() { return this.hasMergeError || this.showMergePipelineForkWarning; }, - shouldShowExtension() { - return ( - window.gon?.features?.refactorMrWidgetsExtensions || - window.gon?.features?.refactorMrWidgetsExtensionsUser - ); - }, shouldShowSecurityExtension() { return window.gon?.features?.refactorSecurityExtension; }, @@ -276,7 +267,7 @@ export default { this.initWidget(data); }) .catch(() => - createFlash({ + createAlert({ message: __('Unable to load the merge request widget. Try reloading the page.'), }), ); @@ -368,7 +359,7 @@ export default { } }) .catch(() => - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }), ); @@ -427,7 +418,7 @@ export default { .catch(() => this.throwDeploymentsError()); }, throwDeploymentsError() { - createFlash({ + createAlert({ message: __( 'Something went wrong while fetching the environments for this merge request. Please try again.', ), @@ -447,7 +438,7 @@ export default { } }) .catch(() => - createFlash({ + createAlert({ message: __('Something went wrong. Please try again.'), }), ); @@ -506,17 +497,24 @@ export default { eventHub.$on('DisablePolling', () => { this.stopPolling(); }); + + eventHub.$on('FetchDeployments', () => { + this.fetchPreMergeDeployments(); + if (this.shouldRenderMergedPipeline) { + this.fetchPostMergeDeployments(); + } + }); }, dismissSuggestPipelines() { this.mr.isDismissedSuggestPipeline = true; }, registerTerraformPlans() { - if (this.shouldRenderTerraformPlans && this.shouldShowExtension) { + if (this.shouldRenderTerraformPlans) { registerExtension(terraformExtension); } }, registerAccessibilityExtension() { - if (this.shouldShowAccessibilityReport && this.shouldShowExtension) { + if (this.shouldShowAccessibilityReport) { registerExtension(accessibilityExtension); } }, @@ -620,16 +618,6 @@ export default { :pipeline-path="mr.pipeline.path" /> - <terraform-plan - v-if="mr.terraformReportsPath && !shouldShowExtension" - :endpoint="mr.terraformReportsPath" - /> - - <grouped-accessibility-reports-app - v-if="shouldShowAccessibilityReport && !shouldShowExtension" - :endpoint="mr.accessibilityReportPath" - /> - <div class="mr-widget-section" data-qa-selector="mr_widget_content"> <component :is="componentName" :mr="mr" :service="service" /> <ready-to-merge diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index eac72ffb2f2..516ba104d7b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -10,6 +10,7 @@ query getState($projectPath: ID!, $iid: String!) { availableAutoMergeStrategies commitCount conflicts + detailedMergeStatus diffHeadSha mergeError mergeStatus diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 7a458f9ce7e..81cb20475cc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -1,3 +1,4 @@ +import { DETAILED_MERGE_STATUS } from '../constants'; import { stateKey } from './state_maps'; export default function deviseState() { @@ -7,7 +8,7 @@ export default function deviseState() { return stateKey.archived; } else if (this.branchMissing) { return stateKey.missingBranch; - } else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') { + } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CHECKING) { return stateKey.checking; } else if (this.hasConflicts) { return stateKey.conflicts; @@ -15,19 +16,20 @@ export default function deviseState() { return stateKey.rebase; } else if (this.hasMergeChecksFailed && !this.autoMergeEnabled) { return stateKey.mergeChecksFailed; - } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { + } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CI_MUST_PASS) { return stateKey.pipelineFailed; - } else if (this.draft) { + } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DRAFT_STATUS) { return stateKey.draft; - } else if (this.hasMergeableDiscussionsState && !this.autoMergeEnabled) { + } else if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DISCUSSIONS_NOT_RESOLVED) { return stateKey.unresolvedDiscussions; - } else if (this.isPipelineBlocked) { - return stateKey.pipelineBlocked; } else if (this.canMerge && this.isSHAMismatch) { return stateKey.shaMismatch; } else if (this.autoMergeEnabled && !this.mergeError) { return stateKey.autoMergeEnabled; - } else if (this.canBeMerged) { + } else if ( + this.detailedMergeStatus === DETAILED_MERGE_STATUS.MERGEABLE || + this.detailedMergeStatus === DETAILED_MERGE_STATUS.CI_STILL_RUNNING + ) { return stateKey.readyToMerge; } return null; 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 e6ff586892f..731d3886f61 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 @@ -219,6 +219,7 @@ export default class MergeRequestStore { this.shouldBeRebased = mergeRequest.shouldBeRebased; this.draft = mergeRequest.draft; this.mergeRequestState = mergeRequest.state; + this.detailedMergeStatus = mergeRequest.detailedMergeStatus; this.setState(); } @@ -291,6 +292,7 @@ export default class MergeRequestStore { this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id; this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline; this.securityReportsDocsPath = data.security_reports_docs_path; + this.securityConfigurationPath = data.security_configuration_path; // code quality const blobPath = data.blob_path || {}; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 84bd6bca601..c93057c491c 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,6 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import CiIcon from './ci_icon.vue'; /** * Renders CI Badge link with CI icon and status text based on @@ -27,6 +26,7 @@ import CiIcon from './ci_icon.vue'; export default { components: { + GlLink, CiIcon, }, directives: { @@ -61,29 +61,21 @@ export default { return className ? `ci-status ci-${className}` : 'ci-status'; }, }, - methods: { - navigateToPipeline() { - visitUrl(this.detailsPath); - - // event used for tracking - this.$emit('ciStatusBadgeClick'); - }, - }, }; </script> <template> - <a + <gl-link v-gl-tooltip :class="cssClass" - class="gl-cursor-pointer" :title="title" data-qa-selector="status_badge_link" - @click="navigateToPipeline" + :href="detailsPath" + @click="$emit('ciStatusBadgeClick')" > <ci-icon :status="status" :css-classes="iconClasses" /> <template v-if="showText"> {{ status.text }} </template> - </a> + </gl-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue index a88a4ca5cb8..75386a3cd01 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue @@ -1,6 +1,6 @@ <script> import { isString } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { DEFAULT_COLOR, COLOR_WIDGET_COLOR, DROPDOWN_VARIANT, ISSUABLE_COLORS } from './constants'; @@ -97,7 +97,7 @@ export default { return DEFAULT_COLOR; }, error() { - createFlash({ + createAlert({ message: this.$options.i18n.fetchingError, captureError: true, }); @@ -161,7 +161,7 @@ export default { }); }) .catch((error) => - createFlash({ + createAlert({ message: this.$options.i18n.updatingError, captureError: true, error, diff --git a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue index 298c7bc50cc..31c98d1e3a7 100644 --- a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue +++ b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue @@ -33,7 +33,7 @@ export default { :title="confidentialTooltip" icon="eye-slash" variant="warning" - class="gl-display-inline gl-mr-2" + class="gl-display-inline gl-mr-3" >{{ __('Confidential') }}</gl-badge > </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index ffe09634a3b..4873996d357 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -56,3 +56,9 @@ export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization'); export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_TYPE = __('Type'); + +// As health status gets reused between issue lists and boards +// this is in the shared constants. Until we have not decoupled the EE filtered search bar +// from the CE component, we need to keep this in the CE code. +// https://gitlab.com/gitlab-org/gitlab/-/issues/377838 +export const TOKEN_TYPE_HEALTH = 'health_status'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index e311df6e66f..8821084ef35 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -12,7 +12,7 @@ import { import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { SortDirection } from './constants'; @@ -197,7 +197,7 @@ export default { .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; - createFlash({ + createAlert({ message: __('An error occurred while parsing recent searches'), }); @@ -346,6 +346,11 @@ export default { :suggestions-list-class="suggestionsListClass" :search-button-attributes="searchButtonAttributes" :search-input-attributes="searchInputAttributes" + :recent-searches-header="__('Recent searches')" + :clear-button-title="__('Clear')" + :close-button-title="__('Close')" + :clear-recent-searches-text="__('Clear recent searches')" + :no-recent-searches-text="__(`You don't have any recent searches`)" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear="onClear" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js index 7c4e372dda1..8a6053b7001 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -24,7 +24,7 @@ export function fetchBranches({ commit, state }, search = '') { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_BRANCHES_ERROR, status); - createFlash({ + createAlert({ message: __('Failed to load branches. Please try again.'), }); }); @@ -43,7 +43,7 @@ export const fetchMilestones = ({ commit, state }, searchTitle = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_MILESTONES_ERROR, status); - createFlash({ + createAlert({ message: __('Failed to load milestones. Please try again.'), }); }); @@ -61,7 +61,7 @@ export const fetchLabels = ({ commit, state }, search = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_LABELS_ERROR, status); - createFlash({ + createAlert({ message: __('Failed to load labels. Please try again.'), }); }); @@ -86,7 +86,7 @@ function fetchUser(options = {}) { .catch(({ response }) => { const { status } = response; commit(`RECEIVE_${action}_ERROR`, status); - createFlash({ + createAlert({ message: errorMessage, }); }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index 848c49c48c7..7c184a3c391 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -1,7 +1,7 @@ <script> import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { compact } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { DEFAULT_NONE_ANY } from '../constants'; @@ -65,7 +65,7 @@ export default { this.authors = Array.isArray(res) ? compact(res) : compact(res.data); }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching users.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue index aa5161ca93c..741395b3193 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -46,7 +46,7 @@ export default { this.branches = data; }) .catch(() => { - createFlash({ message: __('There was a problem fetching branches.') }); + createAlert({ message: __('There was a problem fetching branches.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue index adfe0559b62..d34cfb922a9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql'; @@ -81,7 +81,7 @@ export default { : data[this.namespace]?.contacts.nodes; }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching CRM contacts.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue index e6ab944449e..c7c9350ee93 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql'; @@ -78,7 +78,7 @@ export default { : data[this.namespace]?.organizations.nodes; }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching CRM organizations.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 210d814d22a..929823f7308 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_NONE_ANY } from '../constants'; @@ -48,7 +48,7 @@ export default { this.emojis = Array.isArray(response) ? response : response.data; }) .catch(() => { - createFlash({ message: __('There was a problem fetching emojis.') }); + createAlert({ message: __('There was a problem fetching emojis.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 178c57a5666..bce0c11aafd 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -1,7 +1,7 @@ <script> import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -81,7 +81,7 @@ export default { this.labels = Array.isArray(res) ? res : res.data; }) .catch(() => - createFlash({ + createAlert({ message: __('There was a problem fetching labels.'), }), ) diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 69265d0fdc9..b9ee4d51db1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -65,7 +65,7 @@ export default { } }) .catch(() => { - createFlash({ message: __('There was a problem fetching milestones.') }); + createAlert({ message: __('There was a problem fetching milestones.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue index 9e68c92af5d..59701b4959e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_NONE_ANY } from '../constants'; @@ -47,7 +47,7 @@ export default { this.releases = response; }) .catch(() => { - createFlash({ message: __('There was a problem fetching releases.') }); + createAlert({ message: __('There was a problem fetching releases.') }); }) .finally(() => { this.loading = false; diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue index 72148a0aa7c..c2be5e4f7a1 100644 --- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue +++ b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue @@ -1,8 +1,10 @@ <script> import { GlBadge } from '@gitlab/ui'; import { s__ } from '~/locale'; +import Tracking from '~/tracking'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; const STATUS_TYPES = { SUCCESS: 'success', @@ -10,11 +12,14 @@ const STATUS_TYPES = { DANGER: 'danger', }; +const UPGRADE_DOCS_URL = helpPagePath('update/index'); + export default { name: 'GitlabVersionCheck', components: { GlBadge, }, + mixins: [Tracking.mixin()], props: { size: { type: String, @@ -50,6 +55,10 @@ export default { .then((res) => { if (res.data) { this.status = res.data.severity; + + this.track('rendered_version_badge', { + label: this.title, + }); } }) .catch(() => { @@ -57,12 +66,24 @@ export default { this.status = null; }); }, + onClick() { + this.track('click_version_badge', { label: this.title }); + }, }, + UPGRADE_DOCS_URL, }; </script> <template> - <gl-badge v-if="status" class="version-check-badge" :variant="status" :size="size">{{ - title - }}</gl-badge> + <!-- TODO: remove the span element once bootstrap-vue is updated to version 2.21.1 --> + <!-- TODO: https://github.com/bootstrap-vue/bootstrap-vue/issues/6219 --> + <span v-if="status" data-testid="badge-click-wrapper" @click="onClick"> + <gl-badge + :href="$options.UPGRADE_DOCS_URL" + class="version-check-badge" + :variant="status" + :size="size" + >{{ title }}</gl-badge + > + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/utils.js b/app/assets/javascripts/vue_shared/components/group_select/utils.js new file mode 100644 index 00000000000..0a4622269f4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/group_select/utils.js @@ -0,0 +1,15 @@ +import Api from '~/api'; + +export const groupsPath = (groupsFilter, parentGroupID) => { + if (groupsFilter !== undefined && parentGroupID === undefined) { + throw new Error('Cannot use groupsFilter without a parentGroupID'); + } + switch (groupsFilter) { + case 'descendant_groups': + return Api.descendantGroupsPath.replace(':id', parentGroupID); + case 'subgroups': + return Api.subgroupsPath.replace(':id', parentGroupID); + default: + return Api.groupsPath; + } +}; diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js new file mode 100644 index 00000000000..d80c1ff8b0c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js @@ -0,0 +1,12 @@ +import { issuableTypes } from '~/boards/constants'; +import blockingIssuesQuery from './graphql/blocking_issues.query.graphql'; +import blockingEpicsQuery from './graphql/blocking_epics.query.graphql'; + +export const blockingIssuablesQueries = { + [issuableTypes.issue]: { + query: blockingIssuesQuery, + }, + [issuableTypes.epic]: { + query: blockingEpicsQuery, + }, +}; diff --git a/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_epics.query.graphql index 071a6d7410f..4b9a9243052 100644 --- a/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_epics.query.graphql @@ -1,4 +1,4 @@ -query BoardBlockingEpics($fullPath: ID!, $iid: ID) { +query BlockingEpics($fullPath: ID!, $iid: ID) { group(fullPath: $fullPath) { id issuable: epic(iid: $iid) { diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_issues.query.graphql index 01fab571733..279c2202740 100644 --- a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/graphql/blocking_issues.query.graphql @@ -1,4 +1,4 @@ -query BoardBlockingIssues($id: IssueID!) { +query BlockingIssues($id: IssueID!) { issuable: issue(id: $id) { id blockingIssuables: blockedByIssues { diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue index 3f8a596abd8..253aca8837d 100644 --- a/app/assets/javascripts/boards/components/board_blocked_icon.vue +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue @@ -1,10 +1,11 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; -import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; +import { issuableTypes } from '~/boards/constants'; import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; +import { blockingIssuablesQueries } from './constants'; export default { i18n: { @@ -28,7 +29,6 @@ export default { GlLink, GlLoadingIcon, }, - blockingIssuablesQueries, props: { item: { type: Object, @@ -169,8 +169,8 @@ export default { :id="glIconId" ref="icon" :name="blockIcon" - class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500" - data-testid="issue-blocked-icon" + class="issuable-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500" + data-testid="issuable-blocked-icon" @mouseenter="handleMouseEnter" /> <gl-popover :target="glIconId" placement="top"> @@ -182,12 +182,19 @@ export default { <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p> </template> <template v-else> - <ul class="gl-list-style-none gl-p-0"> - <li v-for="issuable in displayedIssuables" :key="issuable.id"> + <ul class="gl-list-style-none gl-p-0 gl-mb-0"> + <li v-for="(issuable, index) in displayedIssuables" :key="issuable.id"> <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{ issuable.reference }}</gl-link> - <p class="gl-mb-3 gl-display-block!" data-testid="issuable-title"> + <p + class="gl-display-block!" + :class="{ + 'gl-mb-3': index < displayedIssuables.length - 1, + 'gl-mb-0': index === displayedIssuables.length - 1, + }" + data-testid="issuable-title" + > {{ issuable.title }} </p> </li> diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index 926034efd10..caec49c557a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -51,6 +51,7 @@ export default { <gl-dropdown :text="dropdownText" :disabled="disabled" + size="small" boundary="window" right lazy diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 32b3a0e22c2..657e4498b53 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -3,7 +3,7 @@ import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { debounce, unescape } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import GLForm from '~/gl_form'; import axios from '~/lib/utils/axios_utils'; import { stripHtml } from '~/lib/utils/text_utility'; @@ -272,7 +272,7 @@ export default { this.fetchMarkdown() .then((data) => this.renderMarkdown(data)) .catch(() => - createFlash({ + createAlert({ message: __('Error loading markdown preview'), }), ); @@ -315,7 +315,7 @@ export default { this.$nextTick() .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => - createFlash({ + createAlert({ message: __('Error rendering Markdown preview'), }), ); diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 458dfe0ed23..89fffdedbfd 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -7,6 +7,8 @@ import { ITALIC_TEXT, STRIKETHROUGH_TEXT, LINK_TEXT, + INDENT_LINE, + OUTDENT_LINE, } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; @@ -68,12 +70,15 @@ export default { }, computed: { mdTable() { + const header = s__('MarkdownEditor|header'); + const divider = '-'.repeat(header.length); + const cell = ' '.repeat(header.length); + return [ - // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 - '| header | header |', // eslint-disable-line @gitlab/require-i18n-strings - '| ------ | ------ |', - '| cell | cell |', // eslint-disable-line @gitlab/require-i18n-strings - '| cell | cell |', // eslint-disable-line @gitlab/require-i18n-strings + `| ${header} | ${header} |`, + `| ${divider} | ${divider} |`, + `| ${cell} | ${cell} |`, + `| ${cell} | ${cell} |`, ].join('\n'); }, mdSuggestion() { @@ -82,7 +87,8 @@ export default { ); }, mdCollapsibleSection() { - return ['<details><summary>Click to expand</summary>', `{text}`, '</details>'].join('\n'); + const expandText = s__('MarkdownEditor|Click to expand'); + return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n'); }, isMac() { // Accessing properties using ?. to allow tests to use @@ -170,6 +176,8 @@ export default { italic: keysFor(ITALIC_TEXT), strikethrough: keysFor(STRIKETHROUGH_TEXT), link: keysFor(LINK_TEXT), + indent: keysFor(INDENT_LINE), + outdent: keysFor(OUTDENT_LINE), }, i18n: { writeTabTitle: __('Write'), @@ -235,6 +243,7 @@ export default { variant="confirm" category="primary" size="small" + data-qa-selector="dismiss_suggestion_popover_button" @click="handleSuggestDismissed" > {{ __('Got it') }} @@ -318,6 +327,32 @@ export default { icon="list-task" /> <toolbar-button + v-if="!restrictedToolBarItems.includes('indent')" + class="gl-display-none" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), { + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.indent" + command="indentLines" + icon="list-indent" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('outdent')" + class="gl-display-none" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), { + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.outdent" + command="outdentLines" + icon="list-outdent" + /> + <toolbar-button v-if="!restrictedToolBarItems.includes('collapsible-section')" :tag="mdCollapsibleSection" :prepend="true" diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue new file mode 100644 index 00000000000..b38772d5aa5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -0,0 +1,216 @@ +<script> +import { GlSegmentedControl } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import axios from '~/lib/utils/axios_utils'; +import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants'; +import MarkdownField from './field.vue'; + +export default { + components: { + MarkdownField, + LocalStorageSync, + GlSegmentedControl, + ContentEditor: () => + import( + /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' + ), + }, + props: { + value: { + type: String, + required: true, + }, + renderMarkdownPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + quickActionsDocsPath: { + type: String, + required: false, + default: '', + }, + uploadsPath: { + type: String, + required: false, + default: () => window.uploads_path, + }, + enableContentEditor: { + type: Boolean, + required: false, + default: true, + }, + formFieldId: { + type: String, + required: true, + }, + formFieldName: { + type: String, + required: true, + }, + enablePreview: { + type: Boolean, + required: false, + default: true, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + formFieldPlaceholder: { + type: String, + required: false, + default: '', + }, + formFieldAriaLabel: { + type: String, + required: false, + default: '', + }, + initOnAutofocus: { + type: Boolean, + required: false, + default: false, + }, + supportsQuickActions: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + editingMode: EDITING_MODE_MARKDOWN_FIELD, + switchEditingControlEnabled: true, + autofocus: this.initOnAutofocus, + }; + }, + computed: { + isContentEditorActive() { + return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR; + }, + contentEditorAutofocus() { + // Match textarea focus behavior + return this.autofocus ? 'end' : false; + }, + }, + mounted() { + this.autofocusTextarea(this.editingMode); + }, + methods: { + updateMarkdownFromContentEditor({ markdown }) { + this.$emit('input', markdown); + }, + updateMarkdownFromMarkdownField({ target }) { + this.$emit('input', target.value); + }, + enableSwitchEditingControl() { + this.switchEditingControlEnabled = true; + }, + disableSwitchEditingControl() { + this.switchEditingControlEnabled = false; + }, + renderMarkdown(markdown) { + return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body); + }, + onEditingModeChange(editingMode) { + this.notifyEditingModeChange(editingMode); + this.enableAutofocus(editingMode); + }, + onEditingModeRestored(editingMode) { + this.notifyEditingModeChange(editingMode); + }, + notifyEditingModeChange(editingMode) { + this.$emit(editingMode); + }, + enableAutofocus(editingMode) { + this.autofocus = true; + this.autofocusTextarea(editingMode); + }, + autofocusTextarea(editingMode) { + if (this.autofocus && editingMode === EDITING_MODE_MARKDOWN_FIELD) { + this.$refs.textarea.focus(); + } + }, + }, + switchEditingControlOptions: [ + { text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD }, + { text: __('Rich text'), value: EDITING_MODE_CONTENT_EDITOR }, + ], +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-justify-content-start gl-mb-3"> + <gl-segmented-control + v-model="editingMode" + data-testid="toggle-editing-mode-button" + data-qa-selector="editing_mode_button" + class="gl-display-flex" + :options="$options.switchEditingControlOptions" + :disabled="!enableContentEditor || !switchEditingControlEnabled" + @change="onEditingModeChange" + /> + </div> + <local-storage-sync + v-model="editingMode" + storage-key="gl-wiki-content-editor-enabled" + @input="onEditingModeRestored" + /> + <markdown-field + v-if="!isContentEditorActive" + :markdown-preview-path="renderMarkdownPath" + can-attach-file + :enable-autocomplete="enableAutocomplete" + :textarea-value="value" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :uploads-path="uploadsPath" + :enable-preview="enablePreview" + class="bordered-box" + > + <template #textarea> + <textarea + :id="formFieldId" + ref="textarea" + :value="value" + :name="formFieldName" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + :data-supports-quick-actions="supportsQuickActions" + data-qa-selector="markdown_editor_form_field" + :aria-label="formFieldAriaLabel" + :placeholder="formFieldPlaceholder" + @input="updateMarkdownFromMarkdownField" + @keydown="$emit('keydown', $event)" + > + </textarea> + </template> + </markdown-field> + <div v-else> + <content-editor + :render-markdown="renderMarkdown" + :uploads-path="uploadsPath" + :markdown="value" + :autofocus="contentEditorAutofocus" + @change="updateMarkdownFromContentEditor" + @loading="disableSwitchEditingControl" + @loadingSuccess="enableSwitchEditingControl" + @loadingError="enableSwitchEditingControl" + @keydown="$emit('keydown', $event)" + /> + <input + :id="formFieldId" + :value="value" + :name="formFieldName" + data-qa-selector="markdown_editor_form_field" + type="hidden" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 7646a8718d6..855c7a449c4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -139,7 +139,7 @@ export default { </script> <template> - <div class="md-suggestion-header border-bottom-0 gl-mt-3"> + <div class="md-suggestion-header border-bottom-0 gl-px-4 gl-py-3"> <div class="js-suggestion-diff-header gl-font-weight-bold"> {{ __('Suggested change') }} <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn"> @@ -162,6 +162,7 @@ export default { <gl-button class="btn-inverted js-remove-from-batch-btn btn-grouped" :disabled="isApplying" + size="small" @click="removeSuggestionFromBatch" > {{ __('Remove from batch') }} @@ -172,6 +173,7 @@ export default { class="btn-inverted js-add-to-batch-btn btn-grouped" data-qa-selector="add_suggestion_batch_button" :disabled="isDisableButton" + size="small" @click="addSuggestionToBatch" > {{ __('Add suggestion to batch') }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 9b81444fc04..30d72332c90 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,7 +1,7 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Vue from 'vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; @@ -91,7 +91,7 @@ export default { const suggestionElements = container.querySelectorAll('.js-render-suggestion'); if (this.lineType === 'old') { - createFlash({ + createAlert({ message: __('Unable to apply suggestions to a deleted line.'), parent: this.$el, }); diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 49217e38a1b..5ca21522d33 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -47,6 +47,11 @@ export default { required: false, default: 0, }, + command: { + type: String, + required: false, + default: '', + }, /** * A string (or an array of strings) of @@ -81,6 +86,7 @@ export default { :data-md-tag-content="tagContent" :data-md-prepend="prepend" :data-md-shortcuts="shortcutsString" + :data-md-command="command" :title="buttonTitle" :aria-label="buttonTitle" :icon="icon" diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js index 832fb891838..1c4e8d332a9 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -11,7 +11,7 @@ export const fetchImagesFactory = (service) => async ({ state, commit }) => { commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response); } catch (error) { commit(types.RECEIVE_METRIC_IMAGES_ERROR); - createFlash({ message: s__('MetricImages|There was an issue loading metric images.') }); + createAlert({ message: s__('MetricImages|There was an issue loading metric images.') }); } }; @@ -34,7 +34,7 @@ export const uploadImageFactory = (service) => async ( commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response); } catch (error) { commit(types.RECEIVE_METRIC_UPLOAD_ERROR); - createFlash({ message: s__('MetricImages|There was an issue uploading your image.') }); + createAlert({ message: s__('MetricImages|There was an issue uploading your image.') }); } }; @@ -57,7 +57,7 @@ export const updateImageFactory = (service) => async ( commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response); } catch (error) { commit(types.RECEIVE_METRIC_UPLOAD_ERROR); - createFlash({ message: s__('MetricImages|There was an issue updating your image.') }); + createAlert({ message: s__('MetricImages|There was an issue updating your image.') }); } }; @@ -68,7 +68,7 @@ export const deleteImageFactory = (service) => async ({ state, commit }, imageId await service.deleteMetricImage({ imageId, id: projectId, modelIid }); commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId); } catch (error) { - createFlash({ message: s__('MetricImages|There was an issue deleting the image.') }); + createAlert({ message: s__('MetricImages|There was an issue deleting the image.') }); } }; diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index d4f50e347cb..41c92fdba4f 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -61,6 +61,11 @@ export default { required: false, default: 'primary', }, + size: { + type: String, + required: false, + default: 'medium', + }, }, computed: { modalDomId() { @@ -103,6 +108,9 @@ export default { :title="title" :aria-label="title" :category="category" + :size="size" icon="copy-to-clipboard" - /> + > + <slot></slot> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue index e9f278a5db5..ba9edc7620a 100644 --- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue @@ -27,7 +27,7 @@ const filterByName = (data, searchTerm = '') => { }; export default { - name: 'NamespaceSelect', + name: 'NamespaceSelectDeprecated', components: { GlDropdown, GlDropdownDivider, @@ -78,7 +78,7 @@ export default { required: false, default: false, }, - isLoadingMoreGroups: { + isLoading: { type: Boolean, required: false, default: false, @@ -152,7 +152,12 @@ export default { }; </script> <template> - <gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list"> + <gl-dropdown + :text="selectedNamespaceText" + :block="fullWidth" + data-qa-selector="namespaces_list" + @show="$emit('show')" + > <template #header> <gl-search-box-by-type v-model.trim="searchTerm" @@ -201,8 +206,7 @@ export default { >{{ item.humanName }}</gl-dropdown-item > </div> - <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')"> - <gl-loading-icon v-if="isLoadingMoreGroups" class="gl-mb-3" size="sm" /> - </gl-intersection-observer> + <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" /> + <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')" /> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 0cb4a5bc39f..cf34a60c363 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -50,29 +50,19 @@ export default { renderedNote() { return renderMarkdown(this.note.body); }, - avatarSize() { - if (this.line && !this.isOverviewTab) { - return 24; - } - - return { - default: 24, - md: 32, - }; - }, }, }; </script> <template> - <timeline-entry-item class="note note-wrapper being-posted fade-in-half"> - <div class="timeline-icon"> - <gl-avatar-link class="gl-mr-3" :href="getUserData.path"> + <timeline-entry-item class="note note-wrapper note-comment being-posted fade-in-half"> + <div class="timeline-avatar gl-float-left"> + <gl-avatar-link :href="getUserData.path"> <gl-avatar :src="getUserData.avatar_url" :entity-name="getUserData.username" :alt="getUserData.name" - :size="avatarSize" + :size="32" /> </gl-avatar-link> </div> @@ -85,8 +75,10 @@ export default { </a> </div> </div> - <div class="note-body"> - <div v-safe-html="renderedNote" class="note-text md"></div> + <div class="timeline-discussion-body"> + <div class="note-body"> + <div v-safe-html="renderedNote" class="note-text md"></div> + </div> </div> </div> </timeline-entry-item> 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 2206ae98c73..e091fe74717 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -16,7 +16,7 @@ export default { <div class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"></div> - <div class="note-body"><gl-skeleton-loader /></div> + <div class="note-body gl-mt-4"><gl-skeleton-loader /></div> </div> </timeline-entry-item> </template> 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 7e99f1b01b2..1ae5045b34f 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -129,7 +129,12 @@ export default { <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"> - <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> + <note-header + :author="note.author" + :created-at="note.created_at" + :note-id="note.id" + :is-system-note="true" + > <span ref="gfm-content" v-safe-html="actionTextHtml"></span> <template v-if="canSeeDescriptionVersion || note.outdated_line_change_path" @@ -141,7 +146,7 @@ export default { variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" - class="gl-vertical-align-text-bottom" + class="gl-vertical-align-text-bottom gl-font-sm!" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > @@ -150,7 +155,7 @@ export default { :icon="showLines ? 'chevron-up' : 'chevron-down'" variant="link" data-testid="outdated-lines-change-btn" - class="gl-vertical-align-text-bottom" + class="gl-vertical-align-text-bottom gl-font-sm!" @click="toggleDiff" > {{ __('Compare changes') }} @@ -190,7 +195,7 @@ export default { </div> <div v-if="lines.length && showLines" - class="diff-content gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" > <table :class="$options.userColorSchemeClass" diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js index b7768cfa5b9..df1188d365b 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js @@ -4,7 +4,7 @@ export const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; export const thClass = 'gl-hover-bg-blue-50'; export const bodyTrClass = - 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-gray-50 gl-hover-border-b-solid'; export const defaultPageSize = 20; diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 6867b5a75e3..a5027d2ca5c 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -275,7 +275,7 @@ export default { <template> <div class="incident-management-list"> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')"> - <p v-safe-html="serverErrorMessage || i18n.errorMsg"></p> + <span v-safe-html="serverErrorMessage || i18n.errorMsg"></span> </gl-alert> <div diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue index b4d565991f5..c1246b2bf44 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; const DEFAULT_PAGE_SIZES = [20, 50, 100]; @@ -12,6 +13,7 @@ export default { GlDropdownItem, GlIcon, GlSprintf, + LocalStorageSync, }, props: { pageInfo: { @@ -23,6 +25,11 @@ export default { type: Array, default: () => DEFAULT_PAGE_SIZES, }, + storageKey: { + required: false, + type: String, + default: null, + }, }, computed: { @@ -66,6 +73,12 @@ export default { <template> <div class="gl-display-flex gl-align-items-center"> + <local-storage-sync + v-if="storageKey" + :storage-key="storageKey" + :value="pageInfo.perPage" + @input="setPageSize" + /> <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> <template #button-content> 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 a60b630b207..384b084ce09 100644 --- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue @@ -18,15 +18,15 @@ export default { </script> <template> - <timeline-entry-item class="system-note note-wrapper gl-mb-6!"> + <timeline-entry-item class="system-note note-wrapper"> <div class="timeline-icon"> <gl-icon :name="icon" /> </div> <div class="timeline-content"> <div class="note-header"> - <span> + <div class="note-header-info"> <slot></slot> - </span> + </div> </div> <div class="note-body"> <slot name="body"></slot> diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 1948a6778f4..8c9c7c63db1 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -1,6 +1,7 @@ <script> import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { SORT_DIRECTION_UI } from '~/search/sort/constants'; const ASCENDING_ORDER = 'asc'; const DESCENDING_ORDER = 'desc'; @@ -52,6 +53,9 @@ export default { return acc; }, {}); }, + sortDirectionData() { + return this.isSortAscending ? SORT_DIRECTION_UI.asc : SORT_DIRECTION_UI.desc; + }, }, methods: { generateQueryData({ sorting = {}, filter = [] } = {}) { @@ -119,6 +123,7 @@ export default { data-testid="registry-sort-dropdown" :text="sortText" :is-ascending="isSortAscending" + :sort-direction-tool-tip="sortDirectionData.tooltip" @sortDirectionChange="onDirectionChange" > <gl-sorting-item diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index b61996cdcdb..e6c29e24f0c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -53,6 +53,11 @@ export default { required: false, default: false, }, + allowMultipleScopedLabels: { + type: Boolean, + required: false, + default: false, + }, variant: { type: String, required: false, @@ -164,6 +169,7 @@ export default { allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, allowScopedLabels: this.allowScopedLabels, + allowMultipleScopedLabels: this.allowMultipleScopedLabels, dropdownButtonText: this.dropdownButtonText, selectedLabels: this.selectedLabels, labelsFetchPath: this.labelsFetchPath, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 0c697e624ab..2dab97826b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -16,7 +16,7 @@ export const receiveLabelsSuccess = ({ commit }, labels) => commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); export const receiveLabelsFailure = ({ commit }) => { commit(types.RECEIVE_SET_LABELS_FAILURE); - createFlash({ + createAlert({ message: __('Error fetching labels.'), }); }; @@ -38,7 +38,7 @@ export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LA export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS); export const receiveCreateLabelFailure = ({ commit }) => { commit(types.RECEIVE_CREATE_LABEL_FAILURE); - createFlash({ + createAlert({ message: __('Error creating label.'), }); }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 43b23994cdf..c85d9befcbb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -94,14 +94,13 @@ export default { candidateLabel.indeterminate = false; } - if (isScopedLabel(candidateLabel)) { + if (isScopedLabel(candidateLabel) && !state.allowMultipleScopedLabels) { const currentActiveScopedLabel = state.labels.find( ({ set, title }) => set && title !== candidateLabel.title && scopedLabelKey({ title }) === scopedLabelKey(candidateLabel), ); - if (currentActiveScopedLabel) { currentActiveScopedLabel.set = false; } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index 5f344ae4214..ce93ad216ec 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -8,7 +8,7 @@ import { GlLoadingIcon, } from '@gitlab/ui'; import produce from 'immer'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '~/sidebar/constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; @@ -129,7 +129,7 @@ export default { this.$emit('hideCreateView'); } } catch { - createFlash({ message: errorMessage }); + createAlert({ message: errorMessage }); } this.labelCreateInProgress = false; }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index 8d3d4d5f86a..1d854505d11 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '~/sidebar/constants'; @@ -62,7 +62,7 @@ export default { }, update: (data) => data.workspace?.labels?.nodes || [], error() { - createFlash({ message: __('Error fetching labels.') }); + createAlert({ message: __('Error fetching labels.') }); }, }, }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 522fbc07f5e..0e8da7281d8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issues/constants'; @@ -151,7 +151,7 @@ export default { return data.workspace?.issuable; }, error() { - createFlash({ message: __('Error fetching labels.') }); + createAlert({ message: __('Error fetching labels.') }); }, subscribeToMore: { document() { @@ -275,7 +275,7 @@ export default { }); }) .catch((error) => - createFlash({ + createAlert({ message: __('An error occurred while updating labels.'), captureError: true, error, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 445817d3e52..eae5e96ac46 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query issueParticipants($fullPath: ID!, $iid: String!) { +query issueParticipants($fullPath: ID!, $iid: String!, $getStatus: Boolean = false) { workspace: project(fullPath: $fullPath) { id issuable: issue(iid: $iid) { @@ -9,7 +9,7 @@ query issueParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User - ...UserAvailability + ...UserAvailability @include(if: $getStatus) } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql index 05de680ab05..f087ca6c982 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql @@ -19,7 +19,7 @@ query mergeRequestReviewers($fullPath: ID!, $iid: String!) { } } userPermissions { - updateMergeRequest + adminMergeRequest } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 3496d5f4a2e..2781ac71f31 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query getMrParticipants($fullPath: ID!, $iid: String!) { +query getMrParticipants($fullPath: ID!, $iid: String!, $getStatus: Boolean = false) { workspace: project(fullPath: $fullPath) { id issuable: mergeRequest(iid: $iid) { @@ -9,7 +9,7 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User - ...UserAvailability + ...UserAvailability @include(if: $getStatus) } } } 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 257b9f57222..ffd0eea63a1 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,8 +1,6 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { setAttributes } from '~/lib/utils/dom_utils'; -import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants'; export default { directives: { @@ -27,34 +25,6 @@ export default { required: true, }, }, - computed: { - formattedContent() { - let { content } = this; - - BIDI_CHARS.forEach((bidiChar) => { - if (content.includes(bidiChar)) { - content = content.replace(bidiChar, this.wrapBidiChar(bidiChar)); - } - }); - - return content; - }, - }, - methods: { - wrapBidiChar(bidiChar) { - const span = document.createElement('span'); - - setAttributes(span, { - class: BIDI_CHARS_CLASS_LIST, - title: BIDI_CHAR_TOOLTIP, - 'data-testid': 'bidi-wrapper', - }); - - span.innerText = bidiChar; - - return span.outerHTML; - }, - }, }; </script> <template> @@ -78,7 +48,7 @@ export default { </div> <pre - class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-normal" - ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre> + class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" + ><code><span :id="`LC${number}`" v-safe-html="content" :lang="language" class="line" data-testid="content"></span></code></pre> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 30f57f506a6..a28460dd58e 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -1,5 +1,3 @@ -import { __ } from '~/locale'; - // Language map from Rouge::Lexer to highlight.js // Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md). // Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages). @@ -139,13 +137,6 @@ export const BIDI_CHARS = [ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip'; -export const BIDI_CHAR_TOOLTIP = __( - 'Potentially unwanted character detected: Unicode BiDi Control', -); - -export const HLJS_COMMENT_SELECTOR = 'hljs-comment'; +export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; - -export const NPM_URL = 'https://npmjs.com/package'; -export const GEM_URL = 'https://rubygems.org/gems'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js index 5d24a3d110b..d694adf7147 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js @@ -1,6 +1,8 @@ -import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants'; -import wrapComments from './wrap_comments'; +import wrapChildNodes from './wrap_child_nodes'; import linkDependencies from './link_dependencies'; +import wrapBidiChars from './wrap_bidi_chars'; + +export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; /** * Registers our plugins for Highlight.js @@ -10,7 +12,8 @@ import linkDependencies from './link_dependencies'; * @param {Object} hljs - the Highlight.js instance. */ export const registerPlugins = (hljs, fileType, rawContent) => { - hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments }); + hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes }); + hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars }); hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent), }); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js index dbe6812cf16..49704421d6e 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js @@ -1,16 +1,7 @@ import { escape } from 'lodash'; -import { setAttributes } from '~/lib/utils/dom_utils'; -export const createLink = (href, innerText) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - const rel = 'nofollow noreferrer noopener'; - const link = document.createElement('a'); - - setAttributes(link, { href: escape(href), rel }); - link.textContent = innerText; - - return link.outerHTML; -}; +export const createLink = (href, innerText) => + `<a href="${escape(href)}" rel="nofollow noreferrer noopener">${escape(innerText)}</a>`; export const generateHLJSOpenTag = (type, delimiter = '"') => `<span class="hljs-${escape(type)}">${delimiter}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js index 35de8fd13d6..46c9dc38300 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js @@ -1,7 +1,6 @@ -import { joinPaths } from '~/lib/utils/url_utility'; -import { GEM_URL } from '../../constants'; import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; +const GEM_URL = 'https://rubygems.org/gems/'; const methodRegex = '.*add_dependency.*|.*add_runtime_dependency.*|.*add_development_dependency.*'; const openTagRegex = generateHLJSOpenTag('string', '(&.*;)'); const closeTagRegex = '&.*</span>'; @@ -24,7 +23,7 @@ const DEPENDENCY_REGEX = new RegExp( const handleReplace = (method, delimiter, packageName, closeTag, rest) => { // eslint-disable-next-line @gitlab/require-i18n-strings const openTag = generateHLJSOpenTag('string linked', delimiter); - const href = joinPaths(GEM_URL, packageName); + const href = `${GEM_URL}${packageName}`; const packageLink = createLink(href, packageName); return `${method}${openTag}${packageLink}${closeTag}${rest}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js index 3c6fc23c138..4bfd5ec2431 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js @@ -1,8 +1,7 @@ import { unescape } from 'lodash'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { NPM_URL } from '../../constants'; import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; +const NPM_URL = 'https://npmjs.com/package/'; const attrOpenTag = generateHLJSOpenTag('attr'); const stringOpenTag = generateHLJSOpenTag('string'); const closeTag = '"</span>'; @@ -20,7 +19,7 @@ const DEPENDENCY_REGEX = new RegExp( const handleReplace = (original, packageName, version, dependenciesToLink) => { const unescapedPackageName = unescape(packageName); const unescapedVersion = unescape(version); - const href = joinPaths(NPM_URL, unescapedPackageName); + const href = `${NPM_URL}${unescapedPackageName}`; const packageLink = createLink(href, unescapedPackageName); const versionLink = createLink(href, unescapedVersion); const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_bidi_chars.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_bidi_chars.js new file mode 100644 index 00000000000..3b6cd96ef78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_bidi_chars.js @@ -0,0 +1,30 @@ +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +/** + * Highlight.js plugin for wrapping BIDI chars. + * This ensures potentially dangerous BIDI characters are highlighted. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} Result - an object that represents the highlighted result from Highlight.js + */ + +function wrapBidiChar(bidiChar) { + return `<span class="${BIDI_CHARS_CLASS_LIST}" title="${BIDI_CHAR_TOOLTIP}">${bidiChar}</span>`; +} + +export default (result) => { + let { value } = result; + BIDI_CHARS.forEach((bidiChar) => { + if (value.includes(bidiChar)) { + value = value.replace(bidiChar, wrapBidiChar(bidiChar)); + } + }); + + // eslint-disable-next-line no-param-reassign + result.value = value; // Highlight.js expects the result param to be mutated for plugins to work +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js new file mode 100644 index 00000000000..e0ba4b730a7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js @@ -0,0 +1,45 @@ +import { escape } from 'lodash'; + +/** + * Highlight.js plugin for wrapping nodes with the correct selectors to ensure + * child-elements are highlighted correctly after we split up the result into chunks and lines. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} Result - an object that represents the highlighted result from Highlight.js + */ +const newlineRegex = /\r?\n/; +const generateClassName = (suffix) => (suffix ? `hljs-${escape(suffix)}` : ''); +const generateCloseTag = (includeClose) => (includeClose ? '</span>' : ''); +const generateHLJSTag = (kind, content = '', includeClose) => + `<span class="${generateClassName(kind)}">${escape(content)}${generateCloseTag(includeClose)}`; + +const format = (node, kind = '') => { + let buffer = ''; + + if (typeof node === 'string') { + buffer += node + .split(newlineRegex) + .map((newline) => generateHLJSTag(kind, newline, true)) + .join('\n'); + } else if (node.kind) { + const { children } = node; + if (children.length && children.length === 1) { + buffer += format(children[0], node.kind); + } else { + buffer += generateHLJSTag(node.kind); + children.forEach((subChild) => { + buffer += format(subChild, node.kind); + }); + buffer += `</span>`; + } + } + + return buffer; +}; + +export default (result) => { + // NOTE: We're using the private Emitter API here as we expect the Emitter API to be publicly available soon (https://github.com/highlightjs/highlight.js/issues/3621) + // eslint-disable-next-line no-param-reassign, no-underscore-dangle + result.value = result._emitter.rootNode.children.reduce((val, node) => val + format(node), ''); // Highlight.js expects the result param to be mutated for plugins to work +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js deleted file mode 100644 index 8b52df83fdf..00000000000 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js +++ /dev/null @@ -1,41 +0,0 @@ -import { HLJS_COMMENT_SELECTOR } from '../constants'; - -const createWrapper = (content) => { - const span = document.createElement('span'); - span.className = HLJS_COMMENT_SELECTOR; - - // eslint-disable-next-line no-unsanitized/property - span.innerHTML = content; - return span.outerHTML; -}; - -/** - * Highlight.js plugin for wrapping multi-line comments in the `hljs-comment` class. - * This ensures that multi-line comments are rendered correctly in the GitLab UI. - * - * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst - * - * @param {Object} Result - an object that represents the highlighted result from Highlight.js - */ -export default (result) => { - if (!result.value.includes(HLJS_COMMENT_SELECTOR)) return; - - let wrapComment = false; - - // eslint-disable-next-line no-param-reassign - result.value = result.value // Highlight.js expects the result param to be mutated for plugins to work - .split('\n') - .map((lineContent) => { - const includesClosingTag = lineContent.includes('</span>'); - if (lineContent.includes(HLJS_COMMENT_SELECTOR) && !includesClosingTag) { - wrapComment = true; - return lineContent; - } - const line = wrapComment ? createWrapper(lineContent) : lineContent; - if (includesClosingTag) { - wrapComment = false; - } - return line; - }) - .join('\n'); -}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 9c6c12eac7d..536b2c8a281 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -53,7 +53,7 @@ export default { }, computed: { splitContent() { - return this.content.split('\n'); + return this.content.split(/\r?\n/); }, lineNumbers() { return this.splitContent.length; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js new file mode 100644 index 00000000000..535e857d7a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js @@ -0,0 +1,10 @@ +import { highlight } from './highlight_utils'; + +/** + * A webworker for highlighting large amounts of content with Highlight.js + */ +// eslint-disable-next-line no-restricted-globals +self.addEventListener('message', ({ data: { fileType, content, language } }) => { + // eslint-disable-next-line no-restricted-globals + self.postMessage(highlight(fileType, content, language)); +}); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js new file mode 100644 index 00000000000..0da57f9e6fa --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js @@ -0,0 +1,15 @@ +import hljs from 'highlight.js/lib/core'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import { registerPlugins } from '../plugins/index'; + +const initHighlightJs = async (fileType, content, language) => { + const languageDefinition = await languageLoader[language](); + + registerPlugins(hljs, fileType, content); + hljs.registerLanguage(language, languageDefinition.default); +}; + +export const highlight = (fileType, content, language) => { + initHighlightJs(fileType, content, language); + return hljs.highlight(content, { language }).value; +}; diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue deleted file mode 100644 index ce65266cbc9..00000000000 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; -import { secondsToHours } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; - -export default { - name: 'TimezoneDropdown', - components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - }, - directives: { - autofocusonshow, - }, - props: { - value: { - type: String, - required: true, - default: '', - }, - timezoneData: { - type: Array, - required: true, - default: () => [], - }, - }, - data() { - return { - searchTerm: '', - }; - }, - tranlations: { - noResultsText: __('No matching results'), - }, - computed: { - timezones() { - return this.timezoneData.map((timezone) => ({ - formattedTimezone: this.formatTimezone(timezone), - identifier: timezone.identifier, - })); - }, - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.timezones.filter((timezone) => - timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm), - ); - }, - selectedTimezoneLabel() { - return this.value || __('Select timezone'); - }, - }, - methods: { - selectTimezone(selectedTimezone) { - this.$emit('input', selectedTimezone); - this.searchTerm = ''; - }, - isSelected(timezone) { - return this.value === timezone.formattedTimezone; - }, - formatTimezone(item) { - return `[UTC ${secondsToHours(item.offset)}] ${item.name}`; - }, - }, -}; -</script> -<template> - <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs"> - <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> - <gl-dropdown-item - v-for="timezone in filteredResults" - :key="timezone.formattedTimezone" - :is-checked="isSelected(timezone)" - is-check-item - @click="selectTimezone(timezone)" - > - {{ timezone.formattedTimezone }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="!filteredResults.length" - class="gl-pointer-events-none" - data-testid="noMatchingResults" - > - {{ $options.tranlations.noResultsText }} - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue new file mode 100644 index 00000000000..423501265d7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue @@ -0,0 +1,119 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { __ } from '~/locale'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; + +export default { + name: 'TimezoneDropdown', + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + }, + directives: { + autofocusonshow, + }, + props: { + value: { + type: String, + required: true, + default: '', + }, + name: { + type: String, + required: false, + default: '', + }, + timezoneData: { + type: Array, + required: true, + default: () => [], + }, + }, + data() { + return { + searchTerm: '', + tzValue: this.initialTimezone(this.timezoneData, this.value), + }; + }, + translations: { + noResultsText: __('No matching results'), + }, + computed: { + timezones() { + return this.timezoneData.map((timezone) => ({ + formattedTimezone: formatTimezone(timezone), + identifier: timezone.identifier, + })); + }, + filteredResults() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.timezones.filter((timezone) => + timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + selectedTimezoneLabel() { + return this.tzValue || __('Select timezone'); + }, + timezoneIdentifier() { + return this.tzValue + ? this.timezones.find((timezone) => timezone.formattedTimezone === this.tzValue).identifier + : undefined; + }, + }, + methods: { + selectTimezone(selectedTimezone) { + this.tzValue = selectedTimezone.formattedTimezone; + this.$emit('input', selectedTimezone); + this.searchTerm = ''; + }, + isSelected(timezone) { + return this.tzValue === timezone.formattedTimezone; + }, + initialTimezone(timezones, value) { + if (!value) { + return undefined; + } + + const initialTimezone = timezones.find((timezone) => timezone.identifier === value); + + if (initialTimezone) { + return formatTimezone(initialTimezone); + } + + return undefined; + }, + }, +}; +</script> +<template> + <div> + <input + v-if="name" + id="user_timezone" + :name="name" + :value="timezoneIdentifier || value" + type="hidden" + /> + <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs"> + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> + <gl-dropdown-item + v-for="timezone in filteredResults" + :key="timezone.formattedTimezone" + :is-checked="isSelected(timezone)" + is-check-item + @click="selectTimezone(timezone)" + > + {{ timezone.formattedTimezone }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="!filteredResults.length" + class="gl-pointer-events-none" + data-testid="noMatchingResults" + > + {{ $options.translations.noResultsText }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue index 925c6008836..bd5b7b77017 100644 --- a/app/assets/javascripts/vue_shared/components/url_sync.vue +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -1,6 +1,9 @@ <script> import { historyPushState } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; + +export const URL_SET_PARAMS_STRATEGY = 'set'; +export const URL_MERGE_PARAMS_STRATEGY = 'merge'; /** * Renderless component to update the query string, @@ -15,6 +18,12 @@ export default { required: false, default: null, }, + urlParamsUpdateStrategy: { + type: String, + required: false, + default: URL_MERGE_PARAMS_STRATEGY, + validator: (value) => [URL_MERGE_PARAMS_STRATEGY, URL_SET_PARAMS_STRATEGY].includes(value), + }, }, watch: { query: { @@ -29,7 +38,11 @@ export default { }, methods: { updateQuery(newQuery) { - historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true })); + const url = + this.urlParamsUpdateStrategy === URL_SET_PARAMS_STRATEGY + ? setUrlParams(this.query, window.location.href, true) + : mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }); + historyPushState(url); }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index c1e618620d8..6552a874c3a 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -5,29 +5,29 @@ Sample configuration: - <user-avatar-image + <user-avatar lazy :img-src="userAvatarSrc" :img-alt="tooltipText" :tooltip-text="tooltipText" tooltip-placement="top" + :size="24" /> */ +import { GlTooltip, GlAvatar } from '@gitlab/ui'; +import { isObject } from 'lodash'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import UserAvatarImageNew from './user_avatar_image_new.vue'; -import UserAvatarImageOld from './user_avatar_image_old.vue'; +import { placeholderImage } from '~/lazy_loader'; export default { name: 'UserAvatarImage', components: { - UserAvatarImageNew, - UserAvatarImageOld, + GlTooltip, + GlAvatar, }, - mixins: [glFeatureFlagMixin()], props: { lazy: { type: Boolean, @@ -51,8 +51,7 @@ export default { }, size: { type: [Number, Object], - required: false, - default: 20, + required: true, }, tooltipText: { type: String, @@ -64,22 +63,52 @@ export default { required: false, default: 'top', }, - enforceGlAvatar: { - type: Boolean, - required: false, + }, + computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + // Only adds the width to the URL if its not a base64 data image + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.maximumSize}`; + return baseSrc; + }, + maximumSize() { + if (isObject(this.size)) { + return Math.max(...Object.values(this.size)); + } + + return this.size; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; }, }, }; </script> <template> - <user-avatar-image-new - v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar" - v-bind="$props" - > - <slot></slot> - </user-avatar-image-new> - <user-avatar-image-old v-else v-bind="$props"> - <slot></slot> - </user-avatar-image-old> + <span ref="userAvatar"> + <gl-avatar + :class="{ + lazy: lazy, + [cssClasses]: true, + }" + :src="resultantSrcAttribute" + :data-src="sanitizedSource" + :size="size" + :alt="imgAlt" + /> + + <gl-tooltip + v-if="tooltipText || $scopedSlots.default" + :target="() => $refs.userAvatar" + :placement="tooltipPlacement" + boundary="window" + > + <slot>{{ tooltipText }}</slot> + </gl-tooltip> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue deleted file mode 100644 index 6bd66981860..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ /dev/null @@ -1,117 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar that - does not need to link to the user's profile. The image and an optional - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar - lazy - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :tooltip-text="tooltipText" - tooltip-placement="top" - /> - - */ - -import { GlTooltip, GlAvatar } from '@gitlab/ui'; -import { isObject } from 'lodash'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { __ } from '~/locale'; -import { placeholderImage } from '~/lazy_loader'; - -export default { - name: 'UserAvatarImageNew', - components: { - GlTooltip, - GlAvatar, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - imgSrc: { - type: String, - required: false, - default: defaultAvatarUrl, - }, - cssClasses: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: __('user avatar'), - }, - size: { - type: [Number, Object], - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - }, - computed: { - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside user avatar link. - // In both cases we should render the defaultAvatarUrl - sanitizedSource() { - let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - // Only adds the width to the URL if its not a base64 data image - if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) - baseSrc += `?width=${this.maximumSize}`; - return baseSrc; - }, - maximumSize() { - if (isObject(this.size)) { - return Math.max(...Object.values(this.size)); - } - - return this.size; - }, - resultantSrcAttribute() { - return this.lazy ? placeholderImage : this.sanitizedSource; - }, - }, -}; -</script> - -<template> - <span ref="userAvatar"> - <gl-avatar - :class="{ - lazy: lazy, - [cssClasses]: true, - }" - :src="resultantSrcAttribute" - :data-src="sanitizedSource" - :size="size" - :alt="imgAlt" - /> - - <gl-tooltip - v-if=" - tooltipText || - $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ - " - :target="() => $refs.userAvatar" - :placement="tooltipPlacement" - boundary="window" - > - <slot>{{ tooltipText }}</slot> - </gl-tooltip> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue deleted file mode 100644 index 6e8c200d5c3..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue +++ /dev/null @@ -1,114 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar that - does not need to link to the user's profile. The image and an optional - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-image - lazy - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :tooltip-text="tooltipText" - tooltip-placement="top" - /> - - */ - -import { GlTooltip } from '@gitlab/ui'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { __ } from '~/locale'; -import { placeholderImage } from '~/lazy_loader'; - -export default { - name: 'UserAvatarImageOld', - components: { - GlTooltip, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - imgSrc: { - type: String, - required: false, - default: defaultAvatarUrl, - }, - cssClasses: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: __('user avatar'), - }, - size: { - type: Number, - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - }, - computed: { - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside user avatar link. - // In both cases we should render the defaultAvatarUrl - sanitizedSource() { - let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - // Only adds the width to the URL if its not a base64 data image - if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) - baseSrc += `?width=${this.size}`; - return baseSrc; - }, - resultantSrcAttribute() { - return this.lazy ? placeholderImage : this.sanitizedSource; - }, - avatarSizeClass() { - return `s${this.size}`; - }, - }, -}; -</script> - -<template> - <span> - <img - ref="userAvatarImage" - :class="{ - lazy: lazy, - [avatarSizeClass]: true, - [cssClasses]: true, - }" - :src="resultantSrcAttribute" - :width="size" - :height="size" - :alt="imgAlt" - :data-src="sanitizedSource" - class="avatar" - /> - <gl-tooltip - v-if=" - tooltipText || - $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ - " - :target="() => $refs.userAvatarImage" - :placement="tooltipPlacement" - boundary="window" - > - <slot>{{ tooltipText }}</slot> - </gl-tooltip> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index f80abed4d69..1a81da3eb0d 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -9,7 +9,7 @@ :link-href="userProfileUrl" :img-src="userAvatarSrc" :img-alt="tooltipText" - :img-size="20" + :img-size="32" :tooltip-text="tooltipText" :tooltip-placement="top" :username="username" @@ -17,17 +17,18 @@ */ -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import UserAvatarLinkNew from './user_avatar_link_new.vue'; -import UserAvatarLinkOld from './user_avatar_link_old.vue'; +import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; +import UserAvatarImage from './user_avatar_image.vue'; export default { - name: 'UserAvatarLink', + name: 'UserAvatarLinkNew', components: { - UserAvatarLinkNew, - UserAvatarLinkOld, + UserAvatarImage, + GlAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { lazy: { type: Boolean, @@ -56,8 +57,7 @@ export default { }, imgSize: { type: [Number, Object], - required: false, - default: 20, + required: true, }, tooltipText: { type: String, @@ -74,29 +74,43 @@ export default { required: false, default: '', }, - enforceGlAvatar: { - type: Boolean, - required: false, + }, + computed: { + shouldShowUsername() { + return this.username.length > 0; + }, + avatarTooltipText() { + return this.shouldShowUsername ? '' : this.tooltipText; }, }, }; </script> <template> - <user-avatar-link-new - v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar" - v-bind="$props" - > - <slot></slot> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </user-avatar-link-new> + <gl-avatar-link :href="linkHref" class="user-avatar-link"> + <user-avatar-image + :img-src="imgSrc" + :img-alt="imgAlt" + :css-classes="imgCssClasses" + :size="imgSize" + :tooltip-text="avatarTooltipText" + :tooltip-placement="tooltipPlacement" + :lazy="lazy" + > + <slot></slot> + </user-avatar-image> + + <span + v-if="shouldShowUsername" + v-gl-tooltip + :title="tooltipText" + :tooltip-placement="tooltipPlacement" + class="gl-ml-3" + data-testid="user-avatar-link-username" + > + {{ username }} + </span> - <user-avatar-link-old v-else v-bind="$props"> - <slot></slot> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </user-avatar-link-old> + <slot name="avatar-badge"></slot> + </gl-avatar-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue deleted file mode 100644 index 83551c689c4..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue +++ /dev/null @@ -1,122 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar wrapped in - a clickable link (likely to the user's profile). The link, image, and - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-link - :link-href="userProfileUrl" - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :img-size="20" - :tooltip-text="tooltipText" - :tooltip-placement="top" - :username="username" - /> - -*/ - -import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; -import UserAvatarImage from './user_avatar_image.vue'; - -export default { - name: 'UserAvatarLinkNew', - components: { - UserAvatarImage, - GlAvatarLink, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - linkHref: { - type: String, - required: false, - default: '', - }, - imgSrc: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: '', - }, - imgCssClasses: { - type: String, - required: false, - default: '', - }, - imgSize: { - type: [Number, Object], - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - username: { - type: String, - required: false, - default: '', - }, - enforceGlAvatar: { - type: Boolean, - required: false, - }, - }, - computed: { - shouldShowUsername() { - return this.username.length > 0; - }, - avatarTooltipText() { - return this.shouldShowUsername ? '' : this.tooltipText; - }, - }, -}; -</script> - -<template> - <gl-avatar-link :href="linkHref" class="user-avatar-link"> - <user-avatar-image - :img-src="imgSrc" - :img-alt="imgAlt" - :css-classes="imgCssClasses" - :size="imgSize" - :tooltip-text="avatarTooltipText" - :tooltip-placement="tooltipPlacement" - :lazy="lazy" - :enforce-gl-avatar="enforceGlAvatar" - > - <slot></slot> - </user-avatar-image> - - <span - v-if="shouldShowUsername" - v-gl-tooltip - :title="tooltipText" - :tooltip-placement="tooltipPlacement" - class="gl-ml-3" - data-testid="user-avatar-link-username" - > - {{ username }} - </span> - - <slot name="avatar-badge"></slot> - </gl-avatar-link> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue deleted file mode 100644 index c2e46e61e1b..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue +++ /dev/null @@ -1,117 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar wrapped in - a clickable link (likely to the user's profile). The link, image, and - tooltip can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-link - :link-href="userProfileUrl" - :img-src="userAvatarSrc" - :img-alt="tooltipText" - :img-size="20" - :tooltip-text="tooltipText" - :tooltip-placement="top" - :username="username" - /> - -*/ - -import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import UserAvatarImage from './user_avatar_image.vue'; - -export default { - name: 'UserAvatarLinkOld', - components: { - GlLink, - UserAvatarImage, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - linkHref: { - type: String, - required: false, - default: '', - }, - imgSrc: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: '', - }, - imgCssClasses: { - type: String, - required: false, - default: '', - }, - imgSize: { - type: Number, - required: false, - default: 20, - }, - tooltipText: { - type: String, - required: false, - default: '', - }, - tooltipPlacement: { - type: String, - required: false, - default: 'top', - }, - username: { - type: String, - required: false, - default: '', - }, - }, - computed: { - shouldShowUsername() { - return this.username.length > 0; - }, - avatarTooltipText() { - return this.shouldShowUsername ? '' : this.tooltipText; - }, - }, -}; -</script> - -<template> - <span> - <gl-link :href="linkHref" class="user-avatar-link"> - <user-avatar-image - :img-src="imgSrc" - :img-alt="imgAlt" - :css-classes="imgCssClasses" - :size="imgSize" - :tooltip-text="avatarTooltipText" - :tooltip-placement="tooltipPlacement" - :lazy="lazy" - > - <slot></slot> - </user-avatar-image> - - <span - v-if="shouldShowUsername" - v-gl-tooltip - :title="tooltipText" - :tooltip-placement="tooltipPlacement" - data-testid="user-avatar-link-username" - > - {{ username }} - </span> - <slot name="avatar-badge"></slot> - </gl-link> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 9da298ad705..231f5ff3d1f 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -1,6 +1,5 @@ <script> import { GlButton } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '~/locale'; import UserAvatarLink from './user_avatar_link.vue'; @@ -9,7 +8,6 @@ export default { UserAvatarLink, GlButton, }, - mixins: [glFeatureFlagMixin()], props: { items: { type: Array, @@ -22,8 +20,7 @@ export default { }, imgSize: { type: [Number, Object], - required: false, - default: 20, + required: true, }, emptyText: { type: String, @@ -59,9 +56,6 @@ export default { return sprintf(__('%{count} more'), { count }); }, - imgCssClasses() { - return this.glFeatures.glAvatarForAllUserAvatars ? 'gl-mr-3' : ''; - }, }, methods: { expand() { @@ -85,7 +79,7 @@ export default { :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" - :img-css-classes="imgCssClasses" + img-css-classes="gl-mr-3" /> <template v-if="hasBreakpoint"> <gl-button v-if="hasHiddenItems" variant="link" @click="expand"> 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 4b39a8e45bb..80c1fcbacfa 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 @@ -10,7 +10,7 @@ import { GlAvatarLabeled, } from '@gitlab/ui'; import { glEmojiTag } from '~/emoji'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; import { isUserBusy } from '~/set_status_modal/utils'; import Tracking from '~/tracking'; @@ -83,6 +83,8 @@ export default { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; } else if (this.user.status.message_html) { return this.user.status.message_html; + } else if (this.user.status.emoji) { + return glEmojiTag(this.user.status.emoji); } return ''; @@ -139,8 +141,9 @@ export default { await followUser(this.user.id); this.$emit('follow'); } catch (error) { - createFlash({ - message: I18N_ERROR_FOLLOW, + const message = error.response?.data?.message || I18N_ERROR_FOLLOW; + createAlert({ + message, error, captureError: true, }); @@ -159,7 +162,7 @@ export default { await unfollowUser(this.user.id); this.$emit('unfollow'); } catch (error) { - createFlash({ + createAlert({ message: I18N_ERROR_UNFOLLOW, error, captureError: true, diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 3180bd0d283..86a99b8f0ed 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -103,6 +103,7 @@ export default { return { iid: this.iid, fullPath: this.fullPath, + getStatus: true, }; }, update(data) { diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index b6d69faebb5..a851f84ed2f 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -93,3 +93,6 @@ export const confidentialityInfoText = (workspaceType, issuableType) => : __('at least the Reporter role'), }, ); + +export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField'; +export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor'; diff --git a/app/assets/javascripts/vue_shared/directives/safe_html.js b/app/assets/javascripts/vue_shared/directives/safe_html.js new file mode 100644 index 00000000000..450c7fc1bc5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/directives/safe_html.js @@ -0,0 +1,25 @@ +import { sanitize } from '~/lib/dompurify'; + +// Mitigate against future dompurify mXSS bypasses by +// avoiding additional serialize/parse round trip. +// See https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1782 +// and https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2127 +// for more details. +const DEFAULT_CONFIG = { + RETURN_DOM_FRAGMENT: true, +}; + +const transform = (el, binding) => { + if (binding.oldValue !== binding.value) { + const config = { ...DEFAULT_CONFIG, ...(binding.arg ?? {}) }; + + el.textContent = ''; + + el.appendChild(sanitize(binding.value, config)); + } +}; + +export default { + bind: transform, + update: transform, +}; 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 232749a2d01..624ae7027d5 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 @@ -1,11 +1,13 @@ <script> import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue'; import LegacyContainer from './components/legacy_container.vue'; import WelcomePage from './components/welcome.vue'; export default { components: { + NewTopLevelGroupAlert, GlBreadcrumb, GlIcon, WelcomePage, @@ -79,6 +81,14 @@ export default { shouldVerify() { return this.verificationRequired && !this.verificationCompleted; }, + + showNewTopLevelGroupAlert() { + if (this.activePanel.detailProps === undefined) { + return false; + } + + return this.activePanel.detailProps.parentGroupName === ''; + }, }, created() { @@ -130,6 +140,7 @@ export default { <slot name="extra-description"></slot> </div> <div class="col-lg-9"> + <new-top-level-group-alert v-if="showNewTopLevelGroupAlert" /> <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs" /> <legacy-container :key="activePanel.name" :selector="activePanel.selector" /> </div> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue index e0669b3ed27..a4fb30a03a1 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue @@ -1,6 +1,6 @@ <script> import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; @@ -67,7 +67,7 @@ export default { }, methods: { showError(error) { - createFlash({ + createAlert({ message: this.$options.i18n.apiError, captureError: true, error, diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index f6d85599dba..0e1975e1c09 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import ReportSection from '~/reports/components/report_section.vue'; import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; @@ -160,7 +160,7 @@ export default { this.fetchCounts(); }, showError(error) { - createFlash({ + createAlert({ message: this.$options.i18n.apiError, captureError: true, error, diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue new file mode 100644 index 00000000000..5ec16d4ba15 --- /dev/null +++ b/app/assets/javascripts/webhooks/components/form_url_app.vue @@ -0,0 +1,134 @@ +<script> +import { isEmpty } from 'lodash'; +import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +import FormUrlMaskItem from './form_url_mask_item.vue'; + +export default { + components: { + FormUrlMaskItem, + GlFormGroup, + GlFormInput, + GlFormRadio, + GlFormRadioGroup, + GlLink, + }, + props: { + initialUrl: { + type: String, + required: false, + default: null, + }, + initialUrlVariables: { + type: Array, + required: false, + default: null, + }, + }, + data() { + return { + maskEnabled: !isEmpty(this.initialUrlVariables), + url: this.initialUrl, + items: isEmpty(this.initialUrlVariables) ? [{}] : this.initialUrlVariables, + }; + }, + computed: { + maskedUrl() { + if (!this.url) { + return null; + } + + let maskedUrl = this.url; + + this.items.forEach(({ key, value }) => { + if (!key || !value) { + return; + } + + const replacementExpression = new RegExp(value, 'g'); + maskedUrl = maskedUrl.replace(replacementExpression, `{${key}}`); + }); + + return maskedUrl; + }, + }, + methods: { + onItemInput({ index, key, value }) { + this.$set(this.items, index, { key, value }); + }, + addItem() { + this.items.push({}); + }, + removeItem(index) { + this.items.splice(index, 1); + }, + }, + i18n: { + addItem: s__('Webhooks|+ Mask another portion of URL'), + radioFullUrlText: s__('Webhooks|Show full URL'), + radioMaskUrlText: s__('Webhooks|Mask portions of URL'), + radioMaskUrlHelp: s__('Webhooks|Do not show sensitive data such as tokens in the UI.'), + urlDescription: s__( + 'Webhooks|URL must be percent-encoded if it contains one or more special characters.', + ), + urlLabel: __('URL'), + urlPlaceholder: 'http://example.com/trigger-ci.json', + urlPreview: s__('Webhooks|URL preview'), + }, +}; +</script> + +<template> + <div> + <gl-form-group + :label="$options.i18n.urlLabel" + label-for="webhook-url" + :description="$options.i18n.urlDescription" + > + <gl-form-input + id="webhook-url" + v-model="url" + name="hook[url]" + :placeholder="$options.i18n.urlPlaceholder" + data-testid="form-url" + /> + </gl-form-group> + <div class="gl-mt-5"> + <gl-form-radio-group v-model="maskEnabled"> + <gl-form-radio :value="false">{{ $options.i18n.radioFullUrlText }}</gl-form-radio> + <gl-form-radio :value="true" + >{{ $options.i18n.radioMaskUrlText }} + <template #help> + {{ $options.i18n.radioMaskUrlHelp }} + </template> + </gl-form-radio> + </gl-form-radio-group> + + <div v-if="maskEnabled" class="gl-ml-6" data-testid="url-mask-section"> + <form-url-mask-item + v-for="({ key, value }, index) in items" + :key="index" + :index="index" + :item-key="key" + :item-value="value" + @input="onItemInput" + @remove="removeItem" + /> + <div class="gl-mb-5"> + <gl-link @click="addItem">{{ $options.i18n.addItem }}</gl-link> + </div> + + <gl-form-group :label="$options.i18n.urlPreview" label-for="webhook-url-preview"> + <gl-form-input + id="webhook-url-preview" + :value="maskedUrl" + readonly + name="hook[url]" + data-testid="form-url-preview" + /> + </gl-form-group> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue new file mode 100644 index 00000000000..3b75f9b6c0d --- /dev/null +++ b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue @@ -0,0 +1,90 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + props: { + index: { + type: Number, + required: false, + default: null, + }, + itemKey: { + type: String, + required: false, + default: null, + }, + itemValue: { + type: String, + required: false, + default: null, + }, + }, + computed: { + keyInputId() { + return this.inputId('key'); + }, + valueInputId() { + return this.inputId('value'); + }, + }, + methods: { + inputId(type) { + return `webhook-url-mask-item-${type}-${this.index}`; + }, + inputName(type) { + return `hook[url_variables][][${type}]`; + }, + onKeyInput(key) { + this.$emit('input', { index: this.index, key, value: this.itemValue }); + }, + onValueInput(value) { + this.$emit('input', { index: this.index, key: this.itemKey, value }); + }, + onRemoveClick() { + this.$emit('remove', this.index); + }, + }, + i18n: { + keyLabel: s__('Webhooks|How it looks in the UI'), + valueLabel: s__('Webhooks|Sensitive portion of URL'), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-flex-end gl-gap-3 gl-mb-3"> + <gl-form-group + :label="$options.i18n.valueLabel" + :label-for="valueInputId" + class="gl-flex-grow-1 gl-mb-0" + data-testid="mask-item-value" + > + <gl-form-input + :id="valueInputId" + :name="inputName('value')" + :value="itemValue" + @input="onValueInput" + /> + </gl-form-group> + <gl-form-group + :label="$options.i18n.keyLabel" + :label-for="keyInputId" + class="gl-flex-grow-1 gl-mb-0" + data-testid="mask-item-key" + > + <gl-form-input + :id="keyInputId" + :name="inputName('key')" + :value="itemKey" + @input="onKeyInput" + /> + </gl-form-group> + <gl-button icon="remove" :aria-label="__('Remove')" @click="onRemoveClick" /> + </div> +</template> diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js new file mode 100644 index 00000000000..1b2b33e44c1 --- /dev/null +++ b/app/assets/javascripts/webhooks/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import FormUrlApp from './components/form_url_app.vue'; + +export default () => { + const el = document.querySelector('.js-vue-webhook-form'); + + if (!el) { + return null; + } + + const { url: initialUrl, urlVariables } = el.dataset; + + return new Vue({ + el, + name: 'WebhookFormRoot', + render(createElement) { + return createElement(FormUrlApp, { + props: { + initialUrl, + initialUrlVariables: urlVariables ? JSON.parse(urlVariables) : undefined, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4585426edaa..4d6a27f61ac 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -10,7 +10,7 @@ import { GlDropdownDivider, GlIntersectionObserver, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, uniqueId } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; @@ -126,6 +126,9 @@ export default { }, }, computed: { + assigneesTitleId() { + return uniqueId('assignees-title-'); + }, searchUsers() { return this.users.nodes.map((node) => addClass({ ...node, ...node.user })); }, @@ -139,9 +142,6 @@ export default { property: `type_${this.workItemType}`, }; }, - assigneeListEmpty() { - return this.assignees.length === 0; - }, containerClass() { return !this.isEditing ? 'gl-shadow-none!' : ''; }, @@ -296,12 +296,14 @@ export default { <template> <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap"> <span + :id="assigneesTitleId" class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" data-testid="assignees-title" >{{ assigneeText }}</span > <gl-token-selector ref="tokenSelector" + :aria-labelledby="assigneesTitleId" :selected-tokens="localAssignees" :container-class="containerClass" :class="{ 'gl-hover-border-gray-200': canUpdate }" @@ -319,7 +321,7 @@ export default { > <template #empty-placeholder> <div - class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-pl-2 gl-top-2" + class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-secondary gl-pr-4 gl-pl-2 gl-top-2" data-testid="empty-state" > <gl-icon name="profile" /> 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 c2e4a50fe31..57babe4569d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -5,6 +5,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { __, s__ } from '~/locale'; +import EditedAt from '~/issues/show/components/edited.vue'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import workItemQuery from '../graphql/work_item.query.graphql'; @@ -16,6 +17,7 @@ export default { SafeHtml: GlSafeHtmlDirective, }, components: { + EditedAt, GlButton, GlFormGroup, MarkdownField, @@ -89,6 +91,15 @@ export default { workItemType() { return this.workItem?.workItemType?.name; }, + lastEditedAt() { + return this.workItemDescription?.lastEditedAt; + }, + lastEditedByName() { + return this.workItemDescription?.lastEditedBy?.name; + }, + lastEditedByPath() { + return this.workItemDescription?.lastEditedBy?.webPath; + }, markdownPreviewPath() { return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ this.workItemType @@ -228,12 +239,18 @@ export default { class="gl-ml-auto" icon="pencil" data-testid="edit-description" - :aria-label="__('Edit')" + :aria-label="__('Edit description')" @click="startEditing" /> </div> <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div> <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div> + <edited-at + v-if="lastEditedAt" + :updated-at="lastEditedAt" + :updated-by-name="lastEditedByName" + :updated-by-path="lastEditedByPath" + /> </div> </template> 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 3d25df9fcb8..af9b8c6101a 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -7,7 +7,10 @@ import { GlBadge, GlButton, GlTooltipDirective, + GlEmptyState, } from '@gitlab/ui'; +import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; +import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -20,11 +23,14 @@ import { WIDGET_TYPE_WEIGHT, WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_ITERATION, } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; +import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; @@ -35,6 +41,7 @@ import WorkItemDescription from './work_item_description.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; +import WorkItemMilestone from './work_item_milestone.vue'; import WorkItemInformation from './work_item_information.vue'; export default { @@ -49,6 +56,7 @@ export default { GlLoadingIcon, GlSkeletonLoader, GlIcon, + GlEmptyState, WorkItemAssignees, WorkItemActions, WorkItemDescription, @@ -60,6 +68,8 @@ export default { WorkItemInformation, LocalStorageSync, WorkItemTypeIcon, + WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemMilestone, }, mixins: [glFeatureFlagMixin()], props: { @@ -82,6 +92,7 @@ export default { data() { return { error: undefined, + updateError: undefined, workItem: {}, showInfoBanner: true, updateInProgress: false, @@ -100,9 +111,10 @@ export default { }, error() { this.error = this.$options.i18n.fetchError; + document.title = s__('404|Not found'); }, result() { - if (!this.isModal) { + if (!this.isModal && this.workItem.project) { const path = this.workItem.project?.fullPath ? ` · ${this.workItem.project.fullPath}` : ''; @@ -127,7 +139,18 @@ export default { }; }, skip() { - return !this.workItemDueDate; + return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); + }, + }, + { + document: workItemAssigneesSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + skip() { + return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, }, ], @@ -152,37 +175,44 @@ export default { workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, + parentWorkItem() { + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; + }, + parentWorkItemConfidentiality() { + return this.parentWorkItem?.confidential; + }, + parentUrl() { + return `../../issues/${this.parentWorkItem?.iid}`; + }, + workItemIconName() { + return this.workItem?.workItemType?.iconName; + }, + noAccessSvgPath() { + return `data:image/svg+xml;utf8,${encodeURIComponent(noAccessSvg)}`; + }, hasDescriptionWidget() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION); }, workItemAssignees() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES); + return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, workItemLabels() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.isWidgetPresent(WIDGET_TYPE_LABELS); }, workItemDueDate() { - return this.workItem?.widgets?.find( - (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE, - ); + return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); }, workItemWeight() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); + return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); }, workItemHierarchy() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); }, - parentWorkItem() { - return this.workItemHierarchy?.parent; + workItemIteration() { + return this.isWidgetPresent(WIDGET_TYPE_ITERATION); }, - parentWorkItemConfidentiality() { - return this.parentWorkItem?.confidential; - }, - parentUrl() { - return `../../issues/${this.parentWorkItem?.iid}`; - }, - workItemIconName() { - return this.workItem?.workItemType?.iconName; + workItemMilestone() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE); }, }, beforeDestroy() { @@ -191,6 +221,9 @@ export default { this.dismissBanner(); }, methods: { + isWidgetPresent(type) { + return this.workItem?.widgets?.find((widget) => widget.type === type); + }, dismissBanner() { this.showInfoBanner = false; }, @@ -236,7 +269,7 @@ export default { }, ) .catch((error) => { - this.error = error.message; + this.updateError = error.message; }) .finally(() => { this.updateInProgress = false; @@ -249,8 +282,13 @@ export default { <template> <section class="gl-pt-5"> - <gl-alert v-if="error" class="gl-mb-3" variant="danger" @dismiss="error = undefined"> - {{ error }} + <gl-alert + v-if="updateError" + class="gl-mb-3" + variant="danger" + @dismiss="updateError = undefined" + > + {{ updateError }} </gl-alert> <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5"> @@ -289,7 +327,7 @@ export default { </li> </ul> <work-item-type-icon - v-else + v-else-if="!error" :work-item-icon-name="workItemIconName" :work-item-type="workItemType && workItemType.toUpperCase()" show-text @@ -316,7 +354,7 @@ export default { :is-parent-confidential="parentWorkItemConfidentiality" @deleteWorkItem="$emit('deleteWorkItem', workItemType)" @toggleWorkItemConfidentiality="toggleConfidentiality" - @error="error = $event" + @error="updateError = $event" /> <gl-button v-if="isModal" @@ -332,24 +370,25 @@ export default { :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" > <work-item-information - v-if="showInfoBanner" + v-if="showInfoBanner && !error" :show-info-banner="showInfoBanner" @work-item-banner-dismissed="dismissBanner" /> </local-storage-sync> <work-item-title + v-if="workItem.title" :work-item-id="workItem.id" :work-item-title="workItem.title" :work-item-type="workItemType" :work-item-parent-id="workItemParentId" :can-update="canUpdate" - @error="error = $event" + @error="updateError = $event" /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" :can-update="canUpdate" - @error="error = $event" + @error="updateError = $event" /> <work-item-assignees v-if="workItemAssignees" @@ -360,24 +399,33 @@ export default { :work-item-type="workItemType" :can-invite-members="workItemAssignees.canInviteMembers" :full-path="fullPath" - @error="error = $event" + @error="updateError = $event" + /> + <work-item-labels + v-if="workItemLabels" + :work-item-id="workItem.id" + :can-update="canUpdate" + :full-path="fullPath" + @error="updateError = $event" + /> + <work-item-due-date + v-if="workItemDueDate" + :can-update="canUpdate" + :due-date="workItemDueDate.dueDate" + :start-date="workItemDueDate.startDate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" /> <template v-if="workItemsMvc2Enabled"> - <work-item-labels - v-if="workItemLabels" + <work-item-milestone + v-if="workItemMilestone" :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.nodes[0]" + :work-item-type="workItemType" :can-update="canUpdate" :full-path="fullPath" - @error="error = $event" - /> - <work-item-due-date - v-if="workItemDueDate" - :can-update="canUpdate" - :due-date="workItemDueDate.dueDate" - :start-date="workItemDueDate.startDate" - :work-item-id="workItem.id" - :work-item-type="workItemType" - @error="error = $event" + @error="updateError = $event" /> </template> <work-item-weight @@ -387,14 +435,31 @@ export default { :weight="workItemWeight.weight" :work-item-id="workItem.id" :work-item-type="workItemType" - @error="error = $event" + @error="updateError = $event" /> + <template v-if="workItemsMvc2Enabled"> + <work-item-iteration + v-if="workItemIteration" + class="gl-mb-5" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" + /> + </template> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" :full-path="fullPath" class="gl-pt-5" - @error="error = $event" + @error="updateError = $event" + /> + <gl-empty-state + v-if="error" + :title="$options.i18n.fetchErrorTitle" + :description="error" + :svg-path="noAccessSvgPath" /> </template> </section> diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue index 05f8fa8f5e1..eae11c2bb2f 100644 --- a/app/assets/javascripts/work_items/components/work_item_due_date.vue +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -198,7 +198,7 @@ export default { label-cols="3" label-cols-lg="2" > - <span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4"> + <span v-if="isReadonlyWithNoDates" class="gl-text-secondary gl-ml-4"> {{ $options.i18n.none }} </span> <div v-else class="gl-display-flex gl-flex-wrap gl-gap-5"> diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index b8b5198be57..05077862690 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -1,16 +1,22 @@ <script> import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, uniqueId, without } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import workItemQuery from '../graphql/work_item.query.graphql'; -import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants'; +import { + i18n, + I18N_WORK_ITEM_ERROR_FETCHING_LABELS, + TRACKING_CATEGORY_SHOW, + WIDGET_TYPE_LABELS, +} from '../constants'; function isTokenSelectorElement(el) { return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); @@ -52,6 +58,8 @@ export default { localLabels: [], searchKey: '', searchLabels: [], + addLabelIds: [], + removeLabelIds: [], }; }, apollo: { @@ -68,13 +76,21 @@ export default { error() { this.$emit('error', i18n.fetchError); }, + subscribeToMore: { + document: workItemLabelsSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + }, }, searchLabels: { query: labelSearchQuery, variables() { return { fullPath: this.fullPath, - search: this.searchKey, + searchTerm: this.searchKey, }; }, skip() { @@ -84,11 +100,14 @@ export default { return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label })); }, error() { - this.$emit('error', i18n.fetchError); + this.$emit('error', I18N_WORK_ITEM_ERROR_FETCHING_LABELS); }, }, }, computed: { + labelsTitleId() { + return uniqueId('labels-title-'); + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -97,10 +116,7 @@ export default { }; }, allowScopedLabels() { - return this.labelsWidget.allowScopedLabels; - }, - listEmpty() { - return this.labels.length === 0; + return this.labelsWidget?.allowsScopedLabels; }, containerClass() { return !this.isEditing ? 'gl-shadow-none!' : ''; @@ -109,10 +125,10 @@ export default { return this.$apollo.queries.searchLabels.loading; }, labelsWidget() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, labels() { - return this.labelsWidget?.nodes || []; + return this.labelsWidget?.labels?.nodes || []; }, }, watch: { @@ -131,44 +147,74 @@ export default { }, removeLabel({ id }) { this.localLabels = this.localLabels.filter((label) => label.id !== id); + this.removeLabelIds.push(id); + this.setLabels(); }, - setLabels(event) { + async setLabels() { + if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; + this.searchKey = ''; - if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; this.isEditing = false; - this.$apollo - .mutate({ - mutation: localUpdateWorkItemMutation, + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, variables: { input: { id: this.workItemId, - labels: this.localLabels, + labelsWidget: { + addLabelIds: this.addLabelIds, + removeLabelIds: this.removeLabelIds, + }, }, }, - }) - .catch((e) => { - this.$emit('error', e); }); - this.track('updated_labels'); + + if (errors.length > 0) { + this.throwUpdateError(); + return; + } + + this.addLabelIds = []; + this.removeLabelIds = []; + + this.track('updated_labels'); + } catch { + this.throwUpdateError(); + } + }, + throwUpdateError() { + this.$emit('error', i18n.updateError); + // If mutation is rejected, we're rolling back to initial state + this.localLabels = this.labels.map(addClass); + this.addLabelIds = []; + this.removeLabelIds = []; + }, + handleBlur(event) { + if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; + this.setLabels(); }, handleFocus() { this.isEditing = true; this.searchStarted = true; }, async focusTokenSelector(labels) { - if (this.allowScopedLabels) { - const newLabel = labels[labels.length - 1]; - const existingLabels = labels.slice(0, labels.length - 1); - - const newLabelKey = scopedLabelKey(newLabel); + const labelsToAdd = without(labels, ...this.localLabels).map((label) => label.id); + const labelsToRemove = without(this.localLabels, ...labels).map((label) => label.id); - const removeLabelsWithSameScope = existingLabels.filter((label) => { - const sameKey = newLabelKey === scopedLabelKey(label); - return !sameKey; - }); + if (labelsToAdd.length > 0) { + this.addLabelIds.push(...labelsToAdd); + } - this.localLabels = [...removeLabelsWithSameScope, newLabel]; + if (labelsToRemove.length > 0) { + this.removeLabelIds.push(...labelsToRemove); } + + this.localLabels = labels; + this.handleFocus(); await this.$nextTick(); this.$refs.tokenSelector.focusTextInput(); @@ -194,13 +240,15 @@ export default { <template> <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap"> <span + :id="labelsTitleId" class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" data-testid="labels-title" >{{ __('Labels') }}</span > <gl-token-selector ref="tokenSelector" - v-model="localLabels" + :selected-tokens="localLabels" + :aria-labelledby="labelsTitleId" :container-class="containerClass" :dropdown-items="searchLabels" :loading="isLoading" @@ -210,13 +258,13 @@ export default { @input="focusTokenSelector" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" - @blur="setLabels" + @blur="handleBlur" @mouseover.native="handleMouseOver" @mouseout.native="handleMouseOut" > <template #empty-placeholder> <div - class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2" + class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-secondary gl-pr-4 gl-top-2" data-testid="empty-state" > <span v-if="canUpdate" class="gl-ml-2">{{ __('Add labels') }}</span> diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 8f31b07b6a3..37aa48be6e5 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -16,7 +16,13 @@ export default function initWorkItemLinks() { return; } - const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset; + const { + projectPath, + wiHasIssueWeightsFeature, + iid, + wiHasIterationsFeature, + projectNamespace, + } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new new Vue({ @@ -31,6 +37,8 @@ export default function initWorkItemLinks() { iid, fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, + hasIterationsFeature: wiHasIterationsFeature, + projectNamespace, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 840fd910272..0d3e951de7e 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -5,7 +5,7 @@ import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -59,7 +59,7 @@ export default { }, }, parentIssue: { - query: issueConfidentialQuery, + query: getIssueDetailsQuery, variables() { return { fullPath: this.projectPath, @@ -86,6 +86,9 @@ export default { confidential() { return this.parentIssue?.confidential || this.workItem?.confidential || false; }, + issuableIteration() { + return this.parentIssue?.iteration; + }, children() { return ( this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children @@ -257,7 +260,7 @@ export default { class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" data-testid="children-count" > - <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-gray-500" /> + <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" /> {{ childrenCountLabel }} </span> </div> @@ -294,7 +297,7 @@ export default { <template v-else> <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> - <p class="gl-mt-3 gl-mb-4"> + <p class="gl-mb-3"> {{ $options.i18n.emptyStateMessage }} </p> </div> @@ -305,6 +308,7 @@ export default { :issuable-gid="issuableGid" :children-ids="childrenIds" :parent-confidential="confidential" + :parent-iteration="issuableIteration" @cancel="hideAddForm" @addWorkItemChild="addChild" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 8b848995d44..a01f4616cab 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -16,7 +16,7 @@ export default { GlFormGroup, GlFormInput, }, - inject: ['projectPath'], + inject: ['projectPath', 'hasIterationsFeature'], props: { issuableGid: { type: String, @@ -33,6 +33,11 @@ export default { required: false, default: false, }, + parentIteration: { + type: Object, + required: false, + default: () => {}, + }, }, apollo: { workItemTypes: { @@ -77,6 +82,9 @@ export default { taskWorkItemType() { return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; }, + parentIterationId() { + return this.parentIteration?.id; + }, }, methods: { getIdFromGraphQLId, @@ -133,6 +141,13 @@ export default { } else { this.unsetError(); this.$emit('addWorkItemChild', data.workItemCreate.workItem); + /** + * call update mutation only when there is an iteration associated with the issue + */ + // TODO: setting the iteration should be moved to the creation mutation once the backend is done + if (this.parentIterationId && this.hasIterationsFeature) { + this.addIterationToWorkItem(data.workItemCreate.workItem.id); + } } }) .catch(() => { @@ -143,6 +158,19 @@ export default { this.childToCreateTitle = null; }); }, + async addIterationToWorkItem(workItemId) { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: workItemId, + iterationWidget: { + iterationId: this.parentIterationId, + }, + }, + }, + }); + }, }, i18n: { inputLabel: __('Title'), @@ -182,7 +210,7 @@ export default { > <template #result="{ item }"> <div class="gl-display-flex"> - <div class="gl-text-gray-400 gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div> + <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div> <div>{{ item.title }}</div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue new file mode 100644 index 00000000000..c4a36e36555 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -0,0 +1,248 @@ +<script> +import { + GlFormGroup, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSkeletonLoader, + GlSearchBoxByType, + GlDropdownText, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { debounce } from 'lodash'; +import Tracking from '~/tracking'; +import { s__ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + TRACKING_CATEGORY_SHOW, +} from '../constants'; + +const noMilestoneId = 'no-milestone-id'; + +export default { + i18n: { + MILESTONE: s__('WorkItem|Milestone'), + NONE: s__('WorkItem|None'), + MILESTONE_PLACEHOLDER: s__('WorkItem|Add to milestone'), + NO_MATCHING_RESULTS: s__('WorkItem|No matching results'), + NO_MILESTONE: s__('WorkItem|No milestone'), + MILESTONE_FETCH_ERROR: s__( + 'WorkItem|Something went wrong while fetching milestones. Please try again.', + ), + }, + components: { + GlFormGroup, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSkeletonLoader, + GlSearchBoxByType, + GlDropdownText, + }, + mixins: [Tracking.mixin()], + props: { + workItemId: { + type: String, + required: true, + }, + workItemMilestone: { + type: Object, + required: false, + default: () => {}, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + fullPath: { + type: String, + required: true, + }, + }, + data() { + return { + localMilestone: this.workItemMilestone, + searchTerm: '', + shouldFetch: false, + updateInProgress: false, + isFocused: false, + milestones: [], + }; + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_milestone', + property: `type_${this.workItemType}`, + }; + }, + emptyPlaceholder() { + return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE; + }, + dropdownText() { + return this.localMilestone?.title || this.emptyPlaceholder; + }, + isLoadingMilestones() { + return this.$apollo.queries.milestones.loading; + }, + isNoMilestone() { + return this.localMilestone?.id === noMilestoneId || !this.localMilestone?.id; + }, + dropdownClasses() { + return { + 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone, + 'is-not-focused': !this.isFocused, + }; + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + apollo: { + milestones: { + query: projectMilestonesQuery, + variables() { + return { + fullPath: this.fullPath, + title: this.searchTerm, + first: 20, + }; + }, + skip() { + return !this.shouldFetch; + }, + update(data) { + return data?.workspace?.attributes?.nodes || []; + }, + error() { + this.$emit('error', this.i18n.MILESTONE_FETCH_ERROR); + }, + }, + }, + methods: { + handleMilestoneClick(milestone) { + this.localMilestone = milestone; + }, + onDropdownShown() { + this.$refs.search.focusInput(); + this.shouldFetch = true; + this.isFocused = true; + }, + onDropdownHide() { + this.isFocused = false; + this.searchTerm = ''; + this.shouldFetch = false; + this.updateMilestone(); + }, + setSearchKey(value) { + this.searchTerm = value; + }, + isMilestoneChecked(milestone) { + return this.localMilestone?.id === milestone?.id; + }, + updateMilestone() { + if (this.workItemMilestone?.id === this.localMilestone?.id) { + return; + } + + this.track('updated_milestone'); + this.updateInProgress = true; + this.$apollo + .mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + milestone: { + milestoneId: this.localMilestone?.id, + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('\n')); + } + }) + .catch((error) => { + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', msg); + Sentry.captureException(error); + }) + .finally(() => { + this.updateInProgress = false; + }); + }, + }, +}; +</script> + +<template> + <gl-form-group + class="work-item-dropdown" + :label="$options.i18n.MILESTONE" + label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3" + label-cols="3" + label-cols-lg="2" + > + <span + v-if="!canUpdate" + class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal" + data-testid="disabled-text" + > + {{ dropdownText }} + </span> + <gl-dropdown + v-else + :toggle-class="dropdownClasses" + :text="dropdownText" + :loading="updateInProgress" + @shown="onDropdownShown" + @hide="onDropdownHide" + > + <template #header> + <gl-search-box-by-type ref="search" :value="searchTerm" @input="debouncedSearchKeyUpdate" /> + </template> + <gl-dropdown-item + data-testid="no-milestone" + is-check-item + :is-checked="isNoMilestone" + @click="handleMilestoneClick({ id: 'no-milestone-id' })" + > + {{ $options.i18n.NO_MILESTONE }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-text v-if="isLoadingMilestones"> + <gl-skeleton-loader :height="90"> + <rect width="380" height="10" x="10" y="15" rx="4" /> + <rect width="280" height="10" x="10" y="30" rx="4" /> + <rect width="380" height="10" x="10" y="50" rx="4" /> + <rect width="280" height="10" x="10" y="65" rx="4" /> + </gl-skeleton-loader> + </gl-dropdown-text> + <template v-else-if="milestones.length"> + <gl-dropdown-item + v-for="milestone in milestones" + :key="milestone.id" + is-check-item + :is-checked="isMilestoneChecked(milestone)" + @click="handleMilestoneClick(milestone)" + > + {{ milestone.title }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text> + </gl-dropdown> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index 31e75663055..96a6493357c 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -53,7 +53,7 @@ export default { v-gl-tooltip.hover="showTooltipOnHover" :name="iconName" :title="workItemTooltipTitle" - class="gl-mr-2 gl-text-gray-500" + class="gl-mr-2 gl-text-secondary" /> <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> </span> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 78219e62d01..7737c535650 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -17,6 +17,9 @@ export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; +export const WIDGET_TYPE_MILESTONE = 'MILESTONE'; +export const WIDGET_TYPE_ITERATION = 'ITERATION'; + export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; @@ -26,13 +29,19 @@ export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const i18n = { - fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), + fetchErrorTitle: s__('WorkItem|Work item not found'), + fetchError: s__( + "WorkItem|This work item is not available. It either doesn't exist or you don't have permission to view it.", + ), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), confidentialTooltip: s__( 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', ), }; +export const I18N_WORK_ITEM_ERROR_FETCHING_LABELS = s__( + 'WorkItem|Something went wrong when fetching labels. Please try again.', +); export const I18N_WORK_ITEM_ERROR_CREATING = s__( 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.', ); @@ -48,6 +57,10 @@ export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__( ); export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted'); +export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( + 'WorkItem|Something went wrong when fetching iterations. Please try again.', +); + export const sprintfWorkItem = (msg, workItemTypeArg) => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( diff --git a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql new file mode 100644 index 00000000000..6edb6c89f16 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql @@ -0,0 +1,9 @@ +query issuableDetails($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + confidential + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 36ffba8a540..36779dfe11e 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,6 +1,6 @@ enum LocalWidgetType { ASSIGNEES - LABELS + MILESTONE } interface LocalWorkItemWidget { @@ -12,10 +12,9 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget { nodes: [UserCore] } -type LocalWorkItemLabels implements LocalWorkItemWidget { +type LocalWorkItemMilestone implements LocalWorkItemWidget { type: LocalWidgetType! - allowScopedLabels: Boolean! - nodes: [Label!] + nodes: [Milestone!] } extend type WorkItem { @@ -30,17 +29,14 @@ input LocalUserInput { avatarUrl: String } -input LocalLabelInput { - id: ID! - title: String! - color: String - description: String +input LocalMilestoneInput { + milestoneId: ID! } input LocalUpdateWorkItemInput { id: WorkItemID! assignees: [LocalUserInput!] - labels: [LocalLabelInput] + milestone: LocalMilestoneInput! } type LocalWorkItemPayload { 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 f4c77ed2ec0..bb05c9b2135 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,4 +1,3 @@ -#import "~/graphql_shared/fragments/user.fragment.graphql" #import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql" fragment WorkItem on WorkItem { diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 276061af193..fa0ab56df75 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,15 +1,16 @@ -#import "~/graphql_shared/fragments/label.fragment.graphql" #import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem mockWidgets @client { - ... on LocalWorkItemLabels { + ... on LocalWorkItemMilestone { type - allowScopedLabels nodes { - ...Label + id + title + expired + dueDate } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql new file mode 100644 index 00000000000..d5b2de8c4c6 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql @@ -0,0 +1,21 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +subscription issuableAssignees($issuableId: IssuableID!) { + issuableAssigneesUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + widgets { + ... on WorkItemWidgetAssignees { + type + allowsMultipleAssignees + canInviteMembers + assignees { + nodes { + ...User + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql index 7e045fdf431..d8760f147e1 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql @@ -4,6 +4,7 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) { id widgets { ... on WorkItemWidgetStartAndDueDate { + type dueDate startDate } diff --git a/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql new file mode 100644 index 00000000000..86d936bf4dd --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql @@ -0,0 +1,19 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +subscription workItemLabels($issuableId: IssuableID!) { + issuableLabelsUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + widgets { + ... on WorkItemWidgetLabels { + type + labels { + nodes { + ...Label + } + } + } + } + } + } +} 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 3005069f59a..d404cfb10ed 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 @@ -1,8 +1,16 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { type description descriptionHtml + lastEditedAt + lastEditedBy { + name + webPath + } } ... on WorkItemWidgetAssignees { type @@ -14,6 +22,14 @@ fragment WorkItemWidgets on WorkItemWidget { } } } + ... on WorkItemWidgetLabels { + type + labels { + nodes { + ...Label + } + } + } ... on WorkItemWidgetStartAndDueDate { type dueDate diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index bb4c7052238..f872d8c6b12 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -6,7 +6,13 @@ import { createRouter } from './router'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath, hasIssueWeightsFeature, issuesListPath } = el.dataset; + const { + fullPath, + hasIssueWeightsFeature, + issuesListPath, + projectNamespace, + hasIterationsFeature, + } = el.dataset; return new Vue({ el, @@ -17,6 +23,8 @@ export const initWorkItemsRoot = () => { fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), issuesListPath, + projectNamespace, + hasIterationsFeature: parseBoolean(hasIterationsFeature), }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 3b7257591e2..4908b99e5b0 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -6,7 +6,6 @@ import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; -import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import ItemTitle from '../components/item_title.vue'; @@ -29,26 +28,6 @@ export default { required: false, default: '', }, - issueGid: { - type: String, - required: false, - default: '', - }, - lockVersion: { - type: Number, - required: false, - default: null, - }, - lineNumberStart: { - type: String, - required: false, - default: null, - }, - lineNumberEnd: { - type: String, - required: false, - default: null, - }, }, data() { return { @@ -136,28 +115,6 @@ export default { this.error = this.createErrorText; } }, - async createWorkItemFromTask() { - try { - const { data } = await this.$apollo.mutate({ - mutation: createWorkItemFromTaskMutation, - variables: { - input: { - id: this.issueGid, - workItemData: { - lockVersion: this.lockVersion, - title: this.title, - lineNumberStart: Number(this.lineNumberStart), - lineNumberEnd: Number(this.lineNumberEnd), - workItemTypeId: this.selectedWorkItemType, - }, - }, - }, - }); - this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml); - } catch { - this.error = this.createErrorText; - } - }, handleTitleInput(title) { this.title = title; }, diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 9e81e1d4771..21d9db26382 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,13 +1,9 @@ @import './pages/branches'; -@import './pages/clusters'; @import './pages/colors'; @import './pages/commits'; -@import './pages/deploy_keys'; @import './pages/detail_page'; -@import './pages/environment_logs'; @import './pages/events'; @import './pages/groups'; -@import './pages/help'; @import './pages/hierarchy'; @import './pages/issuable'; @import './pages/issues'; @@ -21,11 +17,8 @@ @import './pages/pipelines'; @import './pages/profile'; @import './pages/projects'; -@import './pages/prometheus'; @import './pages/registry'; @import './pages/search'; -@import './pages/service_desk'; @import './pages/settings'; @import './pages/storage_quota'; -@import './pages/tree'; @import './pages/users'; diff --git a/app/assets/stylesheets/bootstrap_migration_reset.scss b/app/assets/stylesheets/bootstrap_migration_reset.scss index ad315c4ada1..fb112a2ee84 100644 --- a/app/assets/stylesheets/bootstrap_migration_reset.scss +++ b/app/assets/stylesheets/bootstrap_migration_reset.scss @@ -54,10 +54,6 @@ strong { font-weight: bold; } -a { - color: $blue-600; -} - hr { overflow: hidden; } diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss deleted file mode 100644 index 5e1128dc4ce..00000000000 --- a/app/assets/stylesheets/components/batch_comments/review_bar.scss +++ /dev/null @@ -1,71 +0,0 @@ -.review-bar-component { - position: fixed; - bottom: 0; - left: 0; - z-index: $zindex-dropdown-menu; - display: flex; - align-items: center; - width: 100%; - height: $toggle-sidebar-height; - padding-left: $contextual-sidebar-width; - padding-right: $gutter_collapsed_width; - background: $white; - border-top: 1px solid $border-color; - transition: padding $gl-transition-duration-medium; - - .page-with-icon-sidebar & { - padding-left: $contextual-sidebar-collapsed-width; - } - - .right-sidebar-expanded & { - padding-right: $gutter_width; - } - - @media (max-width: map-get($grid-breakpoints, sm)-1) { - padding-left: 0; - padding-right: 0; - } - - .dropdown { - margin-left: $grid-size; - } -} - -.review-bar-content { - max-width: $limited-layout-width; - padding: 0 $gl-padding; - width: 100%; - margin: 0 auto; -} - -.review-preview-item-header { - display: flex; - align-items: center; - width: 100%; - margin-bottom: 4px; - - > .bold { - display: flex; - min-width: 0; - line-height: 16px; - } -} - -.review-preview-item-footer { - display: flex; - align-items: center; - margin-top: 4px; -} - -.review-preview-item-content { - width: 100%; - - p { - display: block; - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-bottom: 0; - } -} diff --git a/app/assets/stylesheets/components/date_time_picker.scss b/app/assets/stylesheets/components/date_time_picker.scss deleted file mode 100644 index 21f085cdaf1..00000000000 --- a/app/assets/stylesheets/components/date_time_picker.scss +++ /dev/null @@ -1,5 +0,0 @@ -.date-time-picker { - .date-time-picker-menu { - width: 400px; - } -} diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss deleted file mode 100644 index b8bd1000bfd..00000000000 --- a/app/assets/stylesheets/components/design_management/design.scss +++ /dev/null @@ -1,193 +0,0 @@ -$design-pin-diameter: 28px; -$design-pin-diameter-sm: 24px; -$t-gray-a-16-design-pin: rgba($black, 0.16); - -.layout-page.design-detail-layout { - max-height: 100vh; -} - -.design-detail { - background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity); - - .with-performance-bar & { - top: 35px; - } - - .comment-indicator { - border-radius: 50%; - } - - .comment-indicator, - .frame .design-note-pin { - &:active { - cursor: grabbing; - } - } -} - -.design-scaler-wrapper { - bottom: 0; - left: 50%; - transform: translateX(-50%); -} - -.design-checkbox { - position: absolute; - top: $gl-padding; - left: 30px; -} - -.image-notes { - overflow-y: scroll; - padding: $gl-padding; - padding-top: 50px; - background-color: $white; - flex-shrink: 0; - min-width: 400px; - flex-basis: 28%; - - .link-inherit-color { - &:hover, - &:active, - &:focus { - color: inherit; - text-decoration: none; - } - } - - .toggle-comments { - line-height: 20px; - border-top: 1px solid $border-color; - - &.expanded { - border-bottom: 1px solid $border-color; - } - - .toggle-comments-button:focus { - text-decoration: none; - color: $blue-600; - } - } - - .design-note-pin { - margin-left: $gl-padding; - } - - .design-discussion { - margin: $gl-padding 0; - - &::before { - content: ''; - border-left: 1px solid $gray-100; - position: absolute; - left: 28px; - top: -17px; - height: 17px; - } - - .design-note { - padding: $gl-padding; - list-style: none; - transition: background $gl-transition-duration-medium $general-hover-transition-curve; - border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box - border-top-right-radius: $border-radius-default; - - a { - color: inherit; - } - - .note-text a { - color: $blue-600; - } - } - - .reply-wrapper { - padding: $gl-padding; - } - } - - .reply-wrapper { - border-top: 1px solid $border-color; - } - - .new-discussion-disclaimer { - line-height: 20px; - } -} - -@media (max-width: map-get($grid-breakpoints, lg)) { - .design-detail { - overflow-y: scroll; - } - - .image-notes { - overflow-y: auto; - min-width: 100%; - flex-grow: 1; - flex-basis: auto; - } -} - -.design-card-header { - background: transparent; -} - -.design-note-pin { - display: flex; - height: $design-pin-diameter; - width: $design-pin-diameter; - box-sizing: content-box; - background-color: $purple-500; - color: $white; - font-weight: $gl-font-weight-bold; - border-radius: 50%; - z-index: 1; - padding: 0; - border: 0; - - &.draft { - background-color: $orange-500; - } - - &.resolved { - background-color: $gray-500; - } - - &.on-image { - box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24; - border: $white 2px solid; - will-change: transform, box-shadow, opacity; - // NOTE: verbose transition property required for Safari - transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear; - transform-origin: 0 0; - transform: translate(-50%, -50%); - - &:hover { - transform: scale(1.2) translate(-50%, -50%); - } - - &:active { - box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin; - } - - &.inactive { - @include gl-opacity-5; - - &:hover { - @include gl-opacity-10; - } - } - } - - &.small { - position: absolute; - border: 1px solid $white; - height: $design-pin-diameter-sm; - width: $design-pin-diameter-sm; - } - - &.user-avatar { - top: 25px; - right: 8px; - } -} diff --git a/app/assets/stylesheets/components/design_management/design_list_item.scss b/app/assets/stylesheets/components/design_management/design_list_item.scss deleted file mode 100644 index 09af4da37e9..00000000000 --- a/app/assets/stylesheets/components/design_management/design_list_item.scss +++ /dev/null @@ -1,19 +0,0 @@ -.design-list-item { - height: 280px; - text-decoration: none; - - .icon-version-status { - position: absolute; - right: 10px; - top: 10px; - } - - .card-body { - height: 230px; - } -} - -// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197 -.design-list-item-new { - height: 210px; -} diff --git a/app/assets/stylesheets/components/feature_highlight.scss b/app/assets/stylesheets/components/feature_highlight.scss deleted file mode 100644 index 4d301cc5617..00000000000 --- a/app/assets/stylesheets/components/feature_highlight.scss +++ /dev/null @@ -1,5 +0,0 @@ -.gl-sm-mr-3 { - @media (min-width: $breakpoint-sm) { - @include gl-mr-3; - } -} diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss deleted file mode 100644 index 94d295c324b..00000000000 --- a/app/assets/stylesheets/components/milestone_combobox.scss +++ /dev/null @@ -1,5 +0,0 @@ -.milestone-combobox { - .dropdown-menu.show { - overflow: hidden; - } -} diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 3bb889b6ba0..293caf6fc87 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -75,19 +75,6 @@ $item-remove-button-space: 42px; } } -.item-body, -.card-header { - .health-label-short { - max-width: 0; - } -} - -.card-header { - .health-label-short { - display: initial; - } -} - .item-meta { flex-basis: 100%; font-size: $gl-font-size; @@ -212,11 +199,6 @@ $item-remove-button-space: 42px; max-width: 90%; } - .card-header { - .health-label-short { - max-width: 30px; - } - } } /* Small devices (landscape phones, 768px and up) */ @@ -239,11 +221,6 @@ $item-remove-button-space: 42px; } } - .card-header { - .health-label-short { - max-width: 60px; - } - } } /* Medium devices (desktops, 992px and up) */ @@ -257,12 +234,6 @@ $item-remove-button-space: 42px; font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small` } } - - .card-header { - .health-label-short { - max-width: 100px; - } - } } /* Large devices (large desktops, 1200px and up) */ @@ -309,15 +280,3 @@ $item-remove-button-space: 42px; flex-basis: auto; } } - -@media only screen and (min-width: 1500px) { - .card-header { - .health-label-short { - display: none; - } - - .health-label-long { - display: block; - } - } -} diff --git a/app/assets/stylesheets/components/release_block.scss b/app/assets/stylesheets/components/release_block.scss deleted file mode 100644 index 7e82d0960d7..00000000000 --- a/app/assets/stylesheets/components/release_block.scss +++ /dev/null @@ -1,3 +0,0 @@ -.release-block { - transition: background-color 1s linear; -} diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/components/shortcuts_help.scss index 9182292ffd3..ea2281538b4 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/components/shortcuts_help.scss @@ -27,14 +27,3 @@ } } } - -.documentation { - padding: 7px; - font-size: $gl-font-size-large; -} - -.card.links-card { - a { - color: $blue-600; - } -} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index e977fb92928..07db6b3c147 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -42,7 +42,6 @@ @import 'framework/notes'; @import 'framework/tabs'; @import 'framework/timeline'; -@import 'framework/toggle'; @import 'framework/typography'; @import 'framework/zen'; @import 'framework/wells'; @@ -54,14 +53,11 @@ @import 'framework/emojis'; @import 'framework/icons'; @import 'framework/snippets'; -@import 'framework/memory_graph'; @import 'framework/responsive_tables'; @import 'framework/stacked_progress_bar'; @import 'framework/sortable'; -@import 'framework/ci_variable_list'; @import 'framework/feature_highlight'; @import 'framework/read_more'; -@import 'framework/flex_grid'; @import 'framework/system_messages'; @import 'framework/spinner'; @import 'framework/card'; diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index f947042ba51..799777977ed 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -66,7 +66,6 @@ } &.content-component-block { - padding: 8px 0; background-color: $body-bg; } diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index b1e5ca50a8b..e69d7b4462d 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -1,9 +1,17 @@ .user-contrib-cell { + stroke: $t-gray-a-08; + &:hover { cursor: pointer; stroke: $black; } + &:focus { + @include gl-outline-none; + stroke: $white; + filter: drop-shadow(1px 0 0.5px $blue-400) drop-shadow(0 1px 0.5px $blue-400) drop-shadow(-1px 0 0.5px $blue-400) drop-shadow(0 -1px 0.5px $blue-400); + } + // `app/assets/javascripts/pages/users/activity_calendar.js` sets this attribute @for $i from 1 through length($calendar-activity-colors) { $color: nth($calendar-activity-colors, $i); diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss deleted file mode 100644 index ef4355ad157..00000000000 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ /dev/null @@ -1,77 +0,0 @@ -.ci-variable-list { - margin-left: 0; - margin-bottom: 0; - padding-left: 0; - list-style: none; - clear: both; -} - -.ci-variable-row { - display: flex; - align-items: flex-start; - - @include media-breakpoint-down(xs) { - align-items: flex-end; - } - - &:not(:last-child) { - margin-bottom: $gl-btn-padding; - - @include media-breakpoint-down(xs) { - margin-bottom: 3 * $gl-btn-padding; - } - } - - &:last-child { - .ci-variable-body-item:last-child { - margin-right: $ci-variable-remove-button-width; - - @include media-breakpoint-down(xs) { - margin-right: 0; - } - } - - .ci-variable-row-remove-button { - display: none; - } - - @include media-breakpoint-down(xs) { - .ci-variable-row-body { - margin-right: $ci-variable-remove-button-width; - } - } - } -} - -.ci-variable-row-body { - display: flex; - align-items: flex-start; - width: 100%; - padding-bottom: $gl-padding; - - @include media-breakpoint-down(xs) { - display: block; - } -} - -.ci-variable-body-item { - flex: 1; - - &:not(:last-child) { - margin-right: $gl-btn-padding; - - @include media-breakpoint-down(xs) { - margin-right: 0; - margin-bottom: $gl-btn-padding; - } - } -} - -.ci-variable-masked-item, -.ci-variable-protected-item { - flex: 0 1 auto; - display: flex; - align-items: center; - padding-top: 5px; - padding-bottom: 5px; -} diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 8d1fb5eb55f..f7cd5d7e183 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -43,7 +43,7 @@ z-index: 120; &.is-sidebar-moved { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px}); + --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px}); } .with-system-header & { @@ -578,78 +578,6 @@ table.code { } } -// Merge request diff grid layout -.diff-grid { - .diff-td { - // By default min-width is auto with 1fr which causes some overflow problems - // https://gitlab.com/gitlab-org/gitlab/-/issues/296222 - min-width: 0; - } - - .diff-grid-row { - display: grid; - grid-template-columns: 1fr 1fr; - - &.diff-grid-row-full { - grid-template-columns: 1fr; - } - } - - .diff-grid-left, - .diff-grid-right { - display: grid; - // Zero width column is a placeholder for the EE inline code quality diff - // see ee/.../diffs.scss for more details - grid-template-columns: 50px 8px 0 1fr; - } - - .diff-grid-2-col { - grid-template-columns: 100px 1fr !important; - - &.parallel { - grid-template-columns: 50px 1fr !important; - } - } - - .diff-grid-comments { - display: grid; - grid-template-columns: 1fr 1fr; - } - - .diff-grid-drafts { - display: grid; - grid-template-columns: 1fr 1fr; - } - - &.inline-diff-view { - .diff-grid-comments { - display: grid; - grid-template-columns: 1fr; - } - - .diff-grid-drafts { - display: grid; - grid-template-columns: 1fr; - } - - .diff-grid-row { - grid-template-columns: 1fr; - } - - .diff-grid-left, - .diff-grid-right { - // Zero width column is a placeholder for the EE inline code quality diff - // see ee/../diffs.scss for more details - grid-template-columns: 50px 50px 8px 0 1fr; - } - } -} - -// Merge request diff grid layout overrides -.diff-table.code .diff-tr.line_holder .diff-td.line_content.parallel { - width: unset; -} - .diff-stats { align-items: center; padding: 0 1rem; @@ -730,68 +658,6 @@ table.code { } } -.diff-comment-avatar-holders { - position: absolute; - margin-left: -$gl-padding; - z-index: 100; - @include code-icon-size(); - - &:hover { - .diff-comment-avatar, - .diff-comments-more-count { - @for $i from 1 through 4 { - $x-pos: 14px; - - &:nth-child(#{$i}) { - @if $i == 4 { - $x-pos: 14.5px; - } - - transform: translateX((($i * $x-pos) - $x-pos)); - - &:hover { - transform: translateX((($i * $x-pos) - $x-pos)); - } - } - } - } - - .diff-comments-more-count { - padding-left: 2px; - padding-right: 2px; - width: auto; - } - } -} - -.diff-comment-avatar, -.diff-comments-more-count { - position: absolute; - left: 0; - margin-right: 0; - border-color: $white; - cursor: pointer; - transition: all 0.1s ease-out; - @include code-icon-size(); - - @for $i from 1 through 4 { - &:nth-child(#{$i}) { - z-index: (4 - $i); - } - } - - .avatar { - @include code-icon-size(); - } -} - -.diff-comments-more-count { - padding-left: 0; - padding-right: 0; - overflow: hidden; - @include code-icon-size(); -} - .diff-comments-more-count, .diff-notes-collapse, .diff-codequality-collapse { @@ -867,70 +733,6 @@ table.code { } } - -.diff-file-changes { - max-width: 560px; - width: 100%; - z-index: 150; - min-height: $dropdown-min-height; - max-height: $dropdown-max-height; - overflow-y: auto; - margin-bottom: 0; - - @include media-breakpoint-up(sm) { - left: $gl-padding; - } - - .dropdown-input .dropdown-input-search { - pointer-events: all; - } - - .diff-changed-file { - display: flex; - padding-top: 8px; - padding-bottom: 8px; - min-width: 0; - } - - .diff-file-changed-icon { - margin-top: 2px; - } - - .diff-changed-file-content { - display: flex; - flex-direction: column; - min-width: 0; - } - - .diff-changed-file-name, - .diff-changed-blank-file-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .diff-changed-blank-file-name { - color: $gl-text-color-tertiary; - font-style: italic; - } - - .diff-changed-file-path { - color: $gl-text-color-tertiary; - } - - .diff-changed-stats { - margin-left: auto; - white-space: nowrap; - } -} - -.diff-file-changes-path { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .note-container { background-color: $gray-light; border-top: 1px solid $white-normal; @@ -1007,27 +809,6 @@ table.code { } } -// Notes tweaks for the Changes tab ONLY -.diff-tr { - .timeline-discussion-body { - clear: left; - - .note-body { - margin-top: 0 !important; - } - } - - .timeline-entry img.avatar { - margin-top: -2px; - margin-right: $gl-padding-8; - } - - // tiny adjustment to vertical align with the note header text - .discussion-collapsible .timeline-icon { - padding-top: 2px; - } -} - .files:not([data-can-create-note]) .frame { cursor: auto; } @@ -1097,6 +878,7 @@ table.code { .discussion-notes { min-height: 35px; + background-color: transparent; &:first-child { // First child does not have the jagged borders @@ -1121,6 +903,17 @@ table.code { display: none; } } + + ul.notes { + li.toggle-replies-widget, + .discussion-reply-holder { + margin-left: 2.5rem; + + .reply-author-avatar { + height: 1.5rem; + } + } + } } .discussion-body .image .frame { @@ -1183,9 +976,15 @@ table.code { bottom: 100vh; } -.diff-line-expand-button { - &:hover, - &:focus { - @include gl-bg-gray-200; +.diff-grid-row.expansion.match { + border-top: 1px solid var(--diff-expansion-background-color); + border-bottom: 1px solid var(--diff-expansion-background-color); + + &:first-child { + border-top: 0; + } + + &:last-child { + border-bottom: 0; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index d91524d99e6..d561a7d9450 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -469,6 +469,7 @@ .sidebar-participant { .merge-icon { top: calc(50% + 5px); + left: 3rem; } } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index b51daf0e4dc..b63365e8159 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -46,12 +46,14 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); .flash-notice, .flash-success, .flash-warning { - padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4); - margin-top: 10px; - - .container-fluid, - .container-fluid.container-limited { - background: transparent; + &:not(.gl-alert) { + padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4); + margin-top: 10px; + + .container-fluid, + .container-fluid.container-limited { + background: transparent; + } } } @@ -79,6 +81,19 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); .gl-alert { @include gl-my-4; } + + &.flash-container-no-margin { + .gl-alert { + @include gl-my-0; + } + + .flash-alert, + .flash-notice, + .flash-success, + .flash-warning { + @include gl-mt-0; + } + } } @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/framework/flex_grid.scss b/app/assets/stylesheets/framework/flex_grid.scss deleted file mode 100644 index 10537fd5549..00000000000 --- a/app/assets/stylesheets/framework/flex_grid.scss +++ /dev/null @@ -1,52 +0,0 @@ -.flex-grid { - .grid-row { - border-bottom: 1px solid $border-color; - padding: 0; - - &:last-child { - border-bottom: 0; - } - - @include media-breakpoint-down(md) { - border-bottom: 0; - border-right: 1px solid $border-color; - - &:last-child { - border-right: 0; - } - } - - @include media-breakpoint-down(xs) { - border-right: 0; - border-bottom: 1px solid $border-color; - - &:last-child { - border-bottom: 0; - } - } - } - - .grid-cell { - padding: 10px $gl-padding; - border-right: 1px solid $border-color; - - &:last-child { - border-right: 0; - } - - @include media-breakpoint-up(md) { - flex: 1; - } - - @include media-breakpoint-down(md) { - border-right: 0; - flex: none; - } - } -} - -.card { - .card-body.flex-grid { - padding: 0; - } -} diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 40e11b50eba..66d163f608a 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -8,13 +8,15 @@ font-size: 95%; } -.gfm-project_member { +.gfm-project_member, +.md a.gfm-project_member { padding: 0 2px; background-color: $blue-100; border-radius: $border-radius-default; + color: $blue-700; &.current-user { - background-color: $orange-50; + background-color: $orange-100; } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index d2bb1e3d555..e9a507ebb6b 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -109,12 +109,6 @@ ul.content-list { color: $gl-text-color; word-break: break-word; - &.no-description { - .title { - line-height: $list-text-height; - } - } - .title { font-weight: $gl-font-weight-bold; } @@ -221,6 +215,7 @@ ul.content-list { } } +ul.content-list.content-list-items-padding > li, ul.content-list.issuable-list > li, ul.content-list.todos-list > li, .card > .content-list > li { diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index b623f18c4ae..c40cadafb9c 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -44,12 +44,6 @@ } } -.div-dropzone-alert { - margin-top: 5px; - margin-bottom: 0; - transition: opacity 200ms ease-in-out; -} - .md-header { .nav-links { a { @@ -155,8 +149,16 @@ .md-suggestion-diff { display: table !important; border: 1px solid $border-color !important; - width: 100% !important; - font-family: $monospace-font !important; + + td { + border: 0 !important; + } + + tr.old { + td { + border-radius: 0 !important; + } + } } .suggestions.md > .markdown-code-block { @@ -164,23 +166,12 @@ } .md-suggestion-header { - height: $suggestion-header-height; display: flex; align-items: center; justify-content: space-between; background-color: $gray-light; border: 1px solid $border-color; - padding: $gl-padding; border-radius: $border-radius-default $border-radius-default 0 0; - - svg { - vertical-align: middle; - margin-bottom: 3px; - } - - .dropdown-chevron { - margin-bottom: 0; - } } @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss deleted file mode 100644 index 510969e149a..00000000000 --- a/app/assets/stylesheets/framework/memory_graph.scss +++ /dev/null @@ -1,4 +0,0 @@ -.memory-graph-container { - background: $white; - border: 1px solid $gray-100; -} diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index f39d53c5b1c..8b2a494527b 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -4,22 +4,6 @@ } table { - /* - * TODO - * This is a temporary workaround until we fix the neutral - * color palette in https://gitlab.com/gitlab-org/gitlab/-/issues/213570 - * - * The overwrites here affected the following areas: - * - The subscription seats table. When removing this code, the .seats-table - * <th> and margin overrides should be removed there. - * - * Remove this code as soon as this happens - * - */ - &.gl-table { - @include gl-text-gray-500; - } - &.table { .thead-white { th { diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 43effbdd7d7..32e9bba8712 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -28,15 +28,9 @@ .timeline-entry { color: $gl-text-color; - // [dark-theme]: only give background color to actual notes - // in the timeline, the note form textarea has a background - // of it's own - &:not(.note-form) { - background-color: $white; - } - - &:not(.note-form).internal-note { - background-color: $orange-50; + &:not(.note-form).internal-note .timeline-content, + &:not(.note-form).draft-note .timeline-content { + background-color: $orange-50 !important; } .timeline-entry-inner { @@ -45,23 +39,15 @@ &:target, &.target { - background: $line-target-blue; + .timeline-content { + background: $line-target-blue !important; + } &.system-note .note-body .note-text.system-note-commit-list::after { background: linear-gradient(rgba($line-target-blue, 0.1) -100px, $line-target-blue 100%); } } - img.avatar { - margin-right: $gl-padding-12; - - @include media-breakpoint-down(sm) { - width: $gl-spacing-scale-6; - height: $gl-spacing-scale-6; - margin-right: $gl-padding-8; - } - } - .controls { padding-top: 10px; float: right; diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss deleted file mode 100644 index fd888fdec65..00000000000 --- a/app/assets/stylesheets/framework/toggle.scss +++ /dev/null @@ -1,131 +0,0 @@ -/** -* Toggle button -* -* @usage -* ### Active and Inactive text should be provided as data attributes: -* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled"> -* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span> -* </button> - -* ### Checked should have `is-checked` class -* <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled"> -* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span> -* </button> - -* ### Disabled should have `is-disabled` class -* <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true"> -* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span> -* </button> - -* ### Loading should have `is-loading` and an icon with `loading-icon` class -* <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled"> -* <span class="gl-spinner loading-icon" aria-label="Loading"></span> -* </button> -*/ -.project-feature-toggle { - position: relative; - border: 0; - outline: 0; - display: block; - width: 50px; - height: 24px; - cursor: pointer; - user-select: none; - background: $gray-400; - border-radius: 12px; - padding: 3px; - transition: all 0.4s ease; - - &::selection, - &::before::selection, - &::after::selection { - background: none; - } - - &:focus { - outline: none; - } - - .toggle-icon { - position: relative; - display: block; - left: 0; - border-radius: 9px; - background: $white; - transition: all 0.2s ease; - width: $default-icon-size; - height: $default-icon-size; - } - - .loading-icon { - display: none; - font-size: 12px; - color: $white; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - - &.is-loading { - .loading-icon { - display: block; - - &::before { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - } - } - - &.is-checked { - background: $blue-400; - - .toggle-icon { - left: calc(100% - 18px); - } - } - - &.is-checked .toggle-icon .toggle-status-checked, - .toggle-icon .toggle-status-unchecked { - display: inline; - } - - &.is-checked .toggle-icon .toggle-status-unchecked, - &.is-loading .toggle-icon, - .toggle-icon .toggle-status-checked { - display: none; - } - - &.is-disabled { - opacity: 0.4; - cursor: not-allowed; - } - - @include media-breakpoint-down(xs) { - width: 50px; - - &::before, - &.is-checked::before { - display: none; - } - } - - @keyframes animate-enabled { - 0%, - - 35% { opacity: 0; } - - 100% { opacity: 1; } - } - - @keyframes animate-disabled { - 0%, - - 35% { opacity: 0; } - - 100% { opacity: 1; } - } -} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index e79fb843967..2c2d8a2b592 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -33,14 +33,6 @@ } } - a { - color: $blue-600; - - > code { - color: $blue-600; - } - } - .media-container { display: inline-flex; flex-direction: column; @@ -717,10 +709,6 @@ textarea.js-gfm-input { font-size: $gl-font-size-monospace; } -.strikethrough { - text-decoration: line-through; -} - h1, h2, h3, diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index bd32a817d5d..9cfc5a0201e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -382,6 +382,8 @@ $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: $white; $gl-text-color-secondary-inverted: rgba($white, 0.85); $gl-text-color-disabled: $gray-400; +$link-color: $blue-500 !default; +$link-hover-color: $blue-500 !default; $gl-grayish-blue: #7f8fa4; $gl-header-color: #4c4e54; $gl-font-size-12: 12px; @@ -440,7 +442,6 @@ $browser-scrollbar-size: 10px; $header-height: var(--header-height, 48px); $header-zindex: 1000; $zindex-dropdown-menu: 300; -$suggestion-header-height: 46px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; @@ -650,14 +651,6 @@ $calendar-border-color: rgba(#000, 0.1); $calendar-user-contrib-text: #959494; /* - * Value Stream Analytics - */ -$cycle-analytics-box-padding: 30px; -$cycle-analytics-box-text-color: #8c8c8c; -$cycle-analytics-big-font: 19px; -$cycle-analytics-dismiss-icon-color: #b2b2b2; - -/* * CI */ $ci-skipped-color: #888; @@ -717,11 +710,11 @@ $job-arrow-margin: 55px; */ // See https://gitlab.com/gitlab-org/gitlab/-/issues/332150 to align with Pajamas Design System $calendar-activity-colors: ( - #ededed, - #acd5f2, - #7fa8c9, - #527ba0, - #254e77, + #f5f5f5, + #d4dcfa, + #748eff, + #3547de, + #11118a, ) !default; /* diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index cfd215b81b8..cb9c623c8fc 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -70,15 +70,6 @@ } } -.light-well { - background-color: $gray-light; - padding: 15px; -} - -.dark-well { - background-color: $gray-normal; -} - .card.card-body-centered { h1 { font-weight: $gl-font-weight-normal; diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 5e6e10e44be..7fb2bf9a875 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -175,6 +175,8 @@ $dark-il: #de935f; } &.diff-grid-row { + --diff-expansion-background-color: #{$gray-600}; + @include dark-diff-expansion-line; } diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 19c3d6926e7..66cada9181c 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -168,6 +168,8 @@ $monokai-gh: #75715e; } &.diff-grid-row { + --diff-expansion-background-color: #{$gray-600}; + @include dark-diff-expansion-line; } diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 4c716d20ddf..fa1f7211b3e 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -76,6 +76,10 @@ @include match-line; } + &.diff-grid-row { + --diff-expansion-background-color: #{$gray-100}; + } + .line-coverage { @include line-coverage-border-color($green-500, $orange-500); } diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 70086be1606..a1bba8720a2 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -171,6 +171,8 @@ $solarized-dark-il: #2aa198; } &.diff-grid-row { + --diff-expansion-background-color: #{lighten($solarized-dark-pre-bg, 10%)}; + @include dark-diff-expansion-line; } diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 8d223d1fdb1..33945f7cda9 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -156,6 +156,10 @@ $solarized-light-il: #2aa198; @include match-line; } + &.diff-grid-row { + --diff-expansion-background-color: #{$gray-100}; + } + &.diff-grid-row.expansion .diff-td { background-color: $solarized-light-matchline-bg; } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 9761e3961dd..816aa88cfde 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -154,6 +154,8 @@ pre.code, } &.diff-grid-row { + --diff-expansion-background-color: #{$gray-100}; + @include diff-match-line; } diff --git a/app/assets/stylesheets/lazy_bundles/gridstack.scss b/app/assets/stylesheets/lazy_bundles/gridstack.scss new file mode 100644 index 00000000000..235b225d747 --- /dev/null +++ b/app/assets/stylesheets/lazy_bundles/gridstack.scss @@ -0,0 +1 @@ +@import 'gridstack/dist/gridstack'; diff --git a/app/assets/stylesheets/page_bundles/admin/geo_nodes.scss b/app/assets/stylesheets/page_bundles/admin/geo_nodes.scss new file mode 100644 index 00000000000..b0aaa48569a --- /dev/null +++ b/app/assets/stylesheets/page_bundles/admin/geo_nodes.scss @@ -0,0 +1,45 @@ +@import '../mixins_and_variables_and_functions'; + +.geo-node-header-grid-columns { + grid-template-columns: 1fr auto; + grid-gap: $gl-spacing-scale-5; + + @include media-breakpoint-up(md) { + grid-template-columns: 3fr 1fr; + } +} + +.geo-node-details-grid-columns { + grid-gap: $gl-spacing-scale-5; + + @include media-breakpoint-up(lg) { + grid-template-columns: 1fr 3fr; + } +} + +.geo-node-core-details-grid-columns { + grid-template-columns: 1fr 1fr; + grid-gap: $gl-spacing-scale-5; +} + +.geo-node-replication-details-grid-columns { + grid-template-columns: 1fr 1fr; + grid-gap: 1rem; + + @include media-breakpoint-up(md) { + grid-template-columns: 1fr 1fr 2fr 2fr; + } +} + +.geo-node-filter-grid-columns { + grid-template-columns: 1fr; + + @include media-breakpoint-up(md) { + grid-template-columns: 3fr 1fr; + } +} + +.geo-node-replication-counts-grid { + grid-template-columns: 2fr 1fr 1fr; + grid-gap: 1rem; +} diff --git a/app/assets/stylesheets/page_bundles/admin/geo_replicable.scss b/app/assets/stylesheets/page_bundles/admin/geo_replicable.scss new file mode 100644 index 00000000000..691d4abd195 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/admin/geo_replicable.scss @@ -0,0 +1,18 @@ +@import '../mixins_and_variables_and_functions'; + +.geo-replicable-item-grid { + grid-template-columns: 8ch 1fr auto; + grid-gap: 1rem; +} + +.geo-replicable-filter-grid { + grid-template-columns: 1fr; + + @include media-breakpoint-up(md) { + grid-template-columns: 2fr 1fr; + } + + @include media-breakpoint-up(xl) { + grid-template-columns: 1fr 1fr; + } +} diff --git a/app/assets/stylesheets/page_bundles/cluster_agents.scss b/app/assets/stylesheets/page_bundles/cluster_agents.scss new file mode 100644 index 00000000000..d1fab55738f --- /dev/null +++ b/app/assets/stylesheets/page_bundles/cluster_agents.scss @@ -0,0 +1,13 @@ +@import 'mixins_and_variables_and_functions'; + +.agent-activity-list { + .system-note .timeline-entry-inner { + .timeline-icon { + @include gl-mt-1; + } + } + + .timeline-entry::before { + @include gl-mt-4; + } +} diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/page_bundles/clusters.scss index 27d81d8e53b..a877ae72e31 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/page_bundles/clusters.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .clusters-container { @include media-breakpoint-down(xs) { .nav-controls { @@ -18,15 +20,3 @@ min-height: 372px; } } - -.agent-activity-list { - .system-note .timeline-entry-inner { - .timeline-icon { - @include gl-mt-1; - } - } - - &.issuable-discussion .main-notes-list::before { - @include gl-top-3; - } -} diff --git a/app/assets/stylesheets/page_bundles/graph_charts.scss b/app/assets/stylesheets/page_bundles/graph_charts.scss new file mode 100644 index 00000000000..37a75f92a7e --- /dev/null +++ b/app/assets/stylesheets/page_bundles/graph_charts.scss @@ -0,0 +1,27 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + +.repo-charts { + .sub-header { + margin: 20px 0; + } + + .sub-header-block.border-top { + margin-top: 20px; + padding: 0; + border-top: 1px solid var(--border-color, $border-color); + border-bottom: 0; + } + + .commit-stats li { + font-size: 16px; + } + + .tree-ref-header { + margin-bottom: 20px; + + h4 { + margin: 0; + line-height: 36px; + } + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index a4a82fdcef3..3951f72112f 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -970,9 +970,6 @@ $ide-commit-header-height: 48px; .ide-stage { .card-header { - display: flex; - cursor: pointer; - .ci-status-icon { display: flex; align-items: center; @@ -980,10 +977,6 @@ $ide-commit-header-height: 48px; } } -.ide-stage-collapse-icon { - margin: auto 0 auto auto; -} - .ide-job-header { min-height: 60px; padding: 0 $gl-padding; diff --git a/app/assets/stylesheets/page_bundles/incidents.scss b/app/assets/stylesheets/page_bundles/incidents.scss new file mode 100644 index 00000000000..de246fa14b9 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/incidents.scss @@ -0,0 +1,73 @@ +@import 'mixins_and_variables_and_functions'; + +.issuable-discussion.incident-timeline-events { + .main-notes-list::before { + content: none; + } + + .timeline-event-note { + p { + margin-bottom: 0; + font-size: 0.875rem; + } + } +} + +/** + * We have a very specific design proposal where we cannot + * use `vertical-line` mixin as it is and have to use + * custom styles, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81284#note_904867444 + */ +.timeline-entry-vertical-line { + &::before, + &::after { + content: ''; + border-left: 2px solid $gray-50; + position: absolute; + left: 20px; + height: calc(100% + #{$gl-spacing-scale-5}); + top: -#{$gl-spacing-scale-5}; + } + + &:first-child::before { + content: none; + } + + &:first-child { + &::after { + top: $gl-spacing-scale-5; + height: calc(100% + #{$gl-spacing-scale-5}); + } + } + + &:last-child, + &.create-timeline-event { + &::before { + top: - #{$gl-spacing-scale-5} !important; // Override default positioning + @include gl-h-8; + } + + &::after { + content: none; + } + } +} + +.timeline-entry:not(:last-child) { + .timeline-event-border { + @include gl-pb-5; + @include gl-border-gray-50; + @include gl-border-1; + @include gl-border-b-solid; + } +} + +.timeline-group:last-child { + .timeline-entry:last-child, + .create-timeline-event { + .timeline-event-bottom-border { + @include gl-border-b; + @include gl-pt-5; + } + } +} diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss index 26d694f7421..bbdcf1ea0c6 100644 --- a/app/assets/stylesheets/page_bundles/issues_show.scss +++ b/app/assets/stylesheets/page_bundles/issues_show.scss @@ -1,5 +1,9 @@ @import 'mixins_and_variables_and_functions'; +$design-pin-diameter: 28px; +$design-pin-diameter-sm: 24px; +$t-gray-a-16-design-pin: rgba($black, 0.16); + .description { li { position: relative; @@ -23,6 +27,216 @@ } } +.design-card-header { + background: transparent; +} + +.design-checkbox { + position: absolute; + top: $gl-padding; + left: 30px; +} + +.layout-page.design-detail-layout { + max-height: 100vh; +} + +.design-detail { + background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity); + + .with-performance-bar & { + top: 35px; + } + + .comment-indicator { + border-radius: 50%; + } + + .comment-indicator, + .frame .design-note-pin { + &:active { + cursor: grabbing; + } + } +} + +.design-list-item { + height: 280px; + text-decoration: none; + + .icon-version-status { + position: absolute; + right: 10px; + top: 10px; + } + + .card-body { + height: 230px; + } +} + +// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197 +.design-list-item-new { + height: 210px; +} + +.design-note-pin { + display: flex; + height: $design-pin-diameter; + width: $design-pin-diameter; + box-sizing: content-box; + background-color: var(--purple-500, $purple-500); + color: var(--white, $white); + font-weight: $gl-font-weight-bold; + border-radius: 50%; + z-index: 1; + padding: 0; + border: 0; + + &.draft { + background-color: var(--orange-500, $orange-500); + } + + &.resolved { + background-color: var(--gray-500, $gray-500); + } + + &.on-image { + box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24; + border: var(--white, $white) 2px solid; + will-change: transform, box-shadow, opacity; + // NOTE: verbose transition property required for Safari + transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear; + transform-origin: 0 0; + transform: translate(-50%, -50%); + + &:hover { + transform: scale(1.2) translate(-50%, -50%); + } + + &:active { + box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin; + } + + &.inactive { + @include gl-opacity-5; + + &:hover { + @include gl-opacity-10; + } + } + } + + &.small { + position: absolute; + border: 1px solid var(--white, $white); + height: $design-pin-diameter-sm; + width: $design-pin-diameter-sm; + } + + &.user-avatar { + top: 25px; + right: 8px; + } +} + +.design-scaler-wrapper { + bottom: 0; + left: 50%; + transform: translateX(-50%); +} + +.image-notes { + overflow-y: scroll; + padding: $gl-padding; + padding-top: 50px; + background-color: var(--white, $white); + flex-shrink: 0; + min-width: 400px; + flex-basis: 28%; + + .link-inherit-color { + &:hover, + &:active, + &:focus { + color: inherit; + text-decoration: none; + } + } + + .toggle-comments { + line-height: 20px; + border-top: 1px solid var(--border-color, $border-color); + + &.expanded { + border-bottom: 1px solid var(--border-color, $border-color); + } + + .toggle-comments-button:focus { + text-decoration: none; + color: var(--blue-600, $blue-600); + } + } + + .design-note-pin { + margin-left: $gl-padding; + } + + .design-discussion { + margin: $gl-padding 0; + + &::before { + content: ''; + border-left: 1px solid var(--gray-100, $gray-100); + position: absolute; + left: 28px; + top: -17px; + height: 17px; + } + + .design-note { + padding: $gl-padding; + list-style: none; + transition: background $gl-transition-duration-medium $general-hover-transition-curve; + border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box + border-top-right-radius: $border-radius-default; + + a { + color: inherit; + } + + .note-text a { + color: var(--blue-600, $blue-600); + } + } + + .reply-wrapper { + padding: $gl-padding; + } + } + + .reply-wrapper { + border-top: 1px solid var(--border-color, $border-color); + } + + .new-discussion-disclaimer { + line-height: 20px; + } +} + +@media (max-width: map-get($grid-breakpoints, lg)) { + .design-detail { + overflow-y: scroll; + } + + .image-notes { + overflow-y: auto; + min-width: 100%; + flex-grow: 1; + flex-basis: auto; + } +} + .is-ghost { opacity: 0.3; pointer-events: none; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 463c8f74342..b2fbce7cb4b 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -9,6 +9,124 @@ $tabs-holder-z-index: 250; min-width: 0; } +.diff-comment-avatar-holders { + position: absolute; + margin-left: -$gl-padding; + z-index: 100; + @include code-icon-size(); + + &:hover { + .diff-comment-avatar, + .diff-comments-more-count { + @for $i from 1 through 4 { + $x-pos: 14px; + + &:nth-child(#{$i}) { + @if $i == 4 { + $x-pos: 14.5px; + } + + transform: translateX((($i * $x-pos) - $x-pos)); + + &:hover { + transform: translateX((($i * $x-pos) - $x-pos)); + } + } + } + } + + .diff-comments-more-count { + padding-left: 2px; + padding-right: 2px; + width: auto; + } + } +} + +.diff-comment-avatar, +.diff-comments-more-count { + position: absolute; + left: 0; + margin-right: 0; + border-color: var(--white, $white); + cursor: pointer; + transition: all 0.1s ease-out; + @include code-icon-size(); + + @for $i from 1 through 4 { + &:nth-child(#{$i}) { + z-index: (4 - $i); + } + } + + .avatar { + @include code-icon-size(); + } +} + +.diff-comments-more-count { + padding-left: 0; + padding-right: 0; + overflow: hidden; + @include code-icon-size(); +} + +.diff-file-changes { + max-width: 560px; + width: 100%; + z-index: 150; + min-height: $dropdown-min-height; + max-height: $dropdown-max-height; + overflow-y: auto; + margin-bottom: 0; + + @include media-breakpoint-up(sm) { + left: $gl-padding; + } + + .dropdown-input .dropdown-input-search { + pointer-events: all; + } + + .diff-changed-file { + display: flex; + padding-top: 8px; + padding-bottom: 8px; + min-width: 0; + } + + .diff-file-changed-icon { + margin-top: 2px; + } + + .diff-changed-file-content { + display: flex; + flex-direction: column; + min-width: 0; + } + + .diff-changed-file-name, + .diff-changed-blank-file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .diff-changed-blank-file-name { + color: var(--gray-400, $gray-400); + font-style: italic; + } + + .diff-changed-file-path { + color: var(--gray-400, $gray-400); + } + + .diff-changed-stats { + margin-left: auto; + white-space: nowrap; + } +} + .diff-files-holder { flex: 1; min-width: 0; @@ -19,6 +137,111 @@ $tabs-holder-z-index: 250; } } +.diff-grid { + .diff-td { + // By default min-width is auto with 1fr which causes some overflow problems + // https://gitlab.com/gitlab-org/gitlab/-/issues/296222 + min-width: 0; + } + + .diff-grid-row { + display: grid; + grid-template-columns: 1fr 1fr; + + &.diff-grid-row-full { + grid-template-columns: 1fr; + } + } + + .diff-grid-left, + .diff-grid-right { + display: grid; + // Zero width column is a placeholder for the EE inline code quality diff + // see ee/.../diffs.scss for more details + grid-template-columns: 50px 8px 0 1fr; + } + + .diff-grid-2-col { + grid-template-columns: 100px 1fr !important; + + &.parallel { + grid-template-columns: 50px 1fr !important; + } + } + + .diff-grid-comments { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .diff-grid-drafts { + display: grid; + grid-template-columns: 1fr 1fr; + + .content + .content { + @include gl-border-t; + } + } + + &.inline-diff-view { + .diff-grid-comments { + display: grid; + grid-template-columns: 1fr; + } + + .diff-grid-drafts { + display: grid; + grid-template-columns: 1fr; + } + + .diff-grid-row { + grid-template-columns: 1fr; + } + + .diff-grid-left, + .diff-grid-right { + // Zero width column is a placeholder for the EE inline code quality diff + // see ee/../diffs.scss for more details + grid-template-columns: 50px 50px 8px 0 1fr; + } + } +} + +.diff-line-expand-button { + &:hover, + &:focus { + background-color: var(--gray-200, $gray-200); + } +} + +.diff-table.code .diff-tr.line_holder .diff-td.line_content.parallel { + width: unset; +} + +.diff-tr { + .timeline-discussion-body { + clear: left; + + .note-body { + padding: 0 0 $gl-padding-8; + } + } + + .timeline-entry img.avatar { + margin-top: -2px; + margin-right: $gl-padding-8; + } + + // tiny adjustment to vertical align with the note header text + .discussion-collapsible { + margin-left: 1rem; + + .timeline-icon { + padding-top: 2px; + } + } +} + .with-system-header { --system-header-height: #{$system-header-height}; } @@ -497,10 +720,6 @@ $tabs-holder-z-index: 250; } @include media-breakpoint-down(xs) { - p { - font-size: 13px; - } - .btn-grouped { float: none; margin-right: 0; @@ -661,10 +880,10 @@ $tabs-holder-z-index: 250; &:not(:last-child)::before { content: ''; - border-left: 1px solid var(--gray-100, $gray-100); + border-left: 2px solid var(--gray-10, $gray-10); position: absolute; - left: 28px; bottom: -17px; + left: calc(1rem - 1px); height: 16px; } } @@ -677,7 +896,6 @@ $tabs-holder-z-index: 250; display: flex; align-items: center; flex-wrap: wrap; - padding: 16px; z-index: 199; white-space: nowrap; @@ -833,6 +1051,12 @@ $tabs-holder-z-index: 250; .detail-page-header-actions { .gl-toggle { @include gl-ml-auto; + @include gl-rounded-pill; + @include gl-w-9; + + &.is-checked:hover { + background-color: $blue-500; + } } } @@ -845,3 +1069,88 @@ $tabs-holder-z-index: 250; @include gl-font-weight-normal; } } + +.dropdown-menu li button.gl-toggle:not(.is-checked) { + background: $gray-400; +} + +.mr-widget-content-row:first-child { + border-top: 0; +} + +.memory-graph-container { + background: var(--white, $white); + border: 1px solid var(--gray-100, $gray-100); +} + +.review-bar-component { + position: fixed; + bottom: 0; + left: 0; + z-index: $zindex-dropdown-menu; + display: flex; + align-items: center; + width: 100%; + height: $toggle-sidebar-height; + padding-left: $contextual-sidebar-width; + padding-right: $gutter_collapsed_width; + background: var(--white, $white); + border-top: 1px solid var(--border-color, $border-color); + transition: padding $gl-transition-duration-medium; + + .page-with-icon-sidebar & { + padding-left: $contextual-sidebar-collapsed-width; + } + + .right-sidebar-expanded & { + padding-right: $gutter_width; + } + + @media (max-width: map-get($grid-breakpoints, sm)-1) { + padding-left: 0; + padding-right: 0; + } + + .dropdown { + margin-left: $grid-size; + } +} + +.review-bar-content { + max-width: $limited-layout-width; + padding: 0 $gl-padding; + width: 100%; + margin: 0 auto; +} + +.review-preview-item-header { + display: flex; + align-items: center; + width: 100%; + margin-bottom: 4px; + + > .bold { + display: flex; + min-width: 0; + line-height: 16px; + } +} + +.review-preview-item-footer { + display: flex; + align-items: center; + margin-top: 4px; +} + +.review-preview-item-content { + width: 100%; + + p { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index c401f1a4902..63bcb83e747 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -1,4 +1,4 @@ -@import 'mixins_and_variables_and_functions'; +@import 'page_bundles/mixins_and_variables_and_functions'; $status-box-line-height: 26px; @@ -40,39 +40,6 @@ $status-box-line-height: 26px; } } } - - .card-header { - line-height: $line-height-base; - padding: 14px 16px; - display: flex; - justify-content: space-between; - - .title { - flex: 1; - flex-grow: 2; - } - - .issuable-count-weight { - white-space: nowrap; - - .counter, - .weight { - color: var(--gray-500, $gray-500); - font-weight: $gl-font-weight-bold; - } - } - - &.text-white { - .issuable-count-weight svg { - fill: $white; - } - - .issuable-count-weight .counter, - .weight { - color: var(--white, $white); - } - } - } } .milestone-sidebar { diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/page_bundles/operations.scss index 1dcaa47470b..497cb63033c 100644 --- a/app/assets/stylesheets/components/dashboard_skeleton.scss +++ b/app/assets/stylesheets/page_bundles/operations.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .dashboard-cards { margin-right: -$gl-padding-8; margin-left: -$gl-padding-8; @@ -8,7 +10,7 @@ &-header { &-warning { - background-color: $orange-100; + background-color: var(--orange-100, $orange-100); } } @@ -16,16 +18,16 @@ min-height: 120px; &-warning { - background-color: $orange-50; + background-color: var(--orange-50, $orange-50); } &-failed { - background-color: $red-50; + background-color: var(--red-50, $red-50); } } &-icon { - color: $gray-300; + color: var(--gray-300, $gray-300); } &-footer { @@ -33,7 +35,7 @@ height: $gl-padding-32; &-arrow { - color: $gray-200; + color: var(--gray-200, $gray-200); } &-downstream { @@ -41,7 +43,7 @@ } &-extra { - background-color: $gray-200; + background-color: var(--gray-200, $gray-200); font-size: 10px; line-height: $gl-line-height; width: $gl-padding; @@ -50,7 +52,7 @@ &-header { &-failed { - background-color: $red-100; + background-color: var(--red-100, $red-100); } } @@ -66,10 +68,10 @@ background-repeat: no-repeat; background-size: cover; background-image: linear-gradient(to right, - $gray-50 0%, - $gray-10 20%, - $gray-50 40%, - $gray-50 100%); + var(--gray-50, $gray-50) 0%, + var(--gray-10, $gray-10) 20%, + var(--gray-50, $gray-50) 40%, + var(--gray-50, $gray-50) 100%); border-radius: $gl-padding; height: $gl-padding; margin-top: -$gl-padding-8; diff --git a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss index 0c73bece035..af2dac7739e 100644 --- a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss +++ b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss @@ -1,60 +1,82 @@ @import 'mixins_and_variables_and_functions'; -.pipeline-schedule-form { - .gl-field-error { - margin: 10px 0 0; - } +.ci-variable-list { + margin-left: 0; + margin-bottom: 0; + padding-left: 0; + list-style: none; + clear: both; } -.interval-pattern-form-group { - label { - margin-right: 10px; - font-weight: $gl-font-weight-normal; +.ci-variable-row { + display: flex; + align-items: flex-start; - &[for='custom'] { - margin-right: 0; - } + @include media-breakpoint-down(xs) { + align-items: flex-end; } - .cron-interval-input-wrapper { - padding-left: 0; - } + &:not(:last-child) { + margin-bottom: $gl-btn-padding; - .cron-interval-input { - margin: 10px 10px 0 0; + @include media-breakpoint-down(xs) { + margin-bottom: 3 * $gl-btn-padding; + } } -} -.pipeline-schedule-table-row { - .branch-name-cell { - max-width: 300px; - } + &:last-child { + .ci-variable-body-item:last-child { + margin-right: $ci-variable-remove-button-width; - a { - color: var(--gl-text-color, $gl-text-color); - } + @include media-breakpoint-down(xs) { + margin-right: 0; + } + } + + .ci-variable-row-remove-button { + display: none; + } - svg { - vertical-align: middle; + @include media-breakpoint-down(xs) { + .ci-variable-row-body { + margin-right: $ci-variable-remove-button-width; + } + } } } -.pipeline-schedules-user-callout { - .bordered-box.content-block { - border: 1px solid var(--border-color, $border-color); - background-color: transparent; +.ci-variable-row-body { + display: flex; + align-items: flex-start; + width: 100%; + padding-bottom: $gl-padding; + + @include media-breakpoint-down(xs) { + display: block; } } -.cron-preset-radio-input { - display: inline-block; +.ci-variable-body-item { + flex: 1; - @include media-breakpoint-down(md) { - display: block; - margin: 0 0 5px 5px; + &:not(:last-child) { + margin-right: $gl-btn-padding; + + @include media-breakpoint-down(xs) { + margin-right: 0; + margin-bottom: $gl-btn-padding; + } } +} - input { - margin-right: 3px; +.pipeline-schedule-form { + .gl-field-error { + margin: 10px 0 0; + } +} + +.pipeline-schedule-table-row { + a { + color: var(--gl-text-color, $gl-text-color); } } diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index 59b8823c113..ac1e9fb024b 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -1,4 +1,33 @@ @import 'mixins_and_variables_and_functions'; +@import 'framework/buttons'; + +.edit-user { + .emoji-menu-toggle-button { + @include emoji-menu-toggle-button; + } + + @include media-breakpoint-down(sm) { + .input-md, + .input-lg { + max-width: 100%; + } + } +} + +.modal-profile-crop { + .modal-dialog { + width: 380px; + + @include media-breakpoint-down(xs) { + width: auto; + } + } + + .profile-crop-image-container { + height: 300px; + margin: 0 auto; + } +} .calendar-block { padding-left: 0; @@ -210,3 +239,32 @@ .twitter-icon { color: $twitter; } + +.key-created-at { + line-height: 42px; +} + +.key-list-item { + .key-list-item-info { + @include media-breakpoint-up(sm) { + float: left; + } + } +} + +.ssh-keys-list { + .last-used-at, + .expires, + .key-created-at { + line-height: 32px; + } +} + +.subkeys-list { + @include basic-list; + + li { + padding: 3px 0; + border: 0; + } +} diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss index eec5ebdb383..68bf2fa0f82 100644 --- a/app/assets/stylesheets/page_bundles/project.scss +++ b/app/assets/stylesheets/page_bundles/project.scss @@ -191,12 +191,4 @@ h5 { color: var(--gl-text-color, $gl-text-color); } - - .light-well { - border-radius: 2px; - - color: var(--gray-600, $well-light-text-color); - font-size: 13px; - line-height: 1.6em; - } } diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/page_bundles/prometheus.scss index 71cbd7d9613..702c0e4dd72 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/page_bundles/prometheus.scss @@ -1,3 +1,11 @@ +@import 'mixins_and_variables_and_functions'; + +.date-time-picker { + .date-time-picker-menu { + width: 400px; + } +} + .prometheus-graphs { .dropdown-buttons { > div { @@ -96,15 +104,6 @@ padding: $gl-padding-8; } -.alert-current-setting { - max-width: 240px; - - .badge.badge-danger { - color: $red-500; - background-color: $red-100; - } -} - .prometheus-panel-builder { .preview-date-time-picker { // same as in .dropdown-menu-toggle diff --git a/app/assets/stylesheets/components/release_block_milestone_info.scss b/app/assets/stylesheets/page_bundles/releases.scss index b6a85ae965a..24ffbf9b90c 100644 --- a/app/assets/stylesheets/components/release_block_milestone_info.scss +++ b/app/assets/stylesheets/page_bundles/releases.scss @@ -1,3 +1,9 @@ +@import 'mixins_and_variables_and_functions'; + +.release-block { + transition: background-color 1s linear; +} + .release-block-milestone-info { .milestone-progress-bar-container { width: 300px; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss index a9fbff8958d..58e55e11f7e 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/page_bundles/tree.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .project-last-commit { min-height: 4.75rem; } @@ -100,11 +102,11 @@ margin-bottom: 0; tr { - border-bottom: 1px solid $white-normal; - border-top: 1px solid $white-normal; + border-bottom: 1px solid var(--gray-50, $gray-50); + border-top: 1px solid var(--gray-50, $gray-50); &:last-of-type { - border-bottom-color: $white; + border-bottom-color: transparent; } td, @@ -117,24 +119,24 @@ } td { - border-color: $border-color; + border-color: var(--border-color, $border-color); } &:hover:not(.tree-truncated-warning) { td { - background-color: $blue-50; + background-color: var(--blue-50, $blue-50); background-clip: padding-box; - border-top: 1px solid $blue-200; - border-bottom: 1px solid $blue-200; + border-top: 1px solid var(--blue-200, $blue-200); + border-bottom: 1px solid var(--blue-200, $blue-200); cursor: pointer; } } &.selected { td { - background: $white-normal; - border-top: 1px solid $border-white-normal; - border-bottom: 1px solid $border-white-normal; + background: var(--gray-50, $gray-50); + border-top: 1px solid var(--border-color, $border-color); + border-bottom: 1px solid var(--border-color, $border-color); } } } @@ -156,7 +158,7 @@ i, a { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } img { @@ -175,22 +177,18 @@ } .tree-truncated-warning { - color: $orange-600; - background-color: $orange-50; + color: var(--orange-600, $orange-600); + background-color: var(--orange-50, $orange-50); } .tree-time-ago { min-width: 135px; - color: $gl-text-color-secondary; } .tree-commit { max-width: 320px; - color: $gl-text-color-secondary; .tree-commit-link { - color: $gl-text-color-secondary; - &:hover { text-decoration: underline; } @@ -207,40 +205,3 @@ .blob-content-holder { margin-top: $gl-padding; } - -.blob-upload-dropzone-previews { - display: flex; - justify-content: center; - align-items: center; - text-align: center; - border: 2px; - border-style: dashed; - border-color: $border-color; - min-height: 200px; -} - -.repo-charts { - .sub-header { - margin: 20px 0; - } - - .sub-header-block.border-top { - margin-top: 20px; - padding: 0; - border-top: 1px solid $white-dark; - border-bottom: 0; - } - - .commit-stats li { - font-size: 16px; - } - - .tree-ref-header { - margin-bottom: 20px; - - h4 { - margin: 0; - line-height: 36px; - } - } -} diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index d0fc011dde7..820a1a0b53e 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -63,3 +63,22 @@ display: none; } } + +.work-item-dropdown { + .gl-dropdown-toggle { + background: none !important; + + &:hover, + &:focus { + box-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-darkest, $gray-darkest) !important; + } + + &.is-not-focused:not(:hover, :focus) { + box-shadow: none; + + .gl-button-icon { + display: none; + } + } + } +} diff --git a/app/assets/stylesheets/pages/deploy_keys.scss b/app/assets/stylesheets/pages/deploy_keys.scss deleted file mode 100644 index 997e42a8fd5..00000000000 --- a/app/assets/stylesheets/pages/deploy_keys.scss +++ /dev/null @@ -1,4 +0,0 @@ -.deploy-keys-title { - padding-bottom: 2px; - line-height: 2; -} diff --git a/app/assets/stylesheets/pages/environment_logs.scss b/app/assets/stylesheets/pages/environment_logs.scss deleted file mode 100644 index f8f40076142..00000000000 --- a/app/assets/stylesheets/pages/environment_logs.scss +++ /dev/null @@ -1,54 +0,0 @@ -.environment-logs-page { - .content-wrapper { - padding-bottom: 0; - } -} - -.environment-logs-viewer { - height: calc(100vh - #{$environment-logs-difference-xs-up}); - min-height: 700px; - - @include media-breakpoint-up(md) { - height: calc(100vh - #{$environment-logs-difference-md-up}); - } - - .with-performance-bar & { - height: calc(100vh - #{$environment-logs-difference-xs-up} - #{$performance-bar-height}); - - @include media-breakpoint-up(md) { - height: calc(100vh - #{$environment-logs-difference-md-up} - #{$performance-bar-height}); - } - } - - .top-bar { - .date-time-picker-wrapper, - .dropdown-toggle { - @include media-breakpoint-up(md) { - width: 140px; - } - - @include media-breakpoint-up(lg) { - width: 160px; - } - } - } - - .log-lines, - .gl-infinite-scroll-container { - // makes scrollbar visible by creating contrast - background: $black; - height: 100%; - } - - .build-log { - @include build-log($black); - } - - .gl-infinite-scroll-legend { - margin: 0; - } - - .build-loader-animation { - @include build-loader-animation; - } -} diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 33d00027404..ce8dd6684f2 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -84,6 +84,10 @@ iframe.twitter-share-button { vertical-align: bottom; } + + .gl-label-scoped.gl-label-sm { + --label-inset-border: inset 0 0 0 1px currentColor; + } } code { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 85205f4d5ac..6070311dcb6 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -199,12 +199,15 @@ .sidebar-contained-width, .issuable-sidebar-header { width: 100%; - border-bottom: 0; } .block { @include media-breakpoint-up(lg) { - padding: $gl-spacing-scale-5 0; + padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; + } + + &.participants { + border-bottom: 0; } } } @@ -213,7 +216,8 @@ .sidebar-contained-width, .issuable-sidebar-header { @include clearfix; - padding: $gl-padding 0; + padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; + border-bottom: 1px solid $border-gray-normal; // This prevents the mess when resizing the sidebar // of elements repositioning themselves.. width: $gutter-inner-width; @@ -235,6 +239,13 @@ } } } + + &.time-tracking, + &.participants, + &.subscriptions, + &.with-sub-blocks { + padding-top: $gl-spacing-scale-5; + } } .block-first { @@ -724,13 +735,7 @@ } .issue-check { - padding-right: $gl-padding; - margin-bottom: 10px; min-width: 15px; - - .selected-issuable { - vertical-align: text-top; - } } .issuable-milestone, @@ -851,24 +856,6 @@ } } -.issuable-todo-btn { - .gl-spinner { - display: none; - } - - &.is-loading { - .gl-spinner { - display: inline-block; - } - - &.sidebar-collapsed-icon { - .issuable-todo-inner { - display: none; - } - } - } -} - /* * Following overrides are done to prevent * legacy dropdown styles from influencing @@ -927,88 +914,3 @@ } } } - -.icon-overlap-and-shadow { - filter: - drop-shadow(0 1px 0.5px #fff) - drop-shadow(1px 0 0.5px #fff) - drop-shadow(0 -1px 0.5px #fff) - drop-shadow(-1px 0 0.5px #fff); - margin-right: -7px; - z-index: 1; -} - -.issuable-discussion.incident-timeline-events { - .main-notes-list::before { - content: none; - } - - .timeline-event-note { - p { - margin-bottom: 0; - } - } -} - -/** - * We have a very specific design proposal where we cannot - * use `vertical-line` mixin as it is and have to use - * custom styles, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81284#note_904867444 - */ -.timeline-entry-vertical-line { - &::before, - &::after { - content: ''; - border-left: 2px solid $gray-50; - position: absolute; - left: 39px; - height: calc(100% + #{$gl-spacing-scale-5}); - top: -#{$gl-spacing-scale-5}; - } - - &:first-child::before { - content: none; - } - - &:first-child { - &::after { - top: $gl-spacing-scale-5; - height: calc(100% + #{$gl-spacing-scale-5}); - } - } - - &:last-child, - &.create-timeline-event { - &::before { - top: - #{$gl-spacing-scale-5} !important; // Override default positioning - @include gl-h-8; - } - - &::after { - content: none; - } - } -} - -.timeline-event-note-form { - padding-left: 20px; -} - -.timeline-entry:not(:last-child) { - .timeline-event-border { - @include gl-pb-5; - @include gl-border-gray-50; - @include gl-border-1; - @include gl-border-b-solid; - } -} - -.timeline-group:last-child { - .timeline-entry:last-child, - .create-timeline-event { - .timeline-event-bottom-border { - @include gl-border-b; - @include gl-pt-5; - } - } -} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 843daec8cda..c88834c088f 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -123,6 +123,9 @@ ul.related-merge-requests > li gl-emoji { } .new-branch-col { + @include gl-pb-3; + @include gl-my-2; + .discussion-filter-container { &:not(:last-child) { margin-right: $gl-spacing-scale-3; @@ -221,7 +224,7 @@ ul.related-merge-requests > li gl-emoji { display: flex; .new-branch-col { - padding-top: 0; + @include gl-pb-0; align-self: center; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index fc1b78bf730..438b7b1afa6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -1,15 +1,15 @@ -$system-note-icon-size: 32px; -$system-note-svg-size: 16px; +$system-note-icon-size: 2rem; +$system-note-svg-size: 1rem; @mixin vertical-line($left) { &::before { content: ''; - border-left: 2px solid $gray-10; + border-left: 2px solid var(--gray-10, $gray-10); position: absolute; top: 0; bottom: 0; - left: $left; - height: calc(100% - 20px); + left: calc(#{$left} - 1px); + height: calc(100% + 1.5rem); } } @@ -19,17 +19,10 @@ $system-note-svg-size: 16px; border-radius: $border-radius-default; } -.note-wrapper { - padding: $gl-padding $gl-padding-8 $gl-padding $gl-padding; - - &.outlined { - @include outline-comment(); - } -} - -.issuable-discussion { - .main-notes-list { - @include vertical-line(35px); +.issuable-discussion:not(.incident-timeline-events), +.limited-width-notes { + .main-notes-list > li.timeline-entry:not(:last-of-type) { + @include vertical-line(1rem); } } @@ -41,8 +34,6 @@ $system-note-svg-size: 16px; position: relative; &.timeline > .timeline-entry { - border: 1px solid $border-color; - border-radius: $border-radius-default; margin: $gl-padding 0; &.system-note, @@ -50,6 +41,117 @@ $system-note-svg-size: 16px; border: 0; } + .timeline-avatar { + height: 2rem; + } + + &.note-comment, + &.note-skeleton, + .draft-note { + .timeline-avatar { + margin-top: 5px; + } + + .timeline-content:not(.flash-container) { + margin-left: 2.5rem; + border: 1px solid $border-color; + border-radius: $gl-border-radius-base; + background-color: $white; + padding: $gl-padding-4 $gl-padding-8; + } + + .note-header-info { + min-height: 2rem; + display: flex; + align-items: center; + gap: 0 0.25rem; + flex-wrap: wrap; + } + } + + &.note-discussion { + .timeline-content .discussion-wrapper { + background-color: transparent; + } + + .timeline-content { + ul li { + &:first-of-type { + .timeline-avatar { + margin-top: 5px; + } + + .timeline-content { + margin-left: 2.5rem; + border-left: 1px solid $border-color; + border-right: 1px solid $border-color; + border-top: 1px solid $border-color; + border-top-left-radius: $gl-border-radius-base; + border-top-right-radius: $gl-border-radius-base; + background-color: $white; + padding: $gl-padding-4 $gl-padding-8; + } + } + + &:not(:first-of-type) .timeline-entry-inner { + margin-left: 2.5rem; + border-left: 1px solid $border-color; + border-right: 1px solid $border-color; + background-color: $white; + + .timeline-content { + padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; + } + + .timeline-avatar { + margin: $gl-padding-8 0 0 $gl-padding; + } + + .timeline-discussion-body { + margin-left: 2rem; + } + } + } + + .diff-content { + ul li:first-of-type { + .timeline-avatar { + margin-top: 0; + } + + .timeline-content { + margin-left: 0; + border: 0; + padding: 0; + } + + .timeline-entry-inner { + margin-left: 2.5rem; + border-left: 1px solid $border-color; + border-right: 1px solid $border-color; + background-color: $white; + + .timeline-content { + padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; + } + + .timeline-avatar { + margin: $gl-padding-8 0 0 $gl-padding; + } + + .timeline-discussion-body { + margin-left: 2rem; + } + } + } + } + } + + .discussion-reply-holder { + border: 1px solid $border-color; + } + } + &.note-form { margin-left: 0; @@ -88,10 +190,14 @@ $system-note-svg-size: 16px; .card { margin-bottom: 0; } - } - .timeline-discussion-body { - margin-top: -$gl-padding-8; + .note-header-info { + min-height: 2rem; + display: flex; + align-items: center; + gap: 0 0.25rem; + flex-wrap: wrap; + } } .discussion { @@ -116,16 +222,11 @@ $system-note-svg-size: 16px; &.being-posted { pointer-events: none; opacity: 0.5; - padding: $gl-padding; .dummy-avatar { background-color: $gray-100; border: 1px solid darken($gray-100, 25%); } - - .note-headline-light { - margin-left: 3px; - } } .editing-spinner { @@ -156,6 +257,7 @@ $system-note-svg-size: 16px; .note-edit-form { display: block; margin-left: 0; + margin-top: 0.5rem; &.current-note-edit-form + .note-awards { display: none; @@ -164,13 +266,17 @@ $system-note-svg-size: 16px; } .note-body { - padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding-8; + padding: 0 $gl-padding-8 $gl-padding-8; overflow-x: auto; overflow-y: hidden; .note-text { word-wrap: break-word; } + + .suggestions { + margin-top: 4px; + } } .note-awards { @@ -186,9 +292,10 @@ $system-note-svg-size: 16px; } .system-note { - padding: $gl-padding-4 20px; + padding: $gl-padding-8 0; margin: $gl-padding 0; background-color: transparent; + font-size: $gl-font-size; .note-header-info { padding-bottom: 0; @@ -229,6 +336,15 @@ $system-note-svg-size: 16px; .note-body { overflow: hidden; + padding: 0; + + ul { + margin: 0.5rem 0; + } + + p { + margin-left: 1rem; + } .description-version { position: relative; @@ -305,7 +421,7 @@ $system-note-svg-size: 16px; height: $system-note-icon-size; border: 1px solid $gray-10; border-radius: $system-note-icon-size; - margin: -6px 0 0; + margin: -8px 0 0; svg { width: $system-note-svg-size; @@ -319,25 +435,38 @@ $system-note-svg-size: 16px; .discussion-filter-note { .timeline-icon { - width: $system-note-icon-size + 6; - height: $system-note-icon-size + 6; + width: $system-note-icon-size; + height: $system-note-icon-size; margin-top: -8px; } } } +.card .notes { + .system-note { + margin: 0; + padding: 0; + } + + .timeline-icon { + margin: 8px 0 0 14px; + } +} + + // Diff code in discussion view .discussion-body .diff-file { .file-title { cursor: default; - border-top: 1px solid $border-color; + border-top: 0; 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 + 28px}); + --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px}); } .with-performance-bar & { @@ -357,6 +486,40 @@ $system-note-svg-size: 16px; .line_content { white-space: pre-wrap; } + + .diff-content { + margin-left: 2.5rem; + + &.outdated-lines-wrapper { + margin-left: 0; + } + + .line_holder td:first-of-type { + @include gl-border-l; + } + + .line_holder td:last-of-type { + @include gl-border-r; + } + + .discussion-notes { + margin-left: -2.5rem; + + .notes { + background-color: transparent; + } + + .notes-content { + border: 0; + } + + .timeline-content { + border-top: 0 !important; + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; + } + } + } } .tab-pane.notes { @@ -394,8 +557,17 @@ $system-note-svg-size: 16px; } .system-note { - background-color: $white; - padding: $gl-padding; + background-color: transparent; + padding: 0; + + .timeline-icon { + margin-top: -2px; + } + + .timeline-entry-inner .timeline-icon { + margin-top: $grid-size; + margin-left: 14px; + } } } @@ -487,6 +659,19 @@ $system-note-svg-size: 16px; .code-commit .notes-content, .diff-viewer > .image ~ .note-container { background-color: $white; + + li.note-comment { + padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; + + .avatar { + margin-right: 0; + } + + .note-body { + padding: $gl-padding-4 0 $gl-padding-8; + margin-left: 2.5rem; + } + } } .diff-viewer > .image ~ .note-container form.new-note { @@ -540,9 +725,21 @@ $system-note-svg-size: 16px; padding-bottom: 0; } + .timeline-avatar { + margin-top: 5px; + } + .timeline-content { overflow-x: auto; overflow-y: hidden; + border-radius: $gl-border-radius-base; + padding: $gl-padding-8 !important; + @include gl-border; + + &.expanded { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } } &.note-wrapper { @@ -568,19 +765,10 @@ $system-note-svg-size: 16px; .note { @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { .note-header { - .note-actions { - flex-wrap: wrap; - margin-bottom: $gl-padding-12; - - > :first-child { - margin-left: 0; - } + .note-actions > :first-child { + margin-left: 0; } } - - .note-header-author-name { - display: block; - } } } @@ -593,11 +781,6 @@ $system-note-svg-size: 16px; } } -.note-header-info, -.note-actions { - padding-bottom: $gl-padding-4; -} - .system-note .note-header-info { padding-bottom: 0; } @@ -618,10 +801,6 @@ $system-note-svg-size: 16px; } .note-headline-meta { - .system-note-separator { - color: $gray-500; - } - .note-timestamp { white-space: nowrap; } @@ -667,18 +846,20 @@ $system-note-svg-size: 16px; } .note-actions { - align-self: flex-start; justify-content: flex-end; flex-shrink: 1; display: inline-flex; align-items: center; - margin-left: 10px; + margin-left: $gl-padding-8; color: $gray-400; - margin-top: -4px; @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { + justify-content: flex-start; float: none; - margin-left: 0; + + .note-actions__mobile-spacer { + flex-grow: 1; + } } } @@ -719,7 +900,7 @@ $system-note-svg-size: 16px; } .discussion-toggle-button { - padding: 0; + padding: 0 $gl-padding-8 0 0; background-color: transparent; border: 0; line-height: 20px; @@ -868,6 +1049,28 @@ $system-note-svg-size: 16px; .note-discussion.timeline-entry { padding-left: 0; + ul.notes li.note-wrapper { + .timeline-content { + padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; + } + + .timeline-avatar { + margin: $gl-padding-8 0 0 $gl-padding; + } + } + + ul.notes { + li.toggle-replies-widget { + margin-left: 0; + border-left: 0; + border-right: 0; + } + + div.discussion-reply-holder { + margin-left: 0; + } + } + &:last-child { border-bottom: 0; } @@ -894,6 +1097,16 @@ $system-note-svg-size: 16px; } } + .draft-note-component .draft-note.timeline-entry { + .timeline-content:not(.flash-container) { + padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; + } + + .timeline-avatar { + margin: $gl-padding-8 0 0 $gl-padding; + } + } + .diff-comment-form { display: block; } @@ -909,8 +1122,7 @@ $system-note-svg-size: 16px; // See https://gitlab.com/gitlab-org/gitlab-foss/issues/53918#note_117038785 .unstyled-comments { .discussion-header { - padding: $gl-padding; - border-bottom: 1px solid $border-color; + padding: $gl-padding 0; } .discussion-form-container { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 951e31ef768..8e4dd39e498 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -1,22 +1,3 @@ -.avatar-image { - margin-bottom: $grid-size; - - .avatar { - float: none; - } - - @include media-breakpoint-up(sm) { - float: left; - margin-bottom: 0; - } -} - -.avatar-file-name { - position: relative; - top: 2px; - display: inline-block; -} - .account-well { padding: 10px; background-color: $gray-light; @@ -29,42 +10,6 @@ } } -.user-avatar-button { - .file-name { - display: inline-block; - padding-left: 10px; - } -} - -.subkeys-list { - @include basic-list; - - li { - padding: 3px 0; - border: 0; - } -} - -.key-list-item { - .key-list-item-info { - @include media-breakpoint-up(sm) { - float: left; - } - } -} - -.ssh-keys-list { - .last-used-at, - .expires, - .key-created-at { - line-height: 32px; - } -} - -.key-created-at { - line-height: 42px; -} - .provider-btn-group { display: inline-block; margin-right: 10px; @@ -113,26 +58,6 @@ } } -.modal-profile-crop { - .modal-dialog { - width: 380px; - - @include media-breakpoint-down(xs) { - width: auto; - } - } - - .profile-crop-image-container { - height: 300px; - margin: 0 auto; - } - - .crop-controls { - padding: 10px 0 0; - text-align: center; - } -} - .created-personal-access-token-container { .btn-clipboard { border: 1px solid $border-color; @@ -247,36 +172,6 @@ table.u2f-registrations { } } -.edit-user { - svg { - fill: $gl-text-color-secondary; - } - - .form-group > label { - font-weight: $gl-font-weight-bold; - } - - .form-group > .form-text { - font-size: $gl-font-size; - } - - .emoji-menu-toggle-button { - @include emoji-menu-toggle-button; - padding: 6px 10px; - - .no-emoji-placeholder { - position: relative; - } - } - - @include media-breakpoint-down(sm) { - .input-md, - .input-lg { - max-width: 100%; - } - } -} - .help-block { color: $gl-text-color-secondary; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0d45beab983..be8707dcd50 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -269,49 +269,27 @@ color: $gray-200; } -pre.light-well { - border-color: $well-light-border; -} - /* * Projects list rendered on dashboard and user page */ + +.project-row { + .description p { + margin-bottom: 0; + color: $gl-text-color-secondary; + } +} + .projects-list { @include basic-list; display: flex; flex-direction: column; - // Disable Flexbox for admin page - &.admin-projects, - &.group-settings-projects { - display: block; - - .project-row { - display: block; - - .description > p { - margin-bottom: 0; - } - } - } - .project-row { @include basic-list-stats; display: flex; align-items: center; padding: $gl-padding-12 0; - - &.no-description { - @include media-breakpoint-up(sm) { - .avatar-container { - align-self: center; - } - - .metadata-info { - margin-bottom: 0; - } - } - } } h2 { @@ -634,24 +612,6 @@ pre.light-well { } } -.clearable-input { - position: relative; - - .clear-icon { - display: none; - position: absolute; - right: 9px; - top: 9px; - } - - &.has-value { - .clear-icon { - cursor: pointer; - display: block; - } - } -} - .project-path { .form-control { min-width: 100px; @@ -810,10 +770,3 @@ pre.light-well { } } } - -@include media-breakpoint-down(xs) { - .fork-filtered-search { - width: 100%; - margin: $gl-spacing-scale-2 0; - } -} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index e8f71c8a21c..a8027d2a5f5 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -268,16 +268,6 @@ input[type='checkbox']:hover { } } - .search-clear { - position: absolute; - right: 10px; - top: 9px; - padding: 0; - line-height: 0; - background: none; - border: 0; - } - .search-icon { position: absolute; left: 10px; @@ -327,15 +317,6 @@ input[type='checkbox']:hover { } } -.search-clear { - color: $gray-darkest; - - &:hover, - &:focus { - color: $blue-600; - } -} - .search-page-form { .dropdown-menu-toggle, .btn-search { diff --git a/app/assets/stylesheets/pages/service_desk.scss b/app/assets/stylesheets/pages/service_desk.scss deleted file mode 100644 index 34ab5eb1b74..00000000000 --- a/app/assets/stylesheets/pages/service_desk.scss +++ /dev/null @@ -1,7 +0,0 @@ -.service-desk-issues { - .non-empty-state { - text-align: left; - padding-bottom: $gl-padding-top; - border-bottom: 1px solid $border-color; - } -} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 56acf6de828..c364b233803 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -188,31 +188,6 @@ } } -.nested-settings { - padding-left: 20px; -} - -.input-btn-group { - display: flex; - - .input-large { - flex: 1; - } - - .btn { - margin-left: 10px; - } -} - -.content-list > .settings-flex-row { - display: flex; - align-items: center; - - .float-right { - margin-left: auto; - } -} - .prometheus-metrics-monitoring { .card { .card-toggle { diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 0450b3d9a44..32c3ce1ba8c 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -57,7 +57,7 @@ strong { font-weight: bolder; } a { - color: #007bff; + color: #428fdc; text-decoration: none; background-color: transparent; } @@ -368,6 +368,23 @@ kbd kbd { white-space: nowrap; border: 0; } +.gl-avatar { + border-width: 1px; + border-style: solid; + border-color: rgba(0, 0, 0, 0.08); + overflow: hidden; + flex-shrink: 0; +} +.gl-avatar-s24 { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; + line-height: 1rem; + border-radius: 0.25rem; +} +.gl-avatar-circle { + border-radius: 50%; +} .gl-badge { display: inline-flex; align-items: center; @@ -552,9 +569,6 @@ html [type="button"], strong { font-weight: bold; } -a { - color: #63a6e9; -} svg { vertical-align: baseline; } @@ -1783,10 +1797,15 @@ body.gl-dark { background-color: #262626; border-right: 1px solid #303030; } +.gl-avatar:not(.gl-avatar-identicon), .avatar-container, .avatar { background: rgba(255, 255, 255, 0.04); } +.gl-avatar { + border-style: none; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); +} body.gl-dark { --gl-theme-accent: #868686; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 356fb58b4c8..61a2ce8dd62 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -38,7 +38,7 @@ strong { font-weight: bolder; } a { - color: #007bff; + color: #1f75cb; text-decoration: none; background-color: transparent; } @@ -349,6 +349,23 @@ kbd kbd { white-space: nowrap; border: 0; } +.gl-avatar { + border-width: 1px; + border-style: solid; + border-color: rgba(0, 0, 0, 0.08); + overflow: hidden; + flex-shrink: 0; +} +.gl-avatar-s24 { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; + line-height: 1rem; + border-radius: 0.25rem; +} +.gl-avatar-circle { + border-radius: 50%; +} .gl-badge { display: inline-flex; align-items: center; @@ -533,9 +550,6 @@ html [type="button"], strong { font-weight: bold; } -a { - color: #1068bf; -} svg { vertical-align: baseline; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index edc579f48f6..33e10b9bd62 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -41,7 +41,7 @@ p { margin-bottom: 1rem; } a { - color: #007bff; + color: #1f75cb; text-decoration: none; background-color: transparent; } @@ -498,7 +498,7 @@ input.btn-block[type="button"] { .custom-control-input:checked:disabled ~ .custom-control-label::before, .gl-form-checkbox.custom-control - .custom-control-input:indeterminate:disabled + .custom-control-input[type="checkbox"]:indeterminate:disabled ~ .custom-control-label::before { background-color: #dbdbdb; border-color: #dbdbdb; @@ -507,7 +507,7 @@ input.btn-block[type="button"] { .custom-control-input:checked:disabled ~ .custom-control-label::after, .gl-form-checkbox.custom-control - .custom-control-input:indeterminate:disabled + .custom-control-input[type="checkbox"]:indeterminate:disabled ~ .custom-control-label::after { background-color: #5e5e5e; } @@ -595,9 +595,6 @@ h3 { margin-top: 20px; margin-bottom: 10px; } -a { - color: #1068bf; -} hr { overflow: hidden; } diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 4b74e449e06..8e8cabbe511 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -269,9 +269,9 @@ $well-expand-item: $gray-200; $well-inner-border: $gray-200; $calendar-activity-colors: ( - #303030, - #333861, - #4a5593, - #6172c5, - #788ff7 + #404040, + #1e23a8, + #445cf2, + #97acff, + #e9ebff ); diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index e1ba2a69420..a0d19c3de2a 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -141,7 +141,8 @@ body.gl-dark { } } -.timeline-entry.internal-note:not(.note-form) { +.timeline-entry.internal-note:not(.note-form) .timeline-content, +.timeline-entry.draft-note:not(.note-form) .timeline-content { // soften on darkmode - background-color: mix($gray-50, $orange-50, 75%); + background-color: mix($gray-50, $orange-50, 75%) !important; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index bdb8f758137..4be4fc82d04 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -75,10 +75,8 @@ // .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466 .gl-font-size-inherit, .font-size-inherit { font-size: inherit; } -.gl-w-8 { width: px-to-rem($grid-size); } .gl-w-16 { width: px-to-rem($grid-size * 2); } .gl-w-64 { width: px-to-rem($grid-size * 8); } -.gl-h-8 { height: px-to-rem($grid-size); } .gl-h-32 { height: px-to-rem($grid-size * 4); } .gl-h-64 { height: px-to-rem($grid-size * 8); } @@ -119,13 +117,6 @@ flex-basis: 25%; } -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168 -.gl-md-ml-3 { - @media (min-width: $breakpoint-md) { - margin-left: $gl-spacing-scale-3; - } -} - // 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 @@ -152,48 +143,6 @@ display: flex; } -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1085 -.gl-md-flex-direction-column { - @media (min-width: $breakpoint-md) { - flex-direction: column; - } -} - -// Same as above -.gl-md-flex-direction-column\! { - @media (min-width: $breakpoint-md) { - flex-direction: column !important; - } -} - -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1165 -.gl-xs-mb-4 { - @media (max-width: $breakpoint-sm) { - margin-bottom: $gl-spacing-scale-4; - } -} - -// Same as above -.gl-xs-mb-4\! { - @media (max-width: $breakpoint-sm) { - margin-bottom: $gl-spacing-scale-4 !important; - } -} - -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168 -.gl-sm-pr-3 { - @media (min-width: $breakpoint-sm) { - padding-right: $gl-spacing-scale-3; - } -} - -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1168 -.gl-sm-w-half { - @media (min-width: $breakpoint-sm) { - width: 50%; - } -} - .gl-sm-mr-3 { @include media-breakpoint-up(sm) { margin-right: $gl-spacing-scale-3; @@ -206,21 +155,10 @@ } } -.gl-mb-n3 { - margin-bottom: -$gl-spacing-scale-3; -} - .gl-mr-n2 { margin-right: -$gl-spacing-scale-2; } -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1408 -$gl-line-height-42: px-to-rem(42px); - -.gl-line-height-42 { - line-height: $gl-line-height-42; -} - .gl-w-grid-size-30 { width: $grid-size * 30; } @@ -229,26 +167,6 @@ $gl-line-height-42: px-to-rem(42px); width: $grid-size * 40; } -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209 -.gl-max-w-none\! { - max-width: none !important; -} - -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209 -.gl-max-h-none\! { - max-height: none !important; -} - -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655 -.gl-max-w-62 { - max-width: $grid-size * 62; -} - -// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655 -.gl-max-w-26 { - max-width: $grid-size * 26; -} - .gl-max-w-50p { max-width: 50%; } @@ -271,36 +189,15 @@ $gl-line-height-42: px-to-rem(42px); } } -// Will both be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1465 -.gl-text-transparent { - color: transparent; -} - +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1465 .gl-focus-ring-border-1-gray-900\! { @include gl-focus($gl-border-size-1, $gray-900, true); } -// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2600 -.gl-pr-10 { - padding-right: $gl-spacing-scale-10; -} - /* 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-sm-grid-template-columns-2 { - @include media-breakpoint-up(sm) { - grid-template-columns: 1fr 1fr; - } -} - -.gl-md-grid-template-columns-2 { - @include media-breakpoint-up(md) { - grid-template-columns: 1fr 1fr; - } -} - .gl-md-grid-template-columns-3 { @include media-breakpoint-up(md) { grid-template-columns: repeat(3, 1fr); @@ -313,10 +210,6 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } -.gl-gap-6 { - gap: $gl-spacing-scale-6; -} - .gl-max-w-48 { max-width: $gl-spacing-scale-48; } @@ -346,18 +239,6 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 /* End gitlab-ui#1709 */ /* - * The below two styles will be moved to @gitlab/ui by - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1750 - */ -.gl-max-w-34 { - max-width: 34 * $grid-size; -} - -.gl-max-w-80 { - max-width: 80 * $grid-size; -} - -/* * The below style will be moved to @gitlab/ui by * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1751 */ @@ -370,13 +251,3 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 .gl-flex-flow-row-wrap { flex-flow: row wrap; } - -/* - * The below style will be moved to @gitlab/ui by - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1963 - */ -.gl-gap-y-3 { - > * + * { - margin-top: $gl-spacing-scale-3; - } -} |