diff options
Diffstat (limited to 'app')
1382 files changed, 16680 insertions, 9921 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; - } -} diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb index cfab34f537e..4475f4cde6e 100644 --- a/app/components/pajamas/alert_component.rb +++ b/app/components/pajamas/alert_component.rb @@ -12,8 +12,8 @@ module Pajamas def initialize( title: nil, variant: :info, dismissible: true, show_icon: true, alert_options: {}, close_button_options: {}) - @title = title - @variant = variant + @title = title.presence + @variant = filter_attribute(variant&.to_sym, VARIANT_ICONS.keys, default: :info) @dismissible = dismissible @show_icon = show_icon @alert_options = alert_options @@ -35,7 +35,7 @@ module Pajamas renders_one :body renders_one :actions - ICONS = { + VARIANT_ICONS = { info: 'information-o', warning: 'warning', success: 'check-circle', @@ -44,7 +44,7 @@ module Pajamas }.freeze def icon - ICONS[@variant] + VARIANT_ICONS[@variant] end def icon_classes diff --git a/app/components/pajamas/progress_component.html.haml b/app/components/pajamas/progress_component.html.haml new file mode 100644 index 00000000000..9368fe8b161 --- /dev/null +++ b/app/components/pajamas/progress_component.html.haml @@ -0,0 +1,2 @@ +.progress + .progress-bar{ class: "bg-#{@variant}", style: "width: #{@value}%;" } diff --git a/app/components/pajamas/progress_component.rb b/app/components/pajamas/progress_component.rb new file mode 100644 index 00000000000..1365da13863 --- /dev/null +++ b/app/components/pajamas/progress_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Pajamas + class ProgressComponent < Pajamas::Component + def initialize(value: 0, variant: :primary) + @value = value + @variant = filter_attribute(variant, VARIANT_OPTIONS, default: :primary) + end + + VARIANT_OPTIONS = [:primary, :success].freeze + end +end diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 251ba9e29f2..edd85414696 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -10,6 +10,9 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def index + push_frontend_feature_flag(:vue_broadcast_messages, current_user) + push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user) + @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) @broadcast_message = BroadcastMessage.new end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 2ae0442c005..f3c4244269d 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -60,17 +60,6 @@ class Admin::GroupsController < Admin::ApplicationController end end - def members_update - member_params = params.permit(:user_id, :access_level, :expires_at) - result = Members::CreateService.new(current_user, member_params.merge(limit: -1, source: @group, invite_source: 'admin-group-page')).execute - - if result[:status] == :success - redirect_to [:admin, @group], notice: _('Users were successfully added.') - else - redirect_to [:admin, @group], alert: result[:message] - end - end - def destroy Groups::DestroyService.new(@group, current_user).async_execute diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb index eb279298baf..9d884478e98 100644 --- a/app/controllers/admin/impersonation_tokens_controller.rb +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -14,11 +14,10 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController @impersonation_token = finder.build(impersonation_token_params) if @impersonation_token.save - PersonalAccessToken.redis_store!(current_user.id, @impersonation_token.token) - redirect_to admin_user_impersonation_tokens_path, notice: _("A new impersonation token has been created.") + render json: { new_token: @impersonation_token.token, + active_access_tokens: active_impersonation_tokens }, status: :ok else - set_index_vars - render :index + render json: { errors: @impersonation_token.errors.full_messages }, status: :unprocessable_entity end end @@ -50,19 +49,19 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController PersonalAccessTokensFinder.new({ user: user, impersonation: true }.merge(options)) end + def active_impersonation_tokens + tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute + ::ImpersonationAccessTokenSerializer.new.represent(tokens) + end + def impersonation_token_params params.require(:personal_access_token).permit(:name, :expires_at, :impersonation, scopes: []) end - # rubocop: disable CodeReuse/ActiveRecord def set_index_vars @scopes = Gitlab::Auth.available_scopes_for(current_user) @impersonation_token ||= finder.build - @inactive_impersonation_tokens = finder(state: 'inactive').execute - @active_impersonation_tokens = finder(state: 'active').execute.order(:expires_at) - - @new_impersonation_token = PersonalAccessToken.redis_getdel(current_user.id) + @active_impersonation_tokens = active_impersonation_tokens end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index a0f72f5e58c..96fe0c9331d 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -4,13 +4,6 @@ class Admin::RunnersController < Admin::ApplicationController include RunnerSetupScripts before_action :runner, except: [:index, :tag_list, :runner_setup_scripts] - before_action only: [:index] do - push_frontend_feature_flag(:admin_runners_bulk_delete) - end - - before_action only: [:show] do - push_frontend_feature_flag(:enforce_runner_token_expires_at) - end feature_category :runner urgency :low diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 71d9910b4b8..84efb8b0da8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -16,7 +16,6 @@ class ApplicationController < ActionController::Base include SessionlessAuthentication include SessionsHelper include ConfirmEmailWarning - include Gitlab::Experimentation::ControllerConcern include InitializesCurrentUserMode include Impersonation include Gitlab::Logging::CloudflareHelper diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 88592efcec7..45585ab84b4 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -25,7 +25,20 @@ class AutocompleteController < ApplicationController .new(params: params, current_user: current_user, project: project, group: group) .execute - render json: UserSerializer.new(params.merge({ current_user: current_user })).represent(users, project: project) + presented_users = UserSerializer + .new(params.merge({ current_user: current_user })) + .represent(users, project: project) + + extra_users = presented_suggested_users + + if extra_users.present? + presented_users.reject! do |user| + extra_users.any? { |suggested_user| suggested_user[:id] == user[:id] } + end + presented_users += extra_users + end + + render json: presented_users end def user @@ -80,6 +93,11 @@ class AutocompleteController < ApplicationController def target_branch_params params.permit(:group_id, :project_id).select { |_, v| v.present? } end + + # overridden in EE + def presented_suggested_users + [] + end end AutocompleteController.prepend_mod_with('AutocompleteController') diff --git a/app/controllers/boards/application_controller.rb b/app/controllers/boards/application_controller.rb deleted file mode 100644 index 15ef6698472..00000000000 --- a/app/controllers/boards/application_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Boards - class ApplicationController < ::ApplicationController - respond_to :json - - rescue_from ActiveRecord::RecordNotFound, with: :record_not_found - - private - - def board - @board ||= Board.find(params[:board_id]) - end - - def board_parent - @board_parent ||= board.resource_parent - end - - def record_not_found(exception) - render json: { error: exception.message }, status: :not_found - end - end -end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb deleted file mode 100644 index 5028544795c..00000000000 --- a/app/controllers/boards/issues_controller.rb +++ /dev/null @@ -1,162 +0,0 @@ -# frozen_string_literal: true - -module Boards - class IssuesController < Boards::ApplicationController - # This is the maximum amount of issues which can be moved by one request to - # bulk_move for now. This is temporary and might be removed in future by - # introducing an alternative (async?) approach. - # (related: https://gitlab.com/groups/gitlab-org/-/epics/382) - MAX_MOVE_ISSUES_COUNT = 50 - - include BoardsResponses - include ControllerWithCrossProjectAccessCheck - - requires_cross_project_access if: -> { board&.group_board? } - - before_action :disable_query_limiting, only: [:bulk_move] - before_action :authorize_read_issue, only: [:index] - before_action :authorize_create_issue, only: [:create] - before_action :authorize_update_issue, only: [:update] - skip_before_action :authenticate_user!, only: [:index] - before_action :validate_id_list, only: [:bulk_move] - before_action :can_move_issues?, only: [:bulk_move] - - feature_category :team_planning - urgency :low - - def index - list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params) - issues = issues_from(list_service) - - ::Boards::Issues::ListService.initialize_relative_positions(board, current_user, issues) - - render_issues(issues, list_service.metadata) - end - - def create - service = Boards::Issues::CreateService.new(board_parent, project, current_user, issue_params) - issue = service.execute - - if issue.valid? - render json: serialize_as_json(issue) - else - render json: issue.errors, status: :unprocessable_entity - end - end - - def bulk_move - service = Boards::Issues::MoveService.new(board_parent, current_user, move_params(true)) - - issues = Issue.find(params[:ids]) - - render json: service.execute_multiple(issues) - end - - def update - service = Boards::Issues::MoveService.new(board_parent, current_user, move_params) - - if service.execute(issue) - head :ok - else - head :unprocessable_entity - end - end - - private - - def issues_from(list_service) - issues = list_service.execute - issues.page(params[:page]).per(params[:per] || 20) - .without_count - .preload(associations_to_preload) # rubocop: disable CodeReuse/ActiveRecord - .load - end - - def associations_to_preload - [ - :milestone, - :assignees, - project: [ - :route, - { - namespace: [:route] - } - ], - labels: [:priorities], - notes: [:award_emoji, :author] - ] - end - - def can_move_issues? - head(:forbidden) unless can?(current_user, :admin_issue, board) - end - - def serializer_options(issues) - {} - end - - def render_issues(issues, metadata) - data = { issues: serialize_as_json(issues, opts: serializer_options(issues)) } - data.merge!(metadata) - - render json: data - end - - def issue - @issue ||= issues_finder.find(params[:id]) - end - - def filter_params - params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id]) - .reject { |_, value| value.nil? } - end - - def issues_finder - if board.group_board? - IssuesFinder.new(current_user, group_id: board_parent.id) - else - IssuesFinder.new(current_user, project_id: board_parent.id) - end - end - - def project - @project ||= if board.group_board? - Project.find(issue_params[:project_id]) - else - board_parent - end - end - - def move_params(multiple = false) - id_param = multiple ? :ids : :id - params.permit(id_param, :board_id, :from_list_id, :to_list_id, :move_before_id, :move_after_id) - end - - def issue_params - params.require(:issue) - .permit(:title, :milestone_id, :project_id) - .merge(board_id: params[:board_id], list_id: params[:list_id]) - end - - def serializer - IssueSerializer.new(current_user: current_user) - end - - def serialize_as_json(resource, opts: {}) - opts.merge!(include_full_project_path: board.group_board?, serializer: 'board') - - serializer.represent(resource, opts) - end - - def disable_query_limiting - Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/issues/35174') - end - - def validate_id_list - head(:bad_request) unless params[:ids].is_a?(Array) - head(:unprocessable_entity) if params[:ids].size > MAX_MOVE_ISSUES_COUNT - end - end -end - -Boards::IssuesController.prepend_mod_with('Boards::IssuesController') diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb deleted file mode 100644 index c3b5a887920..00000000000 --- a/app/controllers/boards/lists_controller.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -module Boards - class ListsController < Boards::ApplicationController - include BoardsResponses - - before_action :authorize_admin_list, only: [:create, :destroy, :generate] - before_action :authorize_read_list, only: [:index] - skip_before_action :authenticate_user!, only: [:index] - - feature_category :team_planning - urgency :low - - def index - lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board) - - List.preload_preferences_for_user(lists, current_user) - - render json: serialize_as_json(lists) - end - - def create - response = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board) - - if response.success? - render json: serialize_as_json(response.payload[:list]) - else - render json: { errors: response.errors }, status: :unprocessable_entity - end - end - - def update - list = board.lists.find(params[:id]) - service = Boards::Lists::UpdateService.new(board_parent, current_user, update_list_params) - result = service.execute(list) - - if result.success? - head :ok - else - head result.http_status - end - end - - def destroy - list = board.lists.destroyable.find(params[:id]) - service = Boards::Lists::DestroyService.new(board_parent, current_user) - - if service.execute(list).success? - head :ok - else - head :unprocessable_entity - end - end - - def generate - service = Boards::Lists::GenerateService.new(board_parent, current_user) - - if service.execute(board) - lists = board.lists.movable.preload_associated_models - - List.preload_preferences_for_user(lists, current_user) - - render json: serialize_as_json(lists) - else - head :unprocessable_entity - end - end - - private - - def list_creation_attrs - %i[label_id] - end - - def list_update_attrs - %i[collapsed position] - end - - def create_list_params - params.require(:list).permit(list_creation_attrs) - end - - def update_list_params - params.require(:list).permit(list_update_attrs) - end - - def serialize_as_json(resource) - resource.as_json(serialization_attrs) - end - - def serialization_attrs - { - only: [:id, :list_type, :position], - methods: [:title], - label: true, - collapsed: true, - current_user: current_user - } - end - end -end - -Boards::ListsController.prepend_mod_with('Boards::ListsController') diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb index 451841c43bb..6e43be5594d 100644 --- a/app/controllers/concerns/access_tokens_actions.rb +++ b/app/controllers/concerns/access_tokens_actions.rb @@ -22,11 +22,10 @@ module AccessTokensActions if token_response.success? @resource_access_token = token_response.payload[:access_token] - PersonalAccessToken.redis_store!(key_identity, @resource_access_token.token) - - redirect_to resource_access_tokens_path, notice: _("Your new access token has been created.") + render json: { new_token: @resource_access_token.token, + active_access_tokens: active_resource_access_tokens }, status: :ok else - redirect_to resource_access_tokens_path, alert: _("Failed to create new access token: %{token_response_message}") % { token_response_message: token_response.message } + render json: { errors: token_response.errors }, status: :unprocessable_entity end end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -63,12 +62,15 @@ module AccessTokensActions resource.members.load @scopes = Gitlab::Auth.resource_bot_scopes - @active_resource_access_tokens = finder(state: 'active').execute.preload_users - @inactive_resource_access_tokens = finder(state: 'inactive', sort: 'expires_at_asc').execute.preload_users - @new_resource_access_token = PersonalAccessToken.redis_getdel(key_identity) + @active_resource_access_tokens = active_resource_access_tokens end # rubocop:enable Gitlab/ModuleWithInstanceVariables + def active_resource_access_tokens + tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users + represent(tokens) + end + def finder(options = {}) PersonalAccessTokensFinder.new({ user: bot_users, impersonation: false }.merge(options)) end diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 4228a93d310..fbaa754124c 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -89,6 +89,7 @@ module AuthenticatesWithTwoFactor user.save! sign_in(user, message: :two_factor_authenticated, event: :authentication) else + send_two_factor_otp_attempt_failed_email(user) handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.')) end end @@ -158,6 +159,10 @@ module AuthenticatesWithTwoFactor prompt_for_two_factor(user) end + def send_two_factor_otp_attempt_failed_email(user) + user.notification_service.two_factor_otp_attempt_failed(user, request.remote_ip) + end + def log_failed_two_factor(user, method) # overridden in EE end diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb index 2f9edfad12d..42bf6c68aa7 100644 --- a/app/controllers/concerns/boards_actions.rb +++ b/app/controllers/concerns/boards_actions.rb @@ -5,41 +5,38 @@ module BoardsActions extend ActiveSupport::Concern included do - include BoardsResponses - before_action :authorize_read_board!, only: [:index, :show] - before_action :boards, only: :index - before_action :board, only: :show + before_action :redirect_to_recent_board, only: [:index] + before_action :board, only: [:index, :show] before_action :push_licensed_features, only: [:index, :show] end def index - respond_with_boards + # if no board exists, create one + @board = board_create_service.execute.payload unless board # rubocop:disable Gitlab/ModuleWithInstanceVariables end def show - # Add / update the board in the recent visits table - board_visit_service.new(parent, current_user).execute(board) if request.format.html? + return render_404 unless board - respond_with_board + # Add / update the board in the recent visits table + board_visit_service.new(parent, current_user).execute(board) end private - # Noop on FOSS - def push_licensed_features + def redirect_to_recent_board + return if !parent.multiple_issue_boards_available? || !latest_visited_board + + redirect_to board_path(latest_visited_board.board) end - def boards - strong_memoize(:boards) do - existing_boards = boards_finder.execute - if existing_boards.any? - existing_boards - else - # if no board exists, create one - [board_create_service.execute.payload] - end - end + def latest_visited_board + @latest_visited_board ||= Boards::VisitsFinder.new(parent, current_user).latest + end + + # Noop on FOSS + def push_licensed_features end def board @@ -48,20 +45,26 @@ module BoardsActions end end - def board_type - board_klass.to_type - end - def board_visit_service Boards::Visits::CreateService end - def serializer - BoardSerializer.new(current_user: current_user) + def parent + strong_memoize(:parent) do + group? ? group : project + end + end + + def board_path(board) + if group? + group_board_path(parent, board) + else + project_board_path(parent, board) + end end - def serialize_as_json(resource) - serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) + def group? + instance_variable_defined?(:@group) end end diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb deleted file mode 100644 index eb7392648a1..00000000000 --- a/app/controllers/concerns/boards_responses.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -module BoardsResponses - include Gitlab::Utils::StrongMemoize - - # Overridden on EE module - def board_params - params.require(:board).permit(:name) - end - - def parent - strong_memoize(:parent) do - group? ? group : project - end - end - - def boards_path - if group? - group_boards_path(parent) - else - project_boards_path(parent) - end - end - - def board_path(board) - if group? - group_board_path(parent, board) - else - project_board_path(parent, board) - end - end - - def group? - instance_variable_defined?(:@group) - end - - def authorize_read_list - authorize_action_for!(board, :read_issue_board_list) - end - - def authorize_read_issue - authorize_action_for!(board, :read_issue) - end - - def authorize_update_issue - authorize_action_for!(issue, :admin_issue) - end - - def authorize_create_issue - list = List.find(issue_params[:list_id]) - action = list.backlog? ? :create_issue : :admin_issue - - authorize_action_for!(project, action) - end - - def authorize_admin_list - authorize_action_for!(board, :admin_issue_board_list) - end - - def authorize_action_for!(resource, ability) - return render_403 unless can?(current_user, ability, resource) - end - - def respond_with_boards - respond_with(@boards) # rubocop:disable Gitlab/ModuleWithInstanceVariables - end - - def respond_with_board - # rubocop:disable Gitlab/ModuleWithInstanceVariables - return render_404 unless @board - - respond_with(@board) - # rubocop:enable Gitlab/ModuleWithInstanceVariables - end - - def serialize_as_json(resource) - serializer.represent(resource).as_json - end - - def respond_with(resource) - respond_to do |format| - format.html - format.json do - render json: serialize_as_json(resource) - end - end - end - - def serializer - BoardSerializer.new - end -end - -BoardsResponses.prepend_mod_with('BoardsResponses') diff --git a/app/controllers/concerns/import/github_oauth.rb b/app/controllers/concerns/import/github_oauth.rb new file mode 100644 index 00000000000..d53022aabf2 --- /dev/null +++ b/app/controllers/concerns/import/github_oauth.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Import + module GithubOauth + extend ActiveSupport::Concern + + OAuthConfigMissingError = Class.new(StandardError) + + included do + rescue_from OAuthConfigMissingError, with: :missing_oauth_config + end + + private + + def provider_auth + return if session[access_token_key].present? + + go_to_provider_for_permissions unless ci_cd_only? + end + + def ci_cd_only? + %w[1 true].include?(params[:ci_cd_only]) + end + + def go_to_provider_for_permissions + redirect_to authorize_url + end + + def oauth_client + raise OAuthConfigMissingError unless oauth_config + + oauth_client_from_config + end + + def oauth_client_from_config + @oauth_client_from_config ||= ::OAuth2::Client.new( + oauth_config.app_id, + oauth_config.app_secret, + oauth_options.merge(ssl: { verify: oauth_config['verify_ssl'] }) + ) + end + + def oauth_config + @oauth_config ||= Gitlab::Auth::OAuth::Provider.config_for('github') + end + + def oauth_options + return unless oauth_config + + oauth_config.dig('args', 'client_options').deep_symbolize_keys + end + + def authorize_url + state = SecureRandom.base64(64) + session[auth_state_key] = state + if Feature.enabled?(:remove_legacy_github_client) + oauth_client.auth_code.authorize_url( + redirect_uri: callback_import_url, + scope: 'repo, user, user:email', + state: state + ) + else + client.authorize_url(callback_import_url, state) + end + end + + def get_token(code) + if Feature.enabled?(:remove_legacy_github_client) + oauth_client.auth_code.get_token(code).token + else + client.get_token(code) + end + end + + def missing_oauth_config + session[access_token_key] = nil + + message = _('Missing OAuth configuration for GitHub.') + + respond_to do |format| + format.json do + render json: { errors: message }, status: :unauthorized + end + + format.any do + redirect_to new_import_url, + alert: message + end + end + end + + def callback_import_url + public_send("users_import_#{provider_name}_callback_url", extra_import_params.merge({ namespace_id: params[:namespace_id] })) # rubocop:disable GitlabSecurity/PublicSend + end + + def extra_import_params + {} + end + end +end diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index 96cf6021ea9..e03d1de7bf9 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -59,9 +59,12 @@ module IssuableCollectionsAction end def finder_options + issue_types = Issue::TYPES_FOR_LIST + issue_types = issue_types.excluding('task') unless Feature.enabled?(:work_items) + super.merge( non_archived: true, - issue_types: Issue::TYPES_FOR_LIST + issue_types: issue_types ) end end diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb deleted file mode 100644 index 685c93fc2a2..00000000000 --- a/app/controllers/concerns/multiple_boards_actions.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module MultipleBoardsActions - include Gitlab::Utils::StrongMemoize - extend ActiveSupport::Concern - - included do - include BoardsActions - - before_action :redirect_to_recent_board, only: [:index] - before_action :authenticate_user!, only: [:recent] - before_action :authorize_create_board!, only: [:create] - before_action :authorize_admin_board!, only: [:create, :update, :destroy] - end - - def recent - recent_visits = ::Boards::VisitsFinder.new(parent, current_user).latest(Board::RECENT_BOARDS_SIZE) - recent_boards = recent_visits.map(&:board) - - render json: serialize_as_json(recent_boards) - end - - def create - response = Boards::CreateService.new(parent, current_user, board_params).execute - - respond_to do |format| - format.json do - board = response.payload - - if response.success? - extra_json = { board_path: board_path(board) } - render json: serialize_as_json(board).merge(extra_json) - else - render json: board.errors, status: :unprocessable_entity - end - end - end - end - - def update - service = Boards::UpdateService.new(parent, current_user, board_params) - - respond_to do |format| - format.json do - if service.execute(board) - extra_json = { board_path: board_path(board) } - render json: serialize_as_json(board).merge(extra_json) - else - render json: board.errors, status: :unprocessable_entity - end - end - end - end - - def destroy - service = Boards::DestroyService.new(parent, current_user) - service.execute(board) - - respond_to do |format| - format.json { head :ok } - format.html { redirect_to boards_path, status: :found } - end - end - - private - - def redirect_to_recent_board - return unless board_type == Board.to_type - return if request.format.json? || !parent.multiple_issue_boards_available? || !latest_visited_board - - redirect_to board_path(latest_visited_board.board) - end - - def latest_visited_board - @latest_visited_board ||= Boards::VisitsFinder.new(parent, current_user).latest - end - - def authorize_create_board! - check_multiple_group_issue_boards_available! if group? - end - - def authorize_admin_board! - return render_404 unless can?(current_user, :admin_issue_board, parent) - end - - def serializer - BoardSerializer.new(current_user: current_user) - end - - def serialize_as_json(resource) - serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) - end -end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 1d2f9e31c46..79b3fa28660 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -26,16 +26,24 @@ module PreviewMarkdown } end + def timeline_events_filter_params + { + issuable_reference_expansion_enabled: true, + pipeline: :'incident_management/timeline_event' + } + end + def markdown_service_params params end def markdown_context_params case controller_name - when 'wikis' then { pipeline: :wiki, wiki: wiki, page_slug: params[:id] } - when 'snippets' then { skip_project_check: true } - when 'groups' then { group: group } - when 'projects' then projects_filter_params + when 'wikis' then { pipeline: :wiki, wiki: wiki, page_slug: params[:id] } + when 'snippets' then { skip_project_check: true } + when 'groups' then { group: group } + when 'projects' then projects_filter_params + when 'timeline_events' then timeline_events_filter_params else {} end.merge(requested_path: params[:path], ref: params[:ref]) end diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index 8e936782e5a..4f96cc5c895 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -29,7 +29,13 @@ module ProductAnalyticsTracking track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll) if destinations.include?(:snowplow) && event_enabled?(name) - Gitlab::Tracking.event(self.class.to_s, name, namespace: tracking_namespace_source, user: current_user) + Gitlab::Tracking.event( + self.class.to_s, + name, + namespace: tracking_namespace_source, + user: current_user, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: name).to_context] + ) end end @@ -49,6 +55,7 @@ module ProductAnalyticsTracking user: current_user, property: name, label: label, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: name).to_context], **optional_arguments ) end diff --git a/app/controllers/concerns/registrations_tracking.rb b/app/controllers/concerns/registrations_tracking.rb new file mode 100644 index 00000000000..14743349c1a --- /dev/null +++ b/app/controllers/concerns/registrations_tracking.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RegistrationsTracking + extend ActiveSupport::Concern + + included do + helper_method :glm_tracking_params + end + + private + + def glm_tracking_params + params.permit(:glm_source, :glm_content) + end +end diff --git a/app/controllers/concerns/sends_blob.rb b/app/controllers/concerns/sends_blob.rb index 381f2eba352..3cf260c9f1b 100644 --- a/app/controllers/concerns/sends_blob.rb +++ b/app/controllers/concerns/sends_blob.rb @@ -27,12 +27,14 @@ module SendsBlob private def cached_blob?(blob, allow_caching: false) - stale = stale?(etag: blob.id) # The #stale? method sets cache headers. - - # Because we are opinionated we set the cache headers ourselves. - response.cache_control[:public] = allow_caching + stale = + if Feature.enabled?(:improve_blobs_cache_headers) + stale?(strong_etag: blob.id) + else + stale?(etag: blob.id) + end - response.cache_control[:max_age] = + max_age = if @ref && @commit && @ref == @commit.id # rubocop:disable Gitlab/ModuleWithInstanceVariables # This is a link to a commit by its commit SHA. That means that the blob # is immutable. The only reason to invalidate the cache is if the commit @@ -44,6 +46,16 @@ module SendsBlob Blob::CACHE_TIME end + # Because we are opinionated we set the cache headers ourselves. + if Feature.enabled?(:improve_blobs_cache_headers) + expires_in(max_age, + public: allow_caching, must_revalidate: true, stale_if_error: 5.minutes, + stale_while_revalidate: 1.minute, 's-maxage': 1.minute) + else + response.cache_control[:public] = allow_caching + response.cache_control[:max_age] = max_age + end + !stale end diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 83447744013..2b781c528ad 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -209,9 +209,7 @@ module WikiActions def wiki strong_memoize(:wiki) do wiki = Wiki.for_container(container, current_user) - - # Call #wiki to make sure the Wiki Repo is initialized - wiki.wiki + wiki.create_wiki_repository wiki end @@ -242,7 +240,7 @@ module WikiActions def wiki_pages strong_memoize(:wiki_pages) do Kaminari.paginate_array( - wiki.list_pages(sort: params[:sort], direction: params[:direction]) + wiki.list_pages(direction: params[:direction]) ).page(params[:page]) end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index aec3247f4b2..f8cfa996447 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -67,6 +67,10 @@ class Groups::ApplicationController < ApplicationController end end + def authorize_billings_page! + render_404 unless can?(current_user, :read_billing, group) + end + def authorize_read_group_member! unless can?(current_user, :read_group_member, group) render_403 diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index e64d838b7d1..14b70df0feb 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -5,7 +5,6 @@ class Groups::BoardsController < Groups::ApplicationController include RecordUserLastActivity include Gitlab::Utils::StrongMemoize - before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:board_multi_select, group) push_frontend_feature_flag(:realtime_labels, group) @@ -20,16 +19,6 @@ class Groups::BoardsController < Groups::ApplicationController private - def board_klass - Board - end - - def boards_finder - strong_memoize :boards_finder do - Boards::BoardsFinder.new(parent, current_user) - end - end - def board_finder strong_memoize :board_finder do Boards::BoardsFinder.new(parent, current_user, board_id: params[:id]) @@ -42,10 +31,6 @@ class Groups::BoardsController < Groups::ApplicationController end end - def assign_endpoint_vars - @boards_endpoint = group_boards_path(group) - end - def authorize_read_board! access_denied! unless can?(current_user, :read_issue_board, group) end diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 652f12e34ba..18b055b3f05 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -2,13 +2,9 @@ class Groups::RunnersController < Groups::ApplicationController before_action :authorize_read_group_runners!, only: [:index, :show] - before_action :authorize_admin_group_runners!, only: [:edit, :update, :destroy, :pause, :resume] + before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume] before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] - before_action only: [:show] do - push_frontend_feature_flag(:enforce_runner_token_expires_at) - end - feature_category :runner urgency :low @@ -37,7 +33,9 @@ class Groups::RunnersController < Groups::ApplicationController private def runner - @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute + group_params = { group: @group, membership: :all_available } + + @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: group_params).execute .except(:limit, :offset) .find(params[:id]) end @@ -45,6 +43,12 @@ class Groups::RunnersController < Groups::ApplicationController def runner_params params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) end + + def authorize_update_runner! + return if can?(current_user, :admin_group_runners, group) && can?(current_user, :update_runner, runner) + + render_404 + end end Groups::RunnersController.prepend_mod diff --git a/app/controllers/groups/settings/access_tokens_controller.rb b/app/controllers/groups/settings/access_tokens_controller.rb index b9ab2e008cc..f01b2b779e3 100644 --- a/app/controllers/groups/settings/access_tokens_controller.rb +++ b/app/controllers/groups/settings/access_tokens_controller.rb @@ -13,6 +13,12 @@ module Groups def resource_access_tokens_path group_settings_access_tokens_path end + + private + + def represent(tokens) + ::GroupAccessTokenSerializer.new.represent(tokens, group: resource) + end end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 9316204d89c..269342a6c22 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -81,9 +81,9 @@ class GroupsController < Groups::ApplicationController successful_creation_hooks notice = if @group.chat_team.present? - "Group '#{@group.name}' and its Mattermost team were successfully created." + format(_("Group %{group_name} and its Mattermost team were successfully created."), group_name: @group.name) else - "Group '#{@group.name}' was successfully created." + format(_("Group %{group_name} was successfully created."), group_name: @group.name) end redirect_to @group, notice: notice @@ -393,7 +393,7 @@ class GroupsController < Groups::ApplicationController end def captcha_enabled? - Gitlab::Recaptcha.enabled? && Feature.enabled?(:recaptcha_on_top_level_group_creation, type: :ops) + helpers.recaptcha_enabled? && Feature.enabled?(:recaptcha_on_top_level_group_creation, type: :ops) end def captcha_required? diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index 58a985cbc46..fcf6871d137 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class IdeController < ApplicationController - layout 'fullscreen' - include ClientsidePreviewCSP include StaticObjectExternalStorageCSP include Gitlab::Utils::StrongMemoize @@ -13,7 +11,6 @@ class IdeController < ApplicationController push_frontend_feature_flag(:build_service_proxy) push_frontend_feature_flag(:schema_linting) push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab) - push_frontend_feature_flag(:vscode_web_ide, current_user) define_index_vars end @@ -28,6 +25,8 @@ class IdeController < ApplicationController Gitlab::Tracking.event(self.class.to_s, 'web_ide_views', namespace: project&.namespace, user: current_user) end + + render layout: 'fullscreen', locals: { minimal: helpers.use_new_web_ide? } end private diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 893c0b6ac54..655fc7854fe 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -47,6 +47,8 @@ class Import::BulkImportsController < ApplicationController end def create + return render json: { success: false }, status: :unprocessable_entity unless valid_create_params? + responses = create_params.map do |entry| if entry[:destination_name] entry[:destination_slug] ||= entry[:destination_name] @@ -102,6 +104,10 @@ class Import::BulkImportsController < ApplicationController params.permit(bulk_import: bulk_import_params)[:bulk_import] end + def valid_create_params? + create_params.all? { _1[:source_type] == 'group_entity' } + end + def bulk_import_params %i[ source_type @@ -113,7 +119,7 @@ class Import::BulkImportsController < ApplicationController end def ensure_group_import_enabled - render_404 unless Feature.enabled?(:bulk_import) + render_404 unless ::BulkImports::Features.enabled? end def access_token_key diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 8a3e6809736..92763e09ba3 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -5,14 +5,12 @@ class Import::GithubController < Import::BaseController include ImportHelper include ActionView::Helpers::SanitizeHelper + include Import::GithubOauth before_action :verify_import_enabled before_action :provider_auth, only: [:status, :realtime_changes, :create] before_action :expire_etag_cache, only: [:status, :create] - OAuthConfigMissingError = Class.new(StandardError) - - rescue_from OAuthConfigMissingError, with: :missing_oauth_config rescue_from Octokit::Unauthorized, with: :provider_unauthorized rescue_from Octokit::TooManyRequests, with: :provider_rate_limit rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded @@ -73,6 +71,17 @@ class Import::GithubController < Import::BaseController } end + def cancel + project = Project.imported_from(provider_name).find(params[:project_id]) + result = Import::Github::CancelProjectImportService.new(project, current_user).execute + + if result[:status] == :success + render json: serialized_imported_projects(result[:project]) + else + render json: { errors: result[:message] }, status: result[:http_status] + end + end + protected override :importable_repos @@ -104,7 +113,7 @@ class Import::GithubController < Import::BaseController end def permitted_import_params - [:repo_id, :new_name, :target_namespace] + [:repo_id, :new_name, :target_namespace, { optional_stages: {} }] end def serialized_imported_projects(projects = already_added_projects) @@ -143,58 +152,10 @@ class Import::GithubController < Import::BaseController @filter = @filter&.tr(' ', '')&.tr(':', '') end - def oauth_client - raise OAuthConfigMissingError unless oauth_config - - @oauth_client ||= ::OAuth2::Client.new( - oauth_config.app_id, - oauth_config.app_secret, - oauth_options.merge(ssl: { verify: oauth_config['verify_ssl'] }) - ) - end - - def oauth_config - @oauth_config ||= Gitlab::Auth::OAuth::Provider.config_for('github') - end - - def oauth_options - if oauth_config - oauth_config.dig('args', 'client_options').deep_symbolize_keys - else - OmniAuth::Strategies::GitHub.default_options[:client_options].symbolize_keys - end - end - - def authorize_url - state = SecureRandom.base64(64) - session[auth_state_key] = state - if Feature.enabled?(:remove_legacy_github_client) - oauth_client.auth_code.authorize_url( - redirect_uri: callback_import_url, - scope: 'repo, user, user:email', - state: state - ) - else - client.authorize_url(callback_import_url, state) - end - end - - def get_token(code) - if Feature.enabled?(:remove_legacy_github_client) - oauth_client.auth_code.get_token(code).token - else - client.get_token(code) - end - end - def verify_import_enabled render_404 unless import_enabled? end - def go_to_provider_for_permissions - redirect_to authorize_url - end - def import_enabled? __send__("#{provider_name}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend end @@ -211,10 +172,6 @@ class Import::GithubController < Import::BaseController public_send("status_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: params[:namespace_id].presence })) # rubocop:disable GitlabSecurity/PublicSend end - def callback_import_url - public_send("users_import_#{provider_name}_callback_url", extra_import_params.merge({ namespace_id: params[:namespace_id] })) # rubocop:disable GitlabSecurity/PublicSend - end - def provider_unauthorized session[access_token_key] = nil redirect_to new_import_url, @@ -228,12 +185,6 @@ class Import::GithubController < Import::BaseController alert: _("GitHub API rate limit exceeded. Try again after %{reset_time}") % { reset_time: reset_time } end - def missing_oauth_config - session[access_token_key] = nil - redirect_to new_import_url, - alert: _('Missing OAuth configuration for GitHub.') - end - def auth_state_key :"#{provider_name}_auth_state_key" end @@ -252,24 +203,10 @@ class Import::GithubController < Import::BaseController end # rubocop: enable CodeReuse/ActiveRecord - def provider_auth - if !ci_cd_only? && session[access_token_key].blank? - go_to_provider_for_permissions - end - end - - def ci_cd_only? - %w[1 true].include?(params[:ci_cd_only]) - end - def client_options { wait_for_rate_limit_reset: false } end - def extra_import_params - {} - end - def rate_limit_threshold_exceeded head :too_many_requests end diff --git a/app/controllers/import/github_groups_controller.rb b/app/controllers/import/github_groups_controller.rb new file mode 100644 index 00000000000..6c0773bcfb3 --- /dev/null +++ b/app/controllers/import/github_groups_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Import + class GithubGroupsController < ApplicationController + include Import::GithubOauth + + before_action :provider_auth, only: [:status] + feature_category :importers + + PAGE_LENGTH = 25 + + def status + respond_to do |format| + format.json do + render json: { provider_groups: serialized_provider_groups } + end + end + end + + private + + def serialized_provider_groups + Import::GithubOrgSerializer.new.represent(importable_orgs) + end + + def importable_orgs + client_orgs.to_a + end + + def client_orgs + @client_orgs ||= client.octokit.organizations(nil, pagination_options) + end + + def client + @client ||= Gitlab::GithubImport::Client.new(session[access_token_key]) + end + + def pagination_options + { + page: [1, params[:page].to_i].max, + per_page: PAGE_LENGTH + } + end + + def auth_state_key + :"#{provider_name}_auth_state_key" + end + + def access_token_key + :"#{provider_name}_access_token" + end + + def provider_name + :github + end + end +end diff --git a/app/controllers/jira_connect/public_keys_controller.rb b/app/controllers/jira_connect/public_keys_controller.rb new file mode 100644 index 00000000000..b3144993edb --- /dev/null +++ b/app/controllers/jira_connect/public_keys_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module JiraConnect + class PublicKeysController < ::ApplicationController + # This is not inheriting from JiraConnect::Application controller because + # it doesn't need to handle JWT authentication. + + feature_category :integrations + + skip_before_action :authenticate_user! + + def show + return render_404 if Feature.disabled?(:jira_connect_oauth_self_managed) || !Gitlab.com? + + render plain: public_key.key + end + + private + + def public_key + JiraConnect::PublicKey.find(params[:id]) + end + end +end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index ff466fd5fbb..3b78b997da1 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -4,7 +4,6 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include Gitlab::GonHelper include PageLayoutHelper include OauthApplications - include Gitlab::Experimentation::ControllerConcern include InitializesCurrentUserMode # Defined by the `Doorkeeper::ApplicationsController` and is redundant as we call `authenticate_user!` below. Not diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 2e9fbb1d0d9..bf8b61db2e5 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController - include Gitlab::Experimentation::ControllerConcern include InitializesCurrentUserMode include Gitlab::Utils::StrongMemoize diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 817f272d458..f3f0ddd968a 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -181,6 +181,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end accept_pending_invitations(user: user) if new_user + persist_accepted_terms_if_required(user) if new_user + store_after_sign_up_path_for_user if intent_to_register? sign_in_and_redirect(user, event: :authentication) end @@ -301,6 +303,15 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController redirect_to new_admin_session_path, alert: _('Invalid login or password') end + def persist_accepted_terms_if_required(user) + return unless Feature.enabled?(:update_oauth_registration_flow) + return unless user.persisted? + return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms? + + terms = ApplicationSetting::Term.latest + Users::RespondToTermsService.new(user, terms).execute(accepted: true) + end + def store_after_sign_up_path_for_user store_location_for(:user, users_sign_up_welcome_path) end diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 8ed67c26f19..4cf26d3e1e2 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -3,6 +3,8 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController feature_category :authentication_and_authorization + before_action :check_personal_access_tokens_enabled + def index set_index_vars scopes = params[:scopes].split(',').map(&:squish).select(&:present?).map(&:to_sym) unless params[:scopes].nil? @@ -83,4 +85,8 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def page (params[:page] || 1).to_i end + + def check_personal_access_tokens_enabled + render_404 if Gitlab::CurrentSettings.personal_access_tokens_disabled? + end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 7aca76c2fb1..a57c87bf691 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -55,7 +55,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController :sourcegraph_enabled, :gitpod_enabled, :render_whitespace_in_code, - :markdown_surround_selection + :markdown_surround_selection, + :markdown_automatic_lists, + :use_legacy_web_ide ] end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 0b7d4626c6d..0933f2bb7ea 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -15,31 +15,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController feature_category :authentication_and_authorization def show - if two_factor_authentication_required? && !current_user.two_factor_enabled? - two_factor_authentication_reason( - global: lambda do - flash.now[:alert] = - _('The global settings require you to enable Two-Factor Authentication for your account.') - end, - group: lambda do |groups| - flash.now[:alert] = groups_notification(groups) - end - ) - - unless two_factor_grace_period_expired? - grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) } - end - end - - @qr_code = build_qr_code - @account_string = account_string - - if Feature.enabled?(:webauthn) - setup_webauthn_registration - else - setup_u2f_registration - end + setup_show_page end def create @@ -147,7 +123,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController current_user.increment_failed_attempts! - redirect_to profile_two_factor_auth_path, alert: _('You must provide a valid current password') + @error = { message: _('You must provide a valid current password') } + + setup_show_page + + render 'show' end def current_password_required? @@ -245,4 +225,32 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController redirect_to profile_emails_path, notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.') end end + + def setup_show_page + if two_factor_authentication_required? && !current_user.two_factor_enabled? + two_factor_authentication_reason( + global: lambda do + flash.now[:alert] = + _('The global settings require you to enable Two-Factor Authentication for your account.') + end, + group: lambda do |groups| + flash.now[:alert] = groups_notification(groups) + end + ) + + unless two_factor_grace_period_expired? + grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours + flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) } + end + end + + @qr_code = build_qr_code + @account_string = account_string + + if Feature.enabled?(:webauthn) + setup_webauthn_registration + else + setup_u2f_registration + end + end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 028b7af02c9..2256471047d 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -39,8 +39,8 @@ class Projects::ApplicationController < ApplicationController access_denied!( _('You must have developer or higher permissions in the associated project to view job logs when debug trace ' \ "is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' variable to 'false' in your pipeline " \ - 'configuration or CI/CD settings. If you need to view this job log, a project maintainer must add you to ' \ - 'the project with developer permissions or higher.') + 'configuration or CI/CD settings. If you need to view this job log, a project maintainer or owner must add ' \ + 'you to the project with developer permissions or higher.') ) else access_denied!(_('The current user is not authorized to access the job log.')) diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 9dbf989ca3f..7755effe1da 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -41,7 +41,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController end def contacts - render json: autocomplete_service.contacts + render json: autocomplete_service.contacts(target) end private @@ -51,9 +51,12 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController end def target + # type_id is not required in general + target_type = params.require(:type) + QuickActions::TargetService .new(project, current_user) - .execute(params[:type], params[:type_id]) + .execute(target_type, params[:type_id]) end def authorize_read_crm_contact! diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 2a20c67a23d..01ed5473b41 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -26,7 +26,10 @@ class Projects::BlameController < Projects::ApplicationController blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :no_pagination)) @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! + @blame_pagination = blame_service.pagination + + @blame_per_page = blame_service.per_page end end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 82b35a22669..6a6701ead15 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true class Projects::BoardsController < Projects::ApplicationController - include MultipleBoardsActions + include BoardsActions include IssuableCollections before_action :check_issues_available! - before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:board_multi_select, project) push_frontend_feature_flag(:realtime_labels, project&.group) @@ -20,16 +19,6 @@ class Projects::BoardsController < Projects::ApplicationController private - def board_klass - Board - end - - def boards_finder - strong_memoize :boards_finder do - Boards::BoardsFinder.new(parent, current_user) - end - end - def board_finder strong_memoize :board_finder do Boards::BoardsFinder.new(parent, current_user, board_id: params[:id]) @@ -42,11 +31,6 @@ class Projects::BoardsController < Projects::ApplicationController end end - def assign_endpoint_vars - @boards_endpoint = project_boards_path(project) - @bulk_issues_path = bulk_update_project_issues_path(project) - end - def authorize_read_board! access_denied! unless can?(current_user, :read_issue_board, project) end diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index d7fd65f02a8..61308f24412 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -47,7 +47,8 @@ class Projects::CompareController < Projects::ApplicationController from_to_vars = { from: compare_params[:from].presence, to: compare_params[:to].presence, - from_project_id: compare_params[:from_project_id].presence + from_project_id: compare_params[:from_project_id].presence, + straight: compare_params[:straight].presence } if from_to_vars[:from].blank? || from_to_vars[:to].blank? @@ -112,7 +113,11 @@ class Projects::CompareController < Projects::ApplicationController def compare return @compare if defined?(@compare) - @compare = CompareService.new(source_project, head_ref).execute(target_project, start_ref) + @compare = CompareService.new(source_project, head_ref).execute(target_project, start_ref, straight: straight) + end + + def straight + compare_params[:straight] == "true" end def start_ref @@ -160,6 +165,6 @@ class Projects::CompareController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def compare_params - @compare_params ||= params.permit(:from, :to, :from_project_id) + @compare_params ||= params.permit(:from, :to, :from_project_id, :straight) end end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 96afe9dbb9f..22a42d22914 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -27,11 +27,9 @@ class Projects::DeployKeysController < Projects::ApplicationController end def create - @key = DeployKeys::CreateService.new(current_user, create_params).execute(project: @project) + @key = DeployKeys::CreateService.new(current_user, create_params).execute(project: @project).present - unless @key.valid? - flash[:alert] = @key.errors.full_messages.join(', ').html_safe - end + flash[:alert] = @key.humanized_error_message unless @key.valid? redirect_to_repository end diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb index 8f7554f248b..77ee830fd24 100644 --- a/app/controllers/projects/google_cloud/databases_controller.rb +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -50,16 +50,15 @@ module Projects track_event(:error_enable_cloudsql_services) flash[:error] = error_message(enable_response[:message]) else - permitted_params = params.permit(:gcp_project, :ref, :database_version, :tier) create_response = ::GoogleCloud::CreateCloudsqlInstanceService - .new(project, current_user, create_service_params(permitted_params)) + .new(project, current_user, create_service_params) .execute if create_response[:status] == :error track_event(:error_create_cloudsql_instance) flash[:warning] = error_message(create_response[:message]) else - track_event(:create_cloudsql_instance, permitted_params.to_s) + track_event(:create_cloudsql_instance, permitted_params_create.to_s) flash[:notice] = success_message end end @@ -69,17 +68,25 @@ module Projects private + def permitted_params_create + params.permit(:gcp_project, :ref, :database_version, :tier) + end + def enable_service_params - { google_oauth2_token: token_in_session } + { + google_oauth2_token: token_in_session, + gcp_project_id: permitted_params_create[:gcp_project], + environment_name: permitted_params_create[:ref] + } end - def create_service_params(permitted_params) + def create_service_params { google_oauth2_token: token_in_session, - gcp_project_id: permitted_params[:gcp_project], - environment_name: permitted_params[:ref], - database_version: permitted_params[:database_version], - tier: permitted_params[:tier] + gcp_project_id: permitted_params_create[:gcp_project], + environment_name: permitted_params_create[:ref], + database_version: permitted_params_create[:database_version], + tier: permitted_params_create[:tier] } end diff --git a/app/controllers/projects/incident_management/timeline_events_controller.rb b/app/controllers/projects/incident_management/timeline_events_controller.rb new file mode 100644 index 00000000000..7e7a4758e48 --- /dev/null +++ b/app/controllers/projects/incident_management/timeline_events_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Projects + module IncidentManagement + class TimelineEventsController < Projects::ApplicationController + include PreviewMarkdown + + before_action :authenticate_user! + + respond_to :json + + feature_category :incident_management + urgency :low + end + end +end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index cbf0c756e1e..089ee860ea6 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -7,11 +7,9 @@ class Projects::IncidentsController < Projects::ApplicationController before_action :authorize_read_issue! before_action :load_incident, only: [:show] before_action do - push_frontend_feature_flag(:incident_timeline, @project) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, @project) - push_frontend_feature_flag(:remove_user_attributes_projects, @project) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 800a7df2566..5b1117c0224 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -41,8 +41,8 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_download_code!, only: [:related_branches] before_action do - push_frontend_feature_flag(:incident_timeline, project) - push_frontend_feature_flag(:remove_user_attributes_projects, project) + push_frontend_feature_flag(:preserve_unchanged_markdown, project) + push_frontend_feature_flag(:content_editor_on_issues, project) end before_action only: [:index, :show] do @@ -147,19 +147,26 @@ class Projects::IssuesController < Projects::ApplicationController spam_params = ::Spam::SpamParams.new_from_request(request: request) service = ::Issues::CreateService.new(project: project, current_user: current_user, params: create_params, spam_params: spam_params) - @issue = service.execute + result = service.execute - create_vulnerability_issue_feedback(issue) - - if service.discussions_to_resolve.count(&:resolved?) > 0 - flash[:notice] = if service.discussion_to_resolve_id - _("Resolved 1 discussion.") - else - _("Resolved all discussions.") - end + # Only irrecoverable errors such as unauthorized user won't contain an issue in the response + if result.error? && result[:issue].blank? + render_by_create_result_error(result) && return end - if @issue.valid? + @issue = result[:issue] + + if result.success? + create_vulnerability_issue_feedback(@issue) + + if service.discussions_to_resolve.count(&:resolved?) > 0 + flash[:notice] = if service.discussion_to_resolve_id + _("Resolved 1 discussion.") + else + _("Resolved all discussions.") + end + end + redirect_to project_issue_path(@project, @issue) else # NOTE: this CAPTCHA support method is indirectly included via IssuableActions @@ -372,6 +379,21 @@ class Projects::IssuesController < Projects::ApplicationController private + def render_by_create_result_error(result) + Gitlab::AppLogger.warn( + message: 'Cannot create issue', + errors: result.errors, + http_status: result.http_status + ) + error_method_name = "render_#{result.http_status}".to_sym + + if respond_to?(error_method_name, true) + send(error_method_name) # rubocop:disable GitlabSecurity/PublicSend + else + render_404 + end + end + def clean_params(all_params) issue_type = all_params[:issue_type].to_s all_params.delete(:issue_type) unless WorkItems::Type.allowed_types_for_issues.include?(issue_type) @@ -383,6 +405,7 @@ class Projects::IssuesController < Projects::ApplicationController options = super options[:issue_types] = Issue::TYPES_FOR_LIST + options[:issue_types] = options[:issue_types].excluding('task') unless project.work_items_feature_flag_enabled? if service_desk? options.reject! { |key| key == 'author_username' || key == 'author_id' } diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index a68c2ffa06d..418e7233e21 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -4,6 +4,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic include DiffHelper include RendersNotes include Gitlab::Cache::Helpers + include Gitlab::Tracking::Helpers before_action :commit before_action :define_diff_vars diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 5a212e9a152..9c139733248 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -34,7 +34,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action only: [:show] do push_frontend_feature_flag(:merge_request_widget_graphql, project) push_frontend_feature_flag(:core_security_mr_widget_counts, project) - push_frontend_feature_flag(:refactor_mr_widgets_extensions, project) push_frontend_feature_flag(:refactor_code_quality_extension, project) push_frontend_feature_flag(:refactor_mr_widget_test_summary, project) push_frontend_feature_flag(:issue_assignees_widget, @project) @@ -45,7 +44,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:paginated_mr_discussions, project) push_frontend_feature_flag(:mr_review_submit_comment, project) push_frontend_feature_flag(:mr_experience_survey, project) - push_frontend_feature_flag(:remove_user_attributes_projects, @project) end before_action do @@ -451,15 +449,16 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo return :failed end + squashing = params.fetch(:squash, false) merge_service = ::MergeRequests::MergeService.new(project: @project, current_user: current_user, params: merge_params) - unless merge_service.hooks_validation_pass?(@merge_request) + unless merge_service.hooks_validation_pass?(@merge_request, validate_squash_message: squashing) return :hook_validation_error end return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha - @merge_request.update(merge_error: nil, squash: params.fetch(:squash, false)) + @merge_request.update(merge_error: nil, squash: squashing) if auto_merge_requested? if merge_request.auto_merge_enabled? @@ -555,7 +554,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def endpoint_metadata_url(project, merge_request) - params = request.query_parameters.merge(view: 'inline', diff_head: true) + params = request.query_parameters.merge(view: 'inline', diff_head: true, w: current_user&.show_whitespace_in_diffs ? '0' : '1') diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params) end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index cfb67b7b4ff..78108cf3478 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -4,8 +4,11 @@ class Projects::MilestonesController < Projects::ApplicationController include Gitlab::Utils::StrongMemoize include MilestoneActions + REDIRECT_TARGETS = [:new_release].freeze + before_action :check_issuables_available! before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote] + before_action :redirect_path, only: [:new, :create] # Allow read any milestone before_action :authorize_read_milestone! @@ -59,7 +62,11 @@ class Projects::MilestonesController < Projects::ApplicationController @milestone = Milestones::CreateService.new(project, current_user, milestone_params).execute if @milestone.valid? - redirect_to project_milestone_path(@project, @milestone) + if @redirect_path == :new_release + redirect_to new_project_release_path(@project) + else + redirect_to project_milestone_path(@project, @milestone) + end else render "new" end @@ -113,6 +120,11 @@ class Projects::MilestonesController < Projects::ApplicationController protected + def redirect_path + path = params[:redirect_path]&.to_sym + @redirect_path = path if REDIRECT_TARGETS.include?(path) + end + def project_group strong_memoize(:project_group) do project.group diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index a6b22a28b17..43952a2efe4 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -41,9 +41,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def create - @domain = @project.pages_domains.create(create_params) + @domain = PagesDomains::CreateService.new(@project, current_user, create_params).execute - if @domain.valid? + if @domain&.persisted? redirect_to project_pages_domain_path(@project, @domain) else render 'new' @@ -51,7 +51,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def update - if @domain.update(update_params) + service = ::PagesDomains::UpdateService.new(@project, current_user, update_params) + + if service.execute(@domain) redirect_to project_pages_path(@project), status: :found, notice: 'Domain was updated' @@ -61,7 +63,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def destroy - @domain.destroy + PagesDomains::DeleteService + .new(@project, current_user) + .execute(@domain) respond_to do |format| format.html do @@ -74,9 +78,10 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def clean_certificate - unless @domain.update(user_provided_certificate: nil, user_provided_key: nil) - flash[:alert] = @domain.errors.full_messages.join(', ') - end + update_params = { user_provided_certificate: nil, user_provided_key: nil } + service = ::PagesDomains::UpdateService.new(@project, current_user, update_params) + + flash[:alert] = @domain.errors.full_messages.join(', ') unless service.execute(@domain) redirect_to project_pages_domain_path(@project, @domain) end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index a23d7fb3e6b..ca787785901 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -10,6 +10,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :authorize_update_pipeline_schedule!, only: [:edit, :update] before_action :authorize_take_ownership_pipeline_schedule!, only: [:take_ownership] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] + before_action :push_schedule_feature_flag, only: [:index, :new, :edit] feature_category :continuous_integration urgency :low @@ -115,4 +116,8 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController def authorize_admin_pipeline_schedule! return access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule) end + + def push_schedule_feature_flag + push_frontend_feature_flag(:pipeline_schedules_vue, @project) + end end diff --git a/app/controllers/projects/product_analytics_controller.rb b/app/controllers/projects/product_analytics_controller.rb index c89cd52530a..8085b0a6334 100644 --- a/app/controllers/projects/product_analytics_controller.rb +++ b/app/controllers/projects/product_analytics_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Projects::ProductAnalyticsController < Projects::ApplicationController - before_action :feature_enabled! + before_action :feature_enabled!, only: [:index, :setup, :test, :graphs] before_action :authorize_read_product_analytics! before_action :tracker_variables, only: [:setup, :test] @@ -57,3 +57,5 @@ class Projects::ProductAnalyticsController < Projects::ApplicationController render_404 unless Feature.enabled?(:product_analytics, @project) end end + +Projects::ProductAnalyticsController.prepend_mod_with('Projects::ProductAnalyticsController') diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index abbfe9ce22a..69a540158c6 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -4,7 +4,6 @@ class Projects::ProtectedRefsController < Projects::ApplicationController include RepositorySettingsRedirect # Authorize - before_action :require_non_empty_project before_action :authorize_admin_project! before_action :load_protected_ref, only: [:show, :update, :destroy] diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb index 32916831ecd..bac35583a97 100644 --- a/app/controllers/projects/settings/access_tokens_controller.rb +++ b/app/controllers/projects/settings/access_tokens_controller.rb @@ -13,6 +13,12 @@ module Projects def resource_access_tokens_path namespace_project_settings_access_tokens_path end + + private + + def represent(tokens) + ::ProjectAccessTokenSerializer.new.represent(tokens, project: resource) + end end end end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index c861b24d9ec..76e2da6eb57 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -14,7 +14,7 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController before_action :authorize_read_snippet!, except: [:new, :index] before_action :authorize_update_snippet!, only: :edit - urgency :low, [:index] + urgency :low, [:index, :show] def index @snippet_counts = ::Snippets::CountService diff --git a/app/controllers/projects/web_ide_terminals_controller.rb b/app/controllers/projects/web_ide_terminals_controller.rb index 350b091edfa..cfccc949244 100644 --- a/app/controllers/projects/web_ide_terminals_controller.rb +++ b/app/controllers/projects/web_ide_terminals_controller.rb @@ -10,6 +10,8 @@ class Projects::WebIdeTerminalsController < Projects::ApplicationController feature_category :web_ide + urgency :low, [:check_config] + def check_config return respond_422 unless branch_sha diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 5ceedbc1e01..b7b6e6534fb 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -311,8 +311,6 @@ class ProjectsController < Projects::ApplicationController find_tags = true find_commits = true - use_gitaly_pagination = Feature.enabled?(:use_gitaly_pagination_for_refs, @project) - unless find_refs.nil? find_branches = find_refs.include?('branches') find_tags = find_refs.include?('tags') @@ -323,7 +321,7 @@ class ProjectsController < Projects::ApplicationController if find_branches branches = BranchesFinder.new(@repository, refs_params.merge(per_page: REFS_LIMIT)) - .execute(gitaly_pagination: use_gitaly_pagination) + .execute(gitaly_pagination: true) .take(REFS_LIMIT) .map(&:name) @@ -332,7 +330,7 @@ class ProjectsController < Projects::ApplicationController if find_tags && @repository.tag_count.nonzero? tags = TagsFinder.new(@repository, refs_params.merge(per_page: REFS_LIMIT)) - .execute(gitaly_pagination: use_gitaly_pagination) + .execute(gitaly_pagination: true) .take(REFS_LIMIT) .map(&:name) @@ -435,14 +433,14 @@ class ProjectsController < Projects::ApplicationController analytics_access_level security_and_compliance_access_level container_registry_access_level + releases_access_level ] + operations_feature_attributes end def operations_feature_attributes if Feature.enabled?(:split_operations_visibility_permissions, project) %i[ - environments_access_level feature_flags_access_level releases_access_level - monitor_access_level + environments_access_level feature_flags_access_level monitor_access_level ] else %i[operations_access_level] diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 4e18e6a3b20..a49b82319da 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -4,6 +4,7 @@ module Registrations class WelcomeController < ApplicationController include OneTrustCSP include GoogleAnalyticsCSP + include RegistrationsTracking layout 'minimal' skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update] @@ -25,7 +26,7 @@ module Registrations members = current_user.members - if members.count == 1 && members.last.source.present? + if registering_from_invite?(members) redirect_to members_activity_path(members), notice: helpers.invite_accepted_notice(members.last) else redirect_to path_for_signed_in_user(current_user) @@ -37,6 +38,10 @@ module Registrations private + def registering_from_invite?(members) + members.count == 1 && members.last.source.present? + end + def require_current_user return redirect_to new_user_registration_path unless current_user end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0bd266bb490..31fe30f3f06 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -8,6 +8,7 @@ class RegistrationsController < Devise::RegistrationsController include OneTrustCSP include BizibleCSP include GoogleAnalyticsCSP + include RegistrationsTracking layout 'devise' @@ -114,13 +115,18 @@ class RegistrationsController < Devise::RegistrationsController def after_sign_up_path_for(user) Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?)) - users_sign_up_welcome_path + users_sign_up_welcome_path(glm_tracking_params) end def after_inactive_sign_up_path_for(resource) Gitlab::AppLogger.info(user_created_message) return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval? return dashboard_projects_path if Feature.enabled?(:soft_email_confirmation) + + # when email confirmation is enabled, path to redirect is saved + # after user confirms and comes back, he will be redirected + store_location_for(:redirect, users_sign_up_welcome_path(glm_tracking_params)) + return identity_verification_redirect_path if custom_confirmation_enabled?(resource) users_almost_there_path(email: resource.email) @@ -183,7 +189,7 @@ class RegistrationsController < Devise::RegistrationsController def resource @resource ||= Users::RegistrationsBuildService - .new(current_user, sign_up_params.merge({ skip_confirmation: skip_email_confirmation? })) + .new(current_user, sign_up_params.merge({ skip_confirmation: registered_with_invite_email? })) .execute end @@ -191,7 +197,7 @@ class RegistrationsController < Devise::RegistrationsController @devise_mapping ||= Devise.mappings[:user] end - def skip_email_confirmation? + def registered_with_invite_email? invite_email = session.delete(:invite_email) sign_up_params[:email] == invite_email diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 9f87ad6aaf6..7d4dd04c6d4 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -25,6 +25,10 @@ class SearchController < ApplicationController end before_action :check_search_rate_limit!, only: search_rate_limited_endpoints + before_action only: :show do + push_frontend_feature_flag(:search_page_vertical_nav, current_user) + end + rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index fe3b8d9b8b4..5c969c437f4 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -107,11 +107,11 @@ class SessionsController < Devise::SessionsController end def captcha_enabled? - request.headers[CAPTCHA_HEADER] && Gitlab::Recaptcha.enabled? + request.headers[CAPTCHA_HEADER] && helpers.recaptcha_enabled? end def captcha_on_login_required? - Gitlab::Recaptcha.enabled_on_login? && unverified_anonymous_user? + helpers.recaptcha_enabled_on_login? && unverified_anonymous_user? end # From https://github.com/plataformatec/devise/wiki/How-To:-Use-Recaptcha-with-Devise#devisepasswordscontroller diff --git a/app/controllers/users/namespace_callouts_controller.rb b/app/controllers/users/namespace_callouts_controller.rb deleted file mode 100644 index d4876382dfe..00000000000 --- a/app/controllers/users/namespace_callouts_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Users - class NamespaceCalloutsController < Users::CalloutsController - private - - def callout - Users::DismissNamespaceCalloutService.new( - container: nil, current_user: current_user, params: callout_params - ).execute - end - - def callout_params - params.permit(:namespace_id).merge(feature_name: feature_name) - end - end -end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 3c1a3534912..c35aa8e4346 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -35,7 +35,7 @@ class UsersController < ApplicationController feature_category :source_code_management, [:gpg_keys] # TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/357914 - urgency :low, [:show, :calendar_activities, :contributed, :activity, :projects, :groups, :calendar] + urgency :low, [:show, :calendar_activities, :contributed, :activity, :projects, :groups, :calendar, :snippets] urgency :default, [:followers, :following, :starred] urgency :high, [:exists] @@ -174,8 +174,9 @@ class UsersController < ApplicationController end def follow - current_user.follow(user) + followee = current_user.follow(user) + flash[:alert] = followee.errors.full_messages.join(', ') if followee&.errors&.any? redirect_path = referer_path(request) || @user redirect_to redirect_path diff --git a/app/events/pages_domains/pages_domain_created_event.rb b/app/events/pages_domains/pages_domain_created_event.rb new file mode 100644 index 00000000000..a86718f4681 --- /dev/null +++ b/app/events/pages_domains/pages_domain_created_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PagesDomains + class PagesDomainCreatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'domain' => { 'type' => 'string' } + }, + 'required' => %w[project_id namespace_id root_namespace_id] + } + end + end +end diff --git a/app/events/pages_domains/pages_domain_deleted_event.rb b/app/events/pages_domains/pages_domain_deleted_event.rb new file mode 100644 index 00000000000..7fe165a7249 --- /dev/null +++ b/app/events/pages_domains/pages_domain_deleted_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PagesDomains + class PagesDomainDeletedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'domain' => { 'type' => 'string' } + }, + 'required' => %w[project_id namespace_id root_namespace_id] + } + end + end +end diff --git a/app/events/pages_domains/pages_domain_updated_event.rb b/app/events/pages_domains/pages_domain_updated_event.rb new file mode 100644 index 00000000000..641fb2f6a53 --- /dev/null +++ b/app/events/pages_domains/pages_domain_updated_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PagesDomains + class PagesDomainUpdatedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'domain' => { 'type' => 'string' } + }, + 'required' => %w[project_id namespace_id root_namespace_id] + } + end + end +end diff --git a/app/events/projects/project_attributes_changed_event.rb b/app/events/projects/project_attributes_changed_event.rb new file mode 100644 index 00000000000..f7c27fa65e6 --- /dev/null +++ b/app/events/projects/project_attributes_changed_event.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Projects + class ProjectAttributesChangedEvent < ::Gitlab::EventStore::Event + PAGES_RELATED_ATTRIBUTES = %w[ + pages_https_only + visibility_level + ].freeze + + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'attributes' => { 'type' => 'array' } + }, + 'required' => %w[project_id namespace_id root_namespace_id attributes] + } + end + + def pages_related? + PAGES_RELATED_ATTRIBUTES.any? do |attribute| + data[:attributes].include?(attribute) + end + end + end +end diff --git a/app/events/projects/project_features_changed_event.rb b/app/events/projects/project_features_changed_event.rb new file mode 100644 index 00000000000..a0c6fa1a3f9 --- /dev/null +++ b/app/events/projects/project_features_changed_event.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Projects + class ProjectFeaturesChangedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'features' => { 'type' => 'array' } + }, + 'required' => %w[project_id namespace_id root_namespace_id features] + } + end + + def pages_related? + data[:features].include?("pages_access_level") + end + end +end diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 774947a35b7..d0d98a59677 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -55,6 +55,12 @@ module Ci Ci::Runner.belonging_to_group(@group.id) when :descendants, nil Ci::Runner.belonging_to_group_or_project_descendants(@group.id) + when :all_available + unless can?(@current_user, :read_group_all_available_runners, @group) + raise Gitlab::Access::AccessDeniedError + end + + Ci::Runner.usable_from_scope(@group) else raise ArgumentError, 'Invalid membership filter' end diff --git a/app/finders/clusters/agent_authorizations_finder.rb b/app/finders/clusters/agent_authorizations_finder.rb index 373cf7fe8b9..8b939f5d646 100644 --- a/app/finders/clusters/agent_authorizations_finder.rb +++ b/app/finders/clusters/agent_authorizations_finder.rb @@ -24,13 +24,21 @@ module Clusters # rubocop: disable CodeReuse/ActiveRecord def project_authorizations - ancestor_ids = project.group ? project.ancestors.select(:id) : project.namespace_id + namespace_ids = if project.group + if include_descendants? + all_namespace_ids + else + ancestor_namespace_ids + end + else + project.namespace_id + end Clusters::Agents::ProjectAuthorization .where(project_id: project.id) .joins(agent: :project) .preload(agent: :project) - .where(cluster_agents: { projects: { namespace_id: ancestor_ids } }) + .where(cluster_agents: { projects: { namespace_id: namespace_ids } }) .with_available_ci_access_fields(project) .to_a end @@ -49,17 +57,35 @@ module Clusters authorizations[:group_id].eq(ordered_ancestors_cte.table[:id]) ).join_sources - Clusters::Agents::GroupAuthorization + authorized_groups = Clusters::Agents::GroupAuthorization .with(ordered_ancestors_cte.to_arel) .joins(cte_join_sources) .joins(agent: :project) - .where('projects.namespace_id IN (SELECT id FROM ordered_ancestors)') .with_available_ci_access_fields(project) .order(Arel.sql('agent_id, array_position(ARRAY(SELECT id FROM ordered_ancestors)::bigint[], agent_group_authorizations.group_id)')) .select('DISTINCT ON (agent_id) agent_group_authorizations.*') .preload(agent: :project) - .to_a + + authorized_groups = if include_descendants? + authorized_groups.where(projects: { namespace_id: all_namespace_ids }) + else + authorized_groups.where('projects.namespace_id IN (SELECT id FROM ordered_ancestors)') + end + + authorized_groups.to_a end # rubocop: enable CodeReuse/ActiveRecord + + def ancestor_namespace_ids + project.ancestors.select(:id) + end + + def all_namespace_ids + project.root_ancestor.self_and_descendants.select(:id) + end + + def include_descendants? + Feature.enabled?(:agent_authorization_include_descendants, project) + end end end diff --git a/app/finders/groups/accepting_group_transfers_finder.rb b/app/finders/groups/accepting_group_transfers_finder.rb index df67f940d20..c95318d0098 100644 --- a/app/finders/groups/accepting_group_transfers_finder.rb +++ b/app/finders/groups/accepting_group_transfers_finder.rb @@ -13,12 +13,7 @@ module Groups def execute return Group.none unless can_transfer_group? - items = if Feature.enabled?(:include_groups_from_group_shares_in_group_transfer_locations) - find_all_groups - else - find_groups - end - + items = find_all_groups items = by_search(items) sort(items) diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index ecd6270ed47..9f9d0da6efd 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -19,7 +19,7 @@ class LabelsFinder < UnionFinder items = with_title(items) items = by_subscription(items) items = by_search(items) - sort(items) + sort(items.with_preloaded_container) end private diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb index 1d1ae59674a..3a068252d5c 100644 --- a/app/finders/packages/group_packages_finder.rb +++ b/app/finders/packages/group_packages_finder.rb @@ -22,7 +22,7 @@ module Packages def packages_for_group_projects(installable_only: false) packages = ::Packages::Package - .including_project_route + .including_project_namespace_route .including_tags .for_projects(group_projects_visible_to_current_user.select(:id)) .sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") diff --git a/app/finders/packages/helm/packages_finder.rb b/app/finders/packages/helm/packages_finder.rb index c58d9292e9f..e1b831ca864 100644 --- a/app/finders/packages/helm/packages_finder.rb +++ b/app/finders/packages/helm/packages_finder.rb @@ -5,7 +5,7 @@ module Packages class PackagesFinder include ::Packages::FinderHelper - MAX_PACKAGES_COUNT = 300 + MAX_PACKAGES_COUNT = 1000 def initialize(project, channel) @project = project diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb index 9ae52745bb2..23345f29198 100644 --- a/app/finders/packages/nuget/package_finder.rb +++ b/app/finders/packages/nuget/package_finder.rb @@ -15,7 +15,7 @@ module Packages result = base.nuget .has_version .with_name_like(@params[:package_name]) - result = result.with_version(@params[:package_version]) if @params[:package_version].present? + result = result.with_case_insensitive_version(@params[:package_version]) if @params[:package_version].present? result end end diff --git a/app/finders/packages/package_finder.rb b/app/finders/packages/package_finder.rb index e482a0503f0..9e667b7a63c 100644 --- a/app/finders/packages/package_finder.rb +++ b/app/finders/packages/package_finder.rb @@ -10,7 +10,7 @@ module Packages @project .packages .preload_pipelines - .including_project_route + .including_project_namespace_route .including_tags .displayable .find(@package_id) diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb index b3d14e15953..31fbbfb7937 100644 --- a/app/finders/packages/packages_finder.rb +++ b/app/finders/packages/packages_finder.rb @@ -14,7 +14,7 @@ module Packages def execute packages = project.packages - .including_project_route + .including_project_namespace_route .including_tags packages = packages.preload_pipelines if preload_pipelines diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index 7d356c1014c..8403c531945 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -18,6 +18,12 @@ class PersonalAccessTokensFinder tokens = by_impersonation(tokens) tokens = by_state(tokens) tokens = by_owner_type(tokens) + tokens = by_revoked_state(tokens) + tokens = by_created_before(tokens) + tokens = by_created_after(tokens) + tokens = by_last_used_before(tokens) + tokens = by_last_used_after(tokens) + tokens = by_search(tokens) sort(tokens) end @@ -83,4 +89,40 @@ class PersonalAccessTokensFinder tokens end end + + def by_revoked_state(tokens) + return tokens unless params.has_key?(:revoked) + + params[:revoked] ? tokens.revoked : tokens.not_revoked + end + + def by_created_before(tokens) + return tokens unless params[:created_before] + + tokens.created_before(params[:created_before]) + end + + def by_created_after(tokens) + return tokens unless params[:created_after] + + tokens.created_after(params[:created_after]) + end + + def by_last_used_before(tokens) + return tokens unless params[:last_used_before] + + tokens.last_used_before(params[:last_used_before]) + end + + def by_last_used_after(tokens) + return tokens unless params[:last_used_after] + + tokens.last_used_after(params[:last_used_after]) + end + + def by_search(tokens) + return tokens unless params[:search] + + tokens.search(params[:search]) + end end diff --git a/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb b/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb new file mode 100644 index 00000000000..c1b35d3eaf7 --- /dev/null +++ b/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module BatchLoaders + module AwardEmojiVotesBatchLoader + private + + def load_votes(object, vote_type) + BatchLoader::GraphQL.for(object.id).batch(key: "#{object.issuing_parent_id}-#{vote_type}") do |ids, loader, args| + counts = AwardEmoji.votes_for_collection(ids, object.class.name).named(vote_type).index_by(&:awardable_id) + + ids.each do |id| + loader.call(id, counts[id]&.count || 0) + end + end + end + + def authorized_resource?(object) + Ability.allowed?(current_user, "read_#{object.to_ability_name}".to_sym, object) + end + end +end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index c0e063a34d5..37adf4c2d3b 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -137,6 +137,19 @@ class GitlabSchema < GraphQL::Schema gid end + # Parse an array of strings to an array of GlobalIDs, raising ArgumentError if there are problems + # with it. + # See #parse_gid + # + # ``` + # gids = GitlabSchema.parse_gids(my_array_of_strings, expected_type: ::Project) + # project_ids = gids.map(&:model_id) + # gids.all? { |gid| gid.model_class == ::Project } + # ``` + def parse_gids(global_ids, ctx = {}) + global_ids.map { |gid| parse_gid(gid, ctx) } + end + private def max_query_complexity(ctx) diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 8086d8c02a4..dc4f838ae36 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -13,6 +13,10 @@ module GraphqlTriggers GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable) end + def self.issuable_description_updated(issuable) + GitlabSchema.subscriptions.trigger('issuableDescriptionUpdated', { issuable_id: issuable.to_gid }, issuable) + end + def self.issuable_labels_updated(issuable) GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable) end @@ -20,6 +24,22 @@ module GraphqlTriggers def self.issuable_dates_updated(issuable) GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable) end + + def self.merge_request_reviewers_updated(merge_request) + GitlabSchema.subscriptions.trigger( + 'mergeRequestReviewersUpdated', + { issuable_id: merge_request.to_gid }, + merge_request + ) + end + + def self.merge_request_merge_status_updated(merge_request) + GitlabSchema.subscriptions.trigger( + 'mergeRequestMergeStatusUpdated', + { issuable_id: merge_request.to_gid }, + merge_request + ) + end end GraphqlTriggers.prepend_mod diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb index 2c128e1b339..77a7d7a4147 100644 --- a/app/graphql/mutations/alert_management/create_alert_issue.rb +++ b/app/graphql/mutations/alert_management/create_alert_issue.rb @@ -24,8 +24,8 @@ module Mutations def prepare_response(alert, result) { alert: alert, - issue: result.payload[:issue], - errors: Array(result.message) + issue: result[:issue], + errors: result.errors } end end diff --git a/app/graphql/mutations/ci/job/artifacts_destroy.rb b/app/graphql/mutations/ci/job/artifacts_destroy.rb index c27ab9c4d89..34c58fc1240 100644 --- a/app/graphql/mutations/ci/job/artifacts_destroy.rb +++ b/app/graphql/mutations/ci/job/artifacts_destroy.rb @@ -25,12 +25,21 @@ module Mutations def resolve(id:) job = authorized_find!(id: id) - result = ::Ci::JobArtifacts::DestroyBatchService.new(job.job_artifacts, pick_up_at: Time.current).execute - { - job: job, - destroyed_artifacts_count: result[:destroyed_artifacts_count], - errors: Array(result[:errors]) - } + result = ::Ci::JobArtifacts::DeleteService.new(job).execute + + if result.success? + { + job: job, + destroyed_artifacts_count: result.payload[:destroyed_artifacts_count], + errors: Array(result.payload[:errors]) + } + else + { + job: job, + destroyed_artifacts_count: 0, + errors: Array(result.message) + } + end end end end diff --git a/app/graphql/mutations/ci/pipeline_schedule/base.rb b/app/graphql/mutations/ci/pipeline_schedule/base.rb new file mode 100644 index 00000000000..a737ccce575 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_schedule/base.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineSchedule + class Base < BaseMutation + PipelineScheduleID = ::Types::GlobalIDType[::Ci::PipelineSchedule] + + argument :id, PipelineScheduleID, + required: true, + description: 'ID of the pipeline schedule to mutate.' + + private + + def find_object(id:) + GlobalID::Locator.locate(id) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/pipeline_schedule/delete.rb b/app/graphql/mutations/ci/pipeline_schedule/delete.rb new file mode 100644 index 00000000000..ead9a43161d --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_schedule/delete.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineSchedule + class Delete < Base + graphql_name 'PipelineScheduleDelete' + + authorize :admin_pipeline_schedule + + def resolve(id:) + schedule = authorized_find!(id: id) + + if schedule.destroy + { + errors: [] + } + else + { + errors: ['Failed to remove the pipeline schedule'] + } + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb index b0cffa2c088..27b066ffcf6 100644 --- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb @@ -19,7 +19,13 @@ module Mutations argument :job_token_scope_enabled, GraphQL::Types::Boolean, required: false, - description: 'Indicates CI job tokens generated in this project have restricted access to resources.' + description: 'Indicates CI/CD job tokens generated in this project ' \ + 'have restricted access to other projects.' + + argument :inbound_job_token_scope_enabled, GraphQL::Types::Boolean, + required: false, + description: 'Indicates CI/CD job tokens generated in other projects ' \ + 'have restricted access to this project.' field :ci_cd_settings, Types::Ci::CiCdSettingType, @@ -28,6 +34,9 @@ module Mutations def resolve(full_path:, **args) project = authorized_find!(full_path) + + args.delete(:inbound_job_token_scope_enabled) unless Feature.enabled?(:ci_inbound_job_token_scope, project) + settings = project.ci_cd_settings settings.update(args) diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb index f98138646be..2f2c8c4c668 100644 --- a/app/graphql/mutations/ci/runner/update.rb +++ b/app/graphql/mutations/ci/runner/update.rb @@ -94,6 +94,7 @@ module Mutations ).execute return if result.success? + response[:runner] = nil response[:errors] = result.errors raise ActiveRecord::Rollback end @@ -102,6 +103,7 @@ module Mutations result = ::Ci::Runners::UpdateRunnerService.new(runner).execute(attrs) return if result.success? + response[:runner] = nil response[:errors] = result.errors raise ActiveRecord::Rollback end diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index 1f90f394521..e42e59de78f 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -30,6 +30,9 @@ module Mutations argument :start_and_due_date_widget, ::Types::WorkItems::Widgets::StartAndDueDateUpdateInputType, required: false, description: 'Input for start and due date widget.' + argument :labels_widget, ::Types::WorkItems::Widgets::LabelsUpdateInputType, + required: false, + description: 'Input for labels widget.' end end end diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 6bf8caf82d7..0389a482822 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -83,13 +83,13 @@ module Mutations params = build_create_issue_params(attributes.merge(author_id: current_user.id), project) spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute + result = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute - check_spam_action_response!(issue) + check_spam_action_response!(result[:issue]) if result[:issue] { - issue: issue.valid? ? issue : nil, - errors: errors_on_object(issue) + issue: result.success? ? result[:issue] : nil, + errors: result.errors } end diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index e499e646781..ea72b71715c 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -35,6 +35,36 @@ module Mutations required: false, description: copy_field_description(Types::Namespace::PackageSettingsType, :generic_duplicate_exception_regex) + argument :maven_package_requests_forwarding, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :maven_package_requests_forwarding) + + argument :npm_package_requests_forwarding, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :npm_package_requests_forwarding) + + argument :pypi_package_requests_forwarding, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :pypi_package_requests_forwarding) + + argument :lock_maven_package_requests_forwarding, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :lock_maven_package_requests_forwarding) + + argument :lock_npm_package_requests_forwarding, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :lock_npm_package_requests_forwarding) + + argument :lock_pypi_package_requests_forwarding, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :lock_pypi_package_requests_forwarding) + field :package_settings, Types::Namespace::PackageSettingsType, null: true, diff --git a/app/graphql/mutations/packages/bulk_destroy.rb b/app/graphql/mutations/packages/bulk_destroy.rb new file mode 100644 index 00000000000..a0756d0c3f9 --- /dev/null +++ b/app/graphql/mutations/packages/bulk_destroy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Mutations + module Packages + class BulkDestroy < ::Mutations::BaseMutation + graphql_name 'DestroyPackages' + + MAX_PACKAGES = 20 + TOO_MANY_IDS_ERROR = "Cannot delete more than #{MAX_PACKAGES} packages" + + argument :ids, + [::Types::GlobalIDType[::Packages::Package]], + required: true, + description: "Global IDs of the Packages. Max #{MAX_PACKAGES}" + + def resolve(ids:) + raise_resource_not_available_error!(TOO_MANY_IDS_ERROR) if ids.size > MAX_PACKAGES + + ids = GitlabSchema.parse_gids(ids, expected_type: ::Packages::Package) + .map(&:model_id) + + service = ::Packages::MarkPackagesForDestructionService.new( + packages: packages_from(ids), + current_user: current_user + ) + result = service.execute + + raise_resource_not_available_error! if result.reason == :unauthorized + + errors = result.error? ? Array.wrap(result[:message]) : [] + + { errors: errors } + end + + private + + def packages_from(ids) + ::Packages::Package.displayable + .id_in(ids) + end + end + end +end diff --git a/app/graphql/mutations/packages/destroy_files.rb b/app/graphql/mutations/packages/destroy_files.rb index 3900a2c46ae..60a21be20d8 100644 --- a/app/graphql/mutations/packages/destroy_files.rb +++ b/app/graphql/mutations/packages/destroy_files.rb @@ -25,7 +25,7 @@ module Mutations project = authorized_find!(project_path) raise_resource_not_available_error! "Cannot delete more than #{MAXIMUM_FILES} files" if ids.size > MAXIMUM_FILES - package_files = ::Packages::PackageFile.where(id: parse_gids(ids)) # rubocop:disable CodeReuse/ActiveRecord + package_files = ::Packages::PackageFile.id_in(parse_gids(ids)) ensure_file_access!(project, package_files) @@ -47,7 +47,7 @@ module Mutations end def parse_gids(gids) - gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::Packages::PackageFile).model_id } + GitlabSchema.parse_gids(gids, expected_type: ::Packages::PackageFile).map(&:model_id) end end end diff --git a/app/graphql/mutations/work_items/update_widgets.rb b/app/graphql/mutations/work_items/update_widgets.rb deleted file mode 100644 index 7037b7e5a2a..00000000000 --- a/app/graphql/mutations/work_items/update_widgets.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module WorkItems - # TODO: Deprecate in favor of using WorkItemUpdate. See https://gitlab.com/gitlab-org/gitlab/-/issues/366300 - class UpdateWidgets < BaseMutation - graphql_name 'WorkItemUpdateWidgets' - description "Updates the attributes of a work item's widgets by global ID." \ - " Available only when feature flag `work_items` is enabled." - - include Mutations::SpamProtection - - authorize :update_work_item - - argument :id, ::Types::GlobalIDType[::WorkItem], - required: true, - description: 'Global ID of the work item.' - - argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType, - required: false, - description: 'Input for description widget.' - - field :work_item, Types::WorkItemType, - null: true, - description: 'Updated work item.' - - def resolve(id:, **widget_attributes) - work_item = authorized_find!(id: id) - - unless work_item.project.work_items_feature_flag_enabled? - return { errors: ['`work_items` feature flag disabled for this project'] } - end - - spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - - ::WorkItems::UpdateService.new( - project: work_item.project, - current_user: current_user, - # Cannot use prepare to use `.to_h` on each input due to - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865 - widget_params: widget_attributes.transform_values { |values| values.to_h }, - spam_params: spam_params - ).execute(work_item) - - check_spam_action_response!(work_item) - - { - work_item: work_item.valid? ? work_item : nil, - errors: errors_on_object(work_item) - } - end - - private - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end - end - end -end diff --git a/app/graphql/resolvers/base_issues_resolver.rb b/app/graphql/resolvers/base_issues_resolver.rb index ec47a8996eb..6357132705e 100644 --- a/app/graphql/resolvers/base_issues_resolver.rb +++ b/app/graphql/resolvers/base_issues_resolver.rb @@ -47,8 +47,8 @@ module Resolvers def preloads { alert_management_alert: [:alert_management_alert], - labels: [:labels], assignees: [:assignees], + participants: Issue.participant_includes, timelogs: [:timelogs], customer_relations_contacts: { customer_relations_contacts: [:group] }, escalation_status: [:incident_management_issuable_escalation_status] diff --git a/app/graphql/resolvers/bulk_labels_resolver.rb b/app/graphql/resolvers/bulk_labels_resolver.rb new file mode 100644 index 00000000000..7362e257fb6 --- /dev/null +++ b/app/graphql/resolvers/bulk_labels_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + class BulkLabelsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::LabelType.connection_type, null: true + + def resolve + authorize!(object) + + BatchLoader::GraphQL.for(object.id).batch(cache: false) do |ids, loader, args| + labels = Label.for_targets(object.class.id_in(ids)).group_by(&:target_id) + + ids.each do |id| + loader.call(id, labels[id] || []) + end + end + end + + private + + def authorized_resource?(object) + Ability.allowed?(current_user, :read_label, object.issuing_parent) + end + end +end diff --git a/app/graphql/resolvers/ci/all_jobs_resolver.rb b/app/graphql/resolvers/ci/all_jobs_resolver.rb new file mode 100644 index 00000000000..d918bed9f57 --- /dev/null +++ b/app/graphql/resolvers/ci/all_jobs_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class AllJobsResolver < BaseResolver + type ::Types::Ci::JobType.connection_type, null: true + + argument :statuses, [::Types::Ci::JobStatusEnum], + required: false, + description: 'Filter jobs by status.' + + def resolve(statuses: nil) + ::Ci::JobsFinder.new(current_user: current_user, params: { scope: statuses }).execute + end + end + end +end diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb index ca3b4ebb797..af9a67acfda 100644 --- a/app/graphql/resolvers/ci/runner_projects_resolver.rb +++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb @@ -21,8 +21,8 @@ module Resolvers 'Specify `"id_asc"` if query results\' order is important', milestone: '15.4' }, - description: "Sort order of results. Format: '<field_name>_<sort_direction>', " \ - "for example: 'id_desc' or 'name_asc'" + description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \ + "for example: `id_desc` or `name_asc`" def resolve_with_lookahead(**args) return unless runner.project_type? diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb index b548dc1e175..81099c04e9f 100644 --- a/app/graphql/resolvers/concerns/looks_ahead.rb +++ b/app/graphql/resolvers/concerns/looks_ahead.rb @@ -32,16 +32,37 @@ module LooksAhead {} end + def nested_preloads + {} + end + def filtered_preloads nodes = node_selection return [] unless nodes selected_fields = nodes.selections.map(&:name) + root_level_preloads = preloads_from_node_selection(selected_fields, preloads) - preloads.each.flat_map do |name, requirements| - selected_fields.include?(name) ? requirements : [] - end + root_level_preloads + nested_filtered_preloads(nodes, selected_fields) + end + + def nested_filtered_preloads(nodes, selected_root_fields) + return [] if nested_preloads.empty? + + nested_preloads.each_with_object([]) do |(root_field, fields), result| + next unless selected_root_fields.include?(root_field) + + selected_fields = nodes.selection(root_field).selections.map(&:name) + + result << preloads_from_node_selection(selected_fields, fields) + end.flatten + end + + def preloads_from_node_selection(selected_fields, fields) + fields.each_with_object([]) do |(field, requirements), result| + result << requirements if selected_fields.include?(field) + end.flatten end def node_selection diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index 697cc6f5b03..d56951bc821 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -42,7 +42,6 @@ module ResolvesMergeRequests assignees: [:assignees], reviewers: [:reviewers], participants: MergeRequest.participant_includes, - labels: [:labels], author: [:author], merged_at: [:metrics], commit_count: [:metrics], @@ -53,7 +52,8 @@ module ResolvesMergeRequests head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }], timelogs: [:timelogs], pipelines: [:merge_request_diffs], # used by `recent_diff_head_shas` to load pipelines - committers: [merge_request_diff: [:merge_request_diff_commits]] + committers: [merge_request_diff: [:merge_request_diff_commits]], + suggested_reviewers: [:predictions] } end end diff --git a/app/graphql/resolvers/down_votes_count_resolver.rb b/app/graphql/resolvers/down_votes_count_resolver.rb new file mode 100644 index 00000000000..0e7772f988a --- /dev/null +++ b/app/graphql/resolvers/down_votes_count_resolver.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Resolvers + class DownVotesCountResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include BatchLoaders::AwardEmojiVotesBatchLoader + + type GraphQL::Types::Int, null: true + + def resolve + authorize!(object) + load_votes(object, AwardEmoji::DOWNVOTE_NAME) + end + end +end diff --git a/app/graphql/resolvers/project_pipeline_schedules_resolver.rb b/app/graphql/resolvers/project_pipeline_schedules_resolver.rb new file mode 100644 index 00000000000..eb980f72717 --- /dev/null +++ b/app/graphql/resolvers/project_pipeline_schedules_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + class ProjectPipelineSchedulesResolver < BaseResolver + alias_method :project, :object + + type ::Types::Ci::PipelineScheduleType.connection_type, null: true + + argument :status, ::Types::Ci::PipelineScheduleStatusEnum, + required: false, + description: 'Filter pipeline schedules by active status.' + + def resolve(status: nil) + ::Ci::PipelineSchedulesFinder.new(project).execute(scope: status) + end + end +end diff --git a/app/graphql/resolvers/projects/branch_rules_resolver.rb b/app/graphql/resolvers/projects/branch_rules_resolver.rb index 6c8b416bcea..e99d7ae4d5f 100644 --- a/app/graphql/resolvers/projects/branch_rules_resolver.rb +++ b/app/graphql/resolvers/projects/branch_rules_resolver.rb @@ -3,13 +3,17 @@ module Resolvers module Projects class BranchRulesResolver < BaseResolver + include LooksAhead + type Types::Projects::BranchRuleType.connection_type, null: false alias_method :project, :object - def resolve(**args) - project.protected_branches + def resolve_with_lookahead(**args) + apply_lookahead(project.protected_branches) end end end end + +Resolvers::Projects::BranchRulesResolver.prepend_mod_with('Resolvers::Projects::BranchRulesResolver') diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 4d1e1b867da..0bdba53c7af 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -12,8 +12,8 @@ module Resolvers argument :sort, GraphQL::Types::String, required: false, - description: "Sort order of results. Format: '<field_name>_<sort_direction>', " \ - "for example: 'id_desc' or 'name_asc'" + description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \ + "for example: `id_desc` or `name_asc`" def resolve(**args) ProjectsFinder diff --git a/app/graphql/resolvers/up_votes_count_resolver.rb b/app/graphql/resolvers/up_votes_count_resolver.rb new file mode 100644 index 00000000000..1c78facb694 --- /dev/null +++ b/app/graphql/resolvers/up_votes_count_resolver.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Resolvers + class UpVotesCountResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include BatchLoaders::AwardEmojiVotesBatchLoader + + type GraphQL::Types::Int, null: true + + def resolve + authorize!(object) + load_votes(object, AwardEmoji::UPVOTE_NAME) + end + end +end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index a8c0d363325..a4cbcc61ead 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -37,20 +37,26 @@ module Resolvers def preloads { - last_edited_by: :last_edited_by, - web_url: { project: { namespace: :route } } + work_item_type: :work_item_type, + web_url: { project: { namespace: :route } }, + widgets: :work_item_type } end - # Allows to apply lookahead for fields - # selected from WidgetInterface - override :node_selection - def node_selection - selected_fields = super - - return unless selected_fields + def nested_preloads + { + widgets: widget_preloads, + user_permissions: { update_work_item: :assignees } + } + end - selected_fields.selection(:widgets) + def widget_preloads + { + last_edited_by: :last_edited_by, + assignees: :assignees, + parent: :work_item_parent, + labels: :labels + } end def unconditional_includes diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index bec8c72e783..574791b79e6 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -10,8 +10,17 @@ module Types field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates CI job tokens generated in this project have restricted access to resources.', + description: 'Indicates CI/CD job tokens generated in this project ' \ + 'have restricted access to other projects.', method: :job_token_scope_enabled? + + field :inbound_job_token_scope_enabled, + GraphQL::Types::Boolean, + null: true, + description: 'Indicates CI/CD job tokens generated in other projects ' \ + 'have restricted access to this project.', + method: :inbound_job_token_scope_enabled? + field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, description: 'Whether to keep the latest builds artifacts.', method: :keep_latest_artifacts_available? diff --git a/app/graphql/types/ci/config_variable_type.rb b/app/graphql/types/ci/config_variable_type.rb index 87ae026c2c1..5b5890fd5a5 100644 --- a/app/graphql/types/ci/config_variable_type.rb +++ b/app/graphql/types/ci/config_variable_type.rb @@ -17,6 +17,10 @@ module Types field :value, GraphQL::Types::String, null: true, description: 'Value of the variable.' + + field :value_options, [GraphQL::Types::String], + null: true, + description: 'Value options for the variable.' end end end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index ab6103d9469..4447a10a74e 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -56,6 +56,8 @@ module Types description: 'Indicates the job is active.' field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, description: 'Artifacts generated by the job.' + field :browse_artifacts_path, GraphQL::Types::String, null: true, + description: "URL for browsing the artifact's archive." field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?, description: 'Indicates the job can be canceled.' field :commit_path, GraphQL::Types::String, null: true, @@ -148,17 +150,7 @@ module Types end def stage - ::Gitlab::Graphql::Lazy.with_value(pipeline) do |pl| - BatchLoader::GraphQL.for([pl, object.stage]).batch do |ids, loader| - by_pipeline = ids - .group_by(&:first) - .transform_values { |grp| grp.map(&:second) } - - by_pipeline.each do |p, names| - p.stages.by_name(names).each { |s| loader.call([p, s.name], s) } - end - end - end + ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Stage, object.stage_id).find end # This class is a secret union! @@ -187,6 +179,10 @@ module Types ::Gitlab::Routing.url_helpers.project_job_path(object.project, object) end + def browse_artifacts_path + ::Gitlab::Routing.url_helpers.browse_project_job_artifacts_path(object.project, object) + end + def coverage object&.coverage end diff --git a/app/graphql/types/ci/pipeline_schedule_status_enum.rb b/app/graphql/types/ci/pipeline_schedule_status_enum.rb new file mode 100644 index 00000000000..61bae7daff8 --- /dev/null +++ b/app/graphql/types/ci/pipeline_schedule_status_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Ci + class PipelineScheduleStatusEnum < BaseEnum + graphql_name 'PipelineScheduleStatus' + + value 'ACTIVE', value: "active", description: 'Active pipeline schedules.' + value 'INACTIVE', value: "inactive", description: 'Inactive pipeline schedules.' + end + end +end diff --git a/app/graphql/types/ci/pipeline_schedule_type.rb b/app/graphql/types/ci/pipeline_schedule_type.rb new file mode 100644 index 00000000000..04f9fc78a92 --- /dev/null +++ b/app/graphql/types/ci/pipeline_schedule_type.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Types + module Ci + class PipelineScheduleType < BaseObject + graphql_name 'PipelineSchedule' + + connection_type_class(Types::CountableConnectionType) + + expose_permissions Types::PermissionTypes::Ci::PipelineSchedules + + authorize :read_pipeline_schedule + + field :id, GraphQL::Types::ID, null: false, description: 'ID of the pipeline schedule.' + + field :description, GraphQL::Types::String, null: true, description: 'Description of the pipeline schedule.' + + field :owner, ::Types::UserType, null: false, description: 'Owner of the pipeline schedule.' + + field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates if a pipeline schedule is active.' + + field :next_run_at, Types::TimeType, null: false, description: 'Time when the next pipeline will run.' + + field :real_next_run, Types::TimeType, null: false, description: 'Time when the next pipeline will run.' + + field :last_pipeline, PipelineType, null: true, description: 'Last pipeline object.' + + field :ref_for_display, GraphQL::Types::String, + null: true, description: 'Git ref for the pipeline schedule.', method: :ref_for_display + + field :ref_path, GraphQL::Types::String, null: true, description: 'Path to the ref that triggered the pipeline.' + + field :for_tag, GraphQL::Types::Boolean, + null: false, description: 'Indicates if a pipelines schedule belongs to a tag.', method: :for_tag? + + field :cron, GraphQL::Types::String, null: false, description: 'Cron notation for the schedule.' + + field :cron_timezone, GraphQL::Types::String, null: false, description: 'Timezone for the pipeline schedule.' + + def ref_path + ::Gitlab::Routing.url_helpers.project_commits_path(object.project, object.ref_for_display) + end + end + end +end diff --git a/app/graphql/types/ci/runner_membership_filter_enum.rb b/app/graphql/types/ci/runner_membership_filter_enum.rb index 4fd7e0749b0..d59a68b427b 100644 --- a/app/graphql/types/ci/runner_membership_filter_enum.rb +++ b/app/graphql/types/ci/runner_membership_filter_enum.rb @@ -15,6 +15,13 @@ module Types description: "Include runners that have either a direct or inherited relationship. " \ "These runners can be specific to a project or a group.", value: :descendants + + value 'ALL_AVAILABLE', + description: + "Include all runners. This list includes runners for all projects in the group " \ + "and subgroups, as well as for the parent groups and instance.", + value: :all_available, + deprecated: { milestone: '15.5', reason: :alpha } end end end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index eb4e7b1dabf..dd2286d333d 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -57,7 +57,7 @@ module Types field :deployments, Types::DeploymentType.connection_type, null: true, - description: 'Deployments of the environment. This field can only be resolved for one project in any single request.', + description: 'Deployments of the environment. This field can only be resolved for one environment in any single request.', resolver: Resolvers::DeploymentsResolver do extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 end @@ -72,3 +72,5 @@ module Types end end end + +Types::EnvironmentType.prepend_mod_with('Types::EnvironmentType') diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index d897f3cde48..76fac831199 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -43,8 +43,10 @@ module Types field :updated_by, Types::UserType, null: true, description: 'User that last updated the issue.' - field :labels, Types::LabelType.connection_type, null: true, - description: 'Labels of the issue.' + field :labels, Types::LabelType.connection_type, + null: true, + description: 'Labels of the issue.', + resolver: Resolvers::BulkLabelsResolver field :milestone, Types::MilestoneType, null: true, description: 'Milestone of the issue.' @@ -58,15 +60,20 @@ module Types description: 'Indicates the issue is hidden because the author has been banned. ' \ 'Will always return `null` if `ban_user_feature_flag` feature flag is disabled.' - field :downvotes, GraphQL::Types::Int, null: false, - description: 'Number of downvotes the issue has received.' + field :downvotes, GraphQL::Types::Int, + null: false, + description: 'Number of downvotes the issue has received.', + resolver: Resolvers::DownVotesCountResolver field :merge_requests_count, GraphQL::Types::Int, null: false, description: 'Number of merge requests that close the issue on merge.', resolver: Resolvers::MergeRequestsCountResolver field :relative_position, GraphQL::Types::Int, null: true, description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' - field :upvotes, GraphQL::Types::Int, null: false, - description: 'Number of upvotes the issue has received.' + field :upvotes, GraphQL::Types::Int, + null: false, + description: 'Number of upvotes the issue has received.', + resolver: Resolvers::UpVotesCountResolver + field :user_discussions_count, GraphQL::Types::Int, null: false, description: 'Number of user discussions in the issue.', resolver: Resolvers::UserDiscussionsCountResolver diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 399dcc8e03d..8cc600fc68e 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -75,8 +75,12 @@ module Types null: false, calls_gitaly: true, method: :diverged_from_target_branch?, description: 'Indicates if the source branch is behind the target branch.' - field :downvotes, GraphQL::Types::Int, null: false, - description: 'Number of downvotes for the merge request.' + + field :downvotes, GraphQL::Types::Int, + null: false, + description: 'Number of downvotes for the merge request.', + resolver: Resolvers::DownVotesCountResolver + field :force_remove_source_branch, GraphQL::Types::Boolean, method: :force_remove_source_branch?, null: true, description: 'Indicates if the project settings will lead to source branch deletion after merge.' field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true, @@ -118,8 +122,12 @@ module Types null: false, calls_gitaly: true, method: :target_branch_exists?, description: 'Indicates if the target branch of the merge request exists.' - field :upvotes, GraphQL::Types::Int, null: false, - description: 'Number of upvotes for the merge request.' + + field :upvotes, GraphQL::Types::Int, + null: false, + description: 'Number of upvotes for the merge request.', + resolver: Resolvers::UpVotesCountResolver + field :user_discussions_count, GraphQL::Types::Int, null: true, description: 'Number of user discussions in the merge request.', resolver: Resolvers::UserDiscussionsCountResolver @@ -150,8 +158,11 @@ module Types description: 'Human-readable time estimate of the merge request.' field :human_total_time_spent, GraphQL::Types::String, null: true, description: 'Human-readable total time reported as spent on the merge request.' - field :labels, Types::LabelType.connection_type, null: true, complexity: 5, - description: 'Labels of the merge request.' + field :labels, Types::LabelType.connection_type, + null: true, complexity: 5, + description: 'Labels of the merge request.', + resolver: Resolvers::BulkLabelsResolver + field :milestone, Types::MilestoneType, null: true, description: 'Milestone of the merge request.' field :participants, Types::MergeRequests::ParticipantType.connection_type, null: true, complexity: 15, diff --git a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb index 3de6296154d..1ba72ae33b5 100644 --- a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb +++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb @@ -42,6 +42,9 @@ module Types value 'POLICIES_DENIED', value: :policies_denied, description: 'There are denied policies for the merge request.' + value 'EXTERNAL_STATUS_CHECKS', + value: :status_checks_must_pass, + description: 'Status checks must pass.' end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ea833b35085..5ffc1aeacad 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -114,6 +114,7 @@ module Types mount_mutation Mutations::Ci::Pipeline::Cancel mount_mutation Mutations::Ci::Pipeline::Destroy mount_mutation Mutations::Ci::Pipeline::Retry + mount_mutation Mutations::Ci::PipelineSchedule::Delete mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: { reason: :renamed, replacement: 'ProjectCiCdSettingsUpdate', @@ -137,6 +138,8 @@ module Types mount_mutation Mutations::UserCallouts::Create mount_mutation Mutations::UserPreferences::Update mount_mutation Mutations::Packages::Destroy + mount_mutation Mutations::Packages::BulkDestroy, + extensions: [::Gitlab::Graphql::Limit::FieldCallCount => { limit: 1 }] mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update @@ -146,7 +149,6 @@ module Types mount_mutation Mutations::WorkItems::Delete, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::DeleteTask, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::Update, alpha: { milestone: '15.1' } - mount_mutation Mutations::WorkItems::UpdateWidgets, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' } mount_mutation Mutations::SavedReplies::Create mount_mutation Mutations::SavedReplies::Update diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index 7a0abe619a5..84becba8001 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -8,9 +8,50 @@ module Types authorize :admin_package - field :generic_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' - field :generic_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate generic packages are allowed for this namespace.' - field :maven_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' - field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' + field :generic_duplicate_exception_regex, Types::UntrustedRegexp, + null: true, + description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' + field :generic_duplicates_allowed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether duplicate generic packages are allowed for this namespace.' + field :maven_duplicate_exception_regex, Types::UntrustedRegexp, + null: true, + description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' + field :maven_duplicates_allowed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' + + field :maven_package_requests_forwarding, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether Maven package forwarding is allowed for this namespace.' + field :npm_package_requests_forwarding, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether npm package forwarding is allowed for this namespace.' + field :pypi_package_requests_forwarding, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether PyPI package forwarding is allowed for this namespace.' + + field :lock_maven_package_requests_forwarding, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether Maven package forwarding is locked for all descendent namespaces.' + field :lock_npm_package_requests_forwarding, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether npm package forwarding is locked for all descendent namespaces.' + field :lock_pypi_package_requests_forwarding, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether PyPI package forwarding is locked for all descendent namespaces.' + + field :maven_package_requests_forwarding_locked, GraphQL::Types::Boolean, + null: false, + method: :maven_package_requests_forwarding_locked?, + description: 'Indicates whether Maven package forwarding settings are locked by a parent namespace.' + field :npm_package_requests_forwarding_locked, GraphQL::Types::Boolean, + null: false, + method: :npm_package_requests_forwarding_locked?, + description: 'Indicates whether npm package forwarding settings are locked by a parent namespace.' + field :pypi_package_requests_forwarding_locked, GraphQL::Types::Boolean, + null: false, + method: :pypi_package_requests_forwarding_locked?, + description: 'Indicates whether PyPI package forwarding settings are locked by a parent namespace.' end end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index c254460a51f..eef5ce40bde 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -41,7 +41,7 @@ module Types deprecated: { reason: :renamed, replacement: 'internal', - milestone: '15.3' + milestone: '15.5' } field :internal, GraphQL::Types::Boolean, null: true, diff --git a/app/graphql/types/permission_types/ci/pipeline_schedules.rb b/app/graphql/types/permission_types/ci/pipeline_schedules.rb new file mode 100644 index 00000000000..268ac6096d0 --- /dev/null +++ b/app/graphql/types/permission_types/ci/pipeline_schedules.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + module Ci + class PipelineSchedules < BasePermissionType + graphql_name 'PipelineSchedulePermissions' + + abilities :take_ownership_pipeline_schedule, + :update_pipeline_schedule, + :admin_pipeline_schedule + + ability_field :play_pipeline_schedule, calls_gitaly: true + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index f43f5c27dac..a41af34ef4c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -309,6 +309,12 @@ module Types extras: [:lookahead], resolver: Resolvers::ProjectPipelinesResolver + field :pipeline_schedules, + type: Types::Ci::PipelineScheduleType.connection_type, + null: true, + description: 'Pipeline schedules of the project. This field can only be resolved for one project per request.', + resolver: Resolvers::ProjectPipelineSchedulesResolver + field :pipeline, Types::Ci::PipelineType, null: true, description: 'Build pipeline of the project.', diff --git a/app/graphql/types/projects/branch_rule_type.rb b/app/graphql/types/projects/branch_rule_type.rb index 866cff0f439..e7632c17cca 100644 --- a/app/graphql/types/projects/branch_rule_type.rb +++ b/app/graphql/types/projects/branch_rule_type.rb @@ -13,6 +13,13 @@ module Types null: false, description: 'Branch name, with wildcards, for the branch rules.' + field :is_default, + type: GraphQL::Types::Boolean, + null: false, + method: :default_branch?, + calls_gitaly: true, + description: "Check if this branch rule protects the project's default branch." + field :branch_protection, type: Types::BranchRules::BranchProtectionType, null: false, @@ -31,3 +38,5 @@ module Types end end end + +Types::Projects::BranchRuleType.prepend_mod diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 78463a1804a..1b39f43659e 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -154,6 +154,12 @@ module Types null: true, description: "Whether Gitpod is enabled in application settings." + field :jobs, + ::Types::Ci::JobType.connection_type, + null: true, + description: 'All jobs on this GitLab instance.', + resolver: ::Resolvers::Ci::AllJobsResolver + def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index ef701bbfc10..3b8f5c64beb 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -13,6 +13,9 @@ module Types field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the title of an issuable is updated.' + field :issuable_description_updated, subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the description of an issuable is updated.' + field :issuable_labels_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the labels of an issuable are updated.' @@ -23,6 +26,11 @@ module Types subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the reviewers of a merge request are updated.' + + field :merge_request_merge_status_updated, + subscription: Subscriptions::IssuableUpdated, + null: true, + description: 'Triggered when the merge status of a merge request is updated.' end end diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index eca8c8d845a..a3943361114 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -23,6 +23,9 @@ module Types ORPHAN_TYPES end + # Whenever a new widget is added make sure to update the spec to avoid N + 1 queries in + # spec/requests/api/graphql/project/work_items_spec.rb and add the necessary preloads + # in app/graphql/resolvers/work_items_resolver.rb def self.resolve_type(object, context) case object when ::WorkItems::Widgets::Description diff --git a/app/graphql/types/work_items/widgets/labels_update_input_type.rb b/app/graphql/types/work_items/widgets/labels_update_input_type.rb new file mode 100644 index 00000000000..d38b8cefa63 --- /dev/null +++ b/app/graphql/types/work_items/widgets/labels_update_input_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class LabelsUpdateInputType < BaseInputObject + graphql_name 'WorkItemWidgetLabelsUpdateInput' + + argument :add_label_ids, [Types::GlobalIDType[::Label]], + required: false, + description: 'Global IDs of labels to be added to the work item.', + prepare: ->(label_ids, _ctx) { label_ids.map(&:model_id) } + argument :remove_label_ids, [Types::GlobalIDType[::Label]], + required: false, + description: 'Global IDs of labels to be removed from the work item.', + prepare: ->(label_ids, _ctx) { label_ids.map(&:model_id) } + end + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a75c1b16145..32af1599bd1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -313,7 +313,6 @@ module ApplicationHelper class_names = [] class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards) class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards) - class_names << 'environment-logs-page' if current_controller?(:logs) class_names << 'with-performance-bar' if performance_bar_enabled? class_names << system_message_class class_names << marketing_header_experiment_class @@ -428,7 +427,7 @@ module ApplicationHelper milestones: milestones_project_autocomplete_sources_path(object), commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), snippets: snippets_project_autocomplete_sources_path(object), - contacts: contacts_project_autocomplete_sources_path(object) + contacts: contacts_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]) } end end @@ -448,6 +447,10 @@ module ApplicationHelper form_for(record, *(args << options.merge({ builder: ::Gitlab::FormBuilders::GitlabUiFormBuilder })), &block) end + def gitlab_ui_form_with(**args, &block) + form_with(**args.merge({ builder: ::Gitlab::FormBuilders::GitlabUiFormBuilder }), &block) + end + private def appearance diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index ddc682bc08a..21b18203677 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -441,7 +441,8 @@ module ApplicationSettingsHelper :group_runner_token_expiration_interval, :project_runner_token_expiration_interval, :pipeline_limit_per_project_user_sha, - :invitation_flow_enforcement + :invitation_flow_enforcement, + :can_create_group ].tap do |settings| next if Gitlab.com? diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index f98e70e41d8..db6cf27566f 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -2,18 +2,15 @@ module BoardsHelper def board - @board ||= @board || @boards.first + @board end def board_data { - boards_endpoint: @boards_endpoint, - lists_endpoint: board_lists_path(board), board_id: board.id, disabled: board.disabled_for?(current_user).to_s, root_path: root_path, full_path: full_path, - bulk_update_path: @bulk_issues_path, can_update: can_update?.to_s, can_admin_list: can_admin_list?.to_s, can_admin_board: can_admin_board?.to_s, @@ -94,14 +91,6 @@ module BoardsHelper !multiple_boards_available? && current_board_parent.boards.size > 1 end - def current_board_path(board) - @current_board_path ||= if board.group_board? - group_board_path(current_board_parent, board) - else - project_board_path(current_board_parent, board) - end - end - def current_board_parent @current_board_parent ||= @group || @project end @@ -121,18 +110,6 @@ module BoardsHelper def can_admin_board? can?(current_user, :admin_issue_board, current_board_parent) end - - def can_admin_issue? - can?(current_user, :admin_issue, current_board_parent) - end - - def serializer - CurrentBoardSerializer.new - end - - def current_board_json - serializer.represent(board).as_json - end end BoardsHelper.prepend_mod_with('BoardsHelper') diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index d00301678dd..99a92ba9b59 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -11,7 +11,6 @@ module Ci def js_pipeline_editor_data(project) initial_branch = params[:branch_name] latest_commit = project.repository.commit(initial_branch) || project.commit - commit_sha = latest_commit ? latest_commit.sha : '' total_branches = project.repository_exists? ? project.repository.branch_count : 0 { @@ -27,17 +26,26 @@ module Ci "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => namespace_project_new_merge_request_path, - "pipeline_etag" => latest_commit ? graphql_etag_pipeline_sha_path(commit_sha) : '', + "pipeline_etag" => latest_commit ? graphql_etag_pipeline_sha_path(latest_commit.sha) : '', "pipeline-page-path" => project_pipelines_path(project), "project-path" => project.path, "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, "simulate-pipeline-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'simulate-a-cicd-pipeline'), "total-branches" => total_branches, + "uses-external-config" => uses_external_config?(project) ? 'true' : 'false', "validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'), "yml-help-page-path" => help_page_path('ci/yaml/index') } end + + private + + def uses_external_config?(project) + ci_config_source = Gitlab::Ci::ProjectConfig.new(project: project, sha: nil).source + + [:external_project_source, :remote_source].include?(ci_config_source) + end end end diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index a67771116b9..c93c8dd8d76 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -69,7 +69,8 @@ module Ci end def has_pipeline_badges?(pipeline) - pipeline.child? || + pipeline.schedule? || + pipeline.child? || pipeline.latest? || pipeline.merge_train_pipeline? || pipeline.has_yaml_errors? || diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb index e955ad4cfda..9ecf780f55b 100644 --- a/app/helpers/compare_helper.rb +++ b/app/helpers/compare_helper.rb @@ -42,7 +42,8 @@ module CompareHelper source_project_refs_path: refs_project_path(project), target_project_refs_path: refs_project_path(@target_project), params_from: params[:from], - params_to: params[:to] + params_to: params[:to], + straight: params[:straight] }.tap do |data| data[:projects_from] = target_projects(project).map do |target_project| { id: target_project.id, name: target_project.full_path } diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index bcddb889cf4..b717cbcc312 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -16,6 +16,54 @@ module EventsHelper 'joined' => 'users' }.freeze + def localized_action_name_map + { + accepted: s_('Event|accepted'), + approved: s_('Event|approved'), + closed: s_('Event|closed'), + 'commented on': s_('Event|commented on'), + created: s_('Event|created'), + destroyed: s_('Event|destroyed'), + joined: s_('Event|joined'), + left: s_('Event|left'), + opened: s_('Event|opened'), + updated: s_('Event|updated'), + 'removed due to membership expiration from': s_('Event|removed due to membership expiration from') + }.merge(localized_push_action_name_map, + localized_created_project_action_name_map, + localized_design_action_names + ).freeze + end + + def localized_push_action_name_map + { + 'pushed new': s_('Event|pushed new'), + deleted: s_('Event|deleted'), + 'pushed to': s_('Event|pushed to') + }.freeze + end + + def localized_created_project_action_name_map + { + created: s_('Event|created'), + imported: s_('Event|imported') + }.freeze + end + + def localized_design_action_names + { + added: s_('Event|added'), + updated: s_('Event|updated'), + removed: s_('Event|removed') + }.freeze + end + + def localized_action_name(event) + action_name = event.action_name + # The action fallback is used to cover the types were not included in the maps. + localized_action_name_map[action_name.to_sym] || action_name + end + def link_to_author(event, self_added: false) author = event.author diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index f2e24f54391..9e42aeea9ce 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -39,13 +39,13 @@ module FormHelper end end - def dropdown_max_select(data) - return data[:'max-select'] unless Feature.enabled?(:limit_reviewer_and_assignee_size) + def dropdown_max_select(data, feature_flag) + return data[:'max-select'] unless Feature.enabled?(feature_flag) - if data[:'max-select'] && data[:'max-select'] < MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + if data[:'max-select'] && data[:'max-select'] < ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS data[:'max-select'] else - MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS end end @@ -117,10 +117,16 @@ module FormHelper dropdown_data = multiple_reviewers_dropdown_options(dropdown_data) end + dropdown_data[:data].merge!(reviewers_dropdown_options_for_suggested_reviewers) dropdown_data end # Overwritten + def reviewers_dropdown_options_for_suggested_reviewers + {} + end + + # Overwritten def issue_supports_multiple_assignees? false end @@ -156,7 +162,12 @@ module FormHelper new_options[:title] = _('Select assignee(s)') new_options[:data][:'dropdown-header'] = 'Assignee(s)' - new_options[:data].delete(:'max-select') + + if Feature.enabled?(:limit_assignees_per_issuable) + new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + else + new_options[:data].delete(:'max-select') + end new_options end @@ -168,7 +179,7 @@ module FormHelper new_options[:data][:'dropdown-header'] = _('Reviewer(s)') if Feature.enabled?(:limit_reviewer_and_assignee_size) - new_options[:data][:'max-select'] = MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS else new_options[:data].delete(:'max-select') end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index f77bd6621f9..6b00c213875 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -126,8 +126,8 @@ module GroupsHelper group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group) end - def show_thanks_for_purchase_alert? - params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0 + def show_thanks_for_purchase_alert?(quantity) + quantity.to_i > 0 end def project_list_sort_by @@ -177,7 +177,8 @@ module GroupsHelper subgroups_and_projects_endpoint: group_children_path(group, format: :json), shared_projects_endpoint: group_shared_projects_path(group, format: :json), archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'), - current_group_visibility: group.visibility + current_group_visibility: group.visibility, + initial_sort: project_list_sort_by }.merge(subgroups_and_projects_list_app_data(group)) end diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 1e50033e0e0..e050ccc0e40 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true module HooksHelper + def webhook_form_data(hook) + { + url: hook.url, + url_variables: nil + } + end + def link_to_test_hook(hook, trigger) path = test_hook_path(hook, trigger) trigger_human_name = trigger.to_s.tr('_', ' ').camelize diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index ec1327cf7ae..5b3ca25b5af 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -3,6 +3,32 @@ module IdeHelper def ide_data { + 'can-use-new-web-ide' => can_use_new_web_ide?.to_s, + 'use-new-web-ide' => use_new_web_ide?.to_s, + 'user-preferences-path' => profile_preferences_path, + 'branch-name' => @branch + }.merge(use_new_web_ide? ? new_ide_data : legacy_ide_data) + end + + def can_use_new_web_ide? + Feature.enabled?(:vscode_web_ide, current_user) + end + + def use_new_web_ide? + can_use_new_web_ide? && !current_user.use_legacy_web_ide + end + + private + + def new_ide_data + { + 'project-path' => @project&.path_with_namespace, + 'csp-nonce' => content_security_policy_nonce + } + end + + def legacy_ide_data + { 'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'), 'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'), 'committed-state-svg-path' => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), @@ -13,7 +39,6 @@ module IdeHelper 'clientside-preview-enabled': Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?.to_s, 'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s, 'codesandbox-bundler-url': Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url, - 'branch-name' => @branch, 'default-branch' => @project && @project.default_branch, 'file-path' => @path, 'merge-request' => @merge_request, @@ -24,13 +49,10 @@ module IdeHelper 'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'), 'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'), 'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'), - 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration'), - 'csp-nonce' => content_security_policy_nonce + 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration') } end - private - def convert_to_project_entity_json(project) return unless project diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 96daf398243..2804a58da9e 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -157,9 +157,9 @@ module IssuablesHelper if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type) output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' }) - output << s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) } + output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2' ) else - output << s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) } + output << content_tag(:span, s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2' ) end if issuable.is_a?(Issue) && issuable.service_desk_reply_to diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index a157b1b7b21..115cdd432e3 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -3,6 +3,10 @@ module IssuesHelper include Issues::IssueTypeHelpers + def can_admin_issue? + can?(current_user, :admin_issue, @group || @project) + end + def issue_css_classes(issue) classes = ["issue"] classes << "closed" if issue.closed? @@ -11,6 +15,11 @@ module IssuesHelper classes.join(' ') end + def show_timeline_view_toggle?(issue) + # Overridden in EE + false + end + def issue_manual_ordering_class is_sorting_by_relative_position = @sort == 'relative_position' diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index fc558958ca3..866399f3021 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -12,22 +12,6 @@ module MarkupHelper # https://gitlab.com/gitlab-org/gitlab/-/issues/365358 RENDER_TIMEOUT = 5.seconds - def plain?(filename) - Gitlab::MarkupHelper.plain?(filename) - end - - def markup?(filename) - Gitlab::MarkupHelper.markup?(filename) - end - - def gitlab_markdown?(filename) - Gitlab::MarkupHelper.gitlab_markdown?(filename) - end - - def asciidoc?(filename) - Gitlab::MarkupHelper.asciidoc?(filename) - end - # Use this in places where you would normally use link_to(gfm(...), ...). def link_to_markdown(body, url, html_options = {}) return '' if body.blank? @@ -88,8 +72,10 @@ module MarkupHelper tags = %w(a gl-emoji b strong i em pre code p span) tags << 'img' if options[:allow_images] - text = truncate_visible(md, max_chars || md.length) - text = prepare_for_rendering(text, markdown_field_render_context(object, attribute, options)) + context = markdown_field_render_context(object, attribute, options) + context.reverse_merge!(truncate_visible_max_chars: max_chars || md.length) + + text = prepare_for_rendering(md, context) text = sanitize( text, tags: tags, @@ -146,11 +132,11 @@ module MarkupHelper return '' unless text.present? markup = proc do - if gitlab_markdown?(file_name) + if Gitlab::MarkupHelper.gitlab_markdown?(file_name) markdown_unsafe(text, context) - elsif asciidoc?(file_name) + elsif Gitlab::MarkupHelper.asciidoc?(file_name) asciidoc_unsafe(text, context) - elsif plain?(file_name) + elsif Gitlab::MarkupHelper.plain?(file_name) plain_unsafe(text) else other_markup_unsafe(file_name, text, context) @@ -207,55 +193,6 @@ module MarkupHelper { project: wiki.container } end - # Return +text+, truncated to +max_chars+ characters, excluding any HTML - # tags. - def truncate_visible(text, max_chars) - doc = Nokogiri::HTML.fragment(text) - content_length = 0 - truncated = false - - doc.traverse do |node| - if node.text? || node.content.empty? - if truncated - node.remove - next - end - - # Handle line breaks within a node - if node.content.strip.lines.length > 1 - node.content = "#{node.content.lines.first.chomp}..." - truncated = true - end - - num_remaining = max_chars - content_length - if node.content.length > num_remaining - node.content = node.content.truncate(num_remaining) - truncated = true - end - - content_length += node.content.length - end - - truncated = truncate_if_block(node, truncated) - end - - doc.to_html - end - - # Used by #truncate_visible. If +node+ is the first block element, and the - # text hasn't already been truncated, then append "..." to the node contents - # and return true. Otherwise return false. - def truncate_if_block(node, truncated) - return true if truncated - - if node.element? && (node.description&.block? || node.matches?('pre > code > .line')) - node.inner_html = "#{node.inner_html}..." if node.next_sibling - true - else - truncated - end - end - def strip_empty_link_tags(text) scrubber = Loofah::Scrubber.new do |node| node.remove if node.name == 'a' && node.children.empty? diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb new file mode 100644 index 00000000000..272a3970bc2 --- /dev/null +++ b/app/helpers/milestones_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module MilestonesHelper + def milestone_header_class(primary, issuables) + header_color = milestone_header_color(primary: primary) + header_border = milestone_header_border(issuables) + + "#{header_color} #{header_border} gl-display-flex" + end + + def milestone_counter_class(primary) + primary ? 'gl-text-white' : 'gl-text-gray-500' + end + + private + + def milestone_header_color(primary: false) + return '' unless primary + + 'gl-bg-blue-500 gl-text-white' + end + + def milestone_header_border(issuables) + issuables.empty? ? 'gl-border-bottom-0 gl-rounded-base' : '' + end +end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 4d6ab7b8bf9..0cf2c5cea4c 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -49,7 +49,7 @@ module NavHelper end def page_has_markdown? - current_path?('merge_requests#show') || + current_path?('projects/merge_requests#show') || current_path?('projects/merge_requests/conflicts#show') || current_path?('issues#show') || current_path?('milestones#show') || diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e760fad7be9..cddcdf77710 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -471,8 +471,24 @@ module ProjectsHelper } end + def localized_project_human_access(access) + localized_access_names[access] || Gitlab::Access.human_access(access) + end + private + def localized_access_names + { + Gitlab::Access::NO_ACCESS => _('No access'), + Gitlab::Access::MINIMAL_ACCESS => _("Minimal Access"), + Gitlab::Access::GUEST => _('Guest'), + Gitlab::Access::REPORTER => _('Reporter'), + Gitlab::Access::DEVELOPER => _('Developer'), + Gitlab::Access::MAINTAINER => _('Maintainer'), + Gitlab::Access::OWNER => _('Owner') + } + end + def configure_oauth_import_message(provider, help_url) str = if current_user.admin? 'ImportProjects|To enable importing projects from %{provider}, as administrator you need to configure %{link_start}OAuth integration%{link_end}' diff --git a/app/helpers/recaptcha_helper.rb b/app/helpers/recaptcha_helper.rb index 5b17ab4b815..59f0dc8f819 100644 --- a/app/helpers/recaptcha_helper.rb +++ b/app/helpers/recaptcha_helper.rb @@ -2,9 +2,27 @@ module RecaptchaHelper def recaptcha_enabled? + return false if gitlab_qa? + !!Gitlab::Recaptcha.enabled? end alias_method :show_recaptcha_sign_up?, :recaptcha_enabled? + + def recaptcha_enabled_on_login? + return false if gitlab_qa? + + Gitlab::Recaptcha.enabled_on_login? + end + + private + + def gitlab_qa? + return false unless Gitlab.com? + return false unless request.user_agent.present? + return false unless Gitlab::Environment.qa_user_agent.present? + + ActiveSupport::SecurityUtils.secure_compare(request.user_agent, Gitlab::Environment.qa_user_agent) + end end RecaptchaHelper.prepend_mod diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 50089c7edab..e0db40ebaee 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -82,7 +82,7 @@ module ReleasesHelper markdown_docs_path: help_page_path('user/markdown'), release_assets_docs_path: releases_help_page_path(anchor: 'release-assets'), manage_milestones_path: project_milestones_path(@project), - new_milestone_path: new_project_milestone_path(@project), + new_milestone_path: new_project_milestone_path(@project, redirect_path: 'new_release'), edit_release_docs_path: releases_help_page_path(anchor: 'edit-a-release'), upcoming_release_docs_path: releases_help_page_path(anchor: 'upcoming-releases') } diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index b16235893ae..f2b88287277 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -380,6 +380,40 @@ module SearchHelper end end + def search_filter_link_json(scope, label, data, search) + scope_name = scope.to_s + search_params = params.merge(search).merge({ scope: scope_name }).permit(SEARCH_GENERIC_PARAMS) + active_scope = @scope == scope_name + + result = { label: label, scope: scope_name, data: data, link: search_path(search_params), active: active_scope } + result[:count] = @search_results.formatted_count(scope_name) if active_scope && !@timeout + result[:count_link] = search_count_path(search_params) unless active_scope + + result + end + + # search page scope navigation + def search_navigation + { + projects: { label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? }, + blobs: { label: _("Code"), data: { qa_selector: 'code_tab' }, condition: project_search_tabs?(:blobs) || search_service.show_elasticsearch_tabs? || feature_flag_tab_enabled?(:global_search_code_tab) }, + issues: { label: _("Issues"), condition: project_search_tabs?(:issues) || feature_flag_tab_enabled?(:global_search_issues_tab) }, + merge_requests: { label: _("Merge requests"), condition: project_search_tabs?(:merge_requests) || feature_flag_tab_enabled?(:global_search_merge_requests_tab) }, + wiki_blobs: { label: _("Wiki"), condition: project_search_tabs?(:wiki) || search_service.show_elasticsearch_tabs? }, + commits: { label: _("Commits"), condition: project_search_tabs?(:commits) || (search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab)) }, + notes: { label: _("Comments"), condition: project_search_tabs?(:notes) || search_service.show_elasticsearch_tabs? }, + milestones: { label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? }, + users: { label: _("Users"), condition: show_user_search_tab? }, + snippet_titles: { label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: @show_snippets.present? && @project.nil? } + } + end + + def search_navigation_json + search_navigation.each_with_object({}) do |(key, value), hash| + hash[key] = search_filter_link_json(key, value[:label], value[:data], value[:search]) if value[:condition] + end.to_json + end + def search_filter_input_options(type, placeholder = _('Search or filter results...')) opts = { diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 88aff31af54..14ee6007a43 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -39,11 +39,6 @@ module SelectsHelper select2_tag(id, opts) end - def namespace_select_tag(id, opts = {}) - opts[:class] = [*opts[:class], 'ajax-namespace-select'].join(' ') - select2_tag(id, opts) - end - def project_select_tag(id, opts = {}) opts[:class] = [*opts[:class], 'ajax-project-select'].join(' ') diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 129180d1ccf..56138ba95c2 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -49,6 +49,6 @@ module SessionsHelper match = regex.match(email) return email unless match - match[1] + '*' * match[2].length + match[3] + '*' * match[4].length + match[5] + match[1] + '*' * (match[2] || '').length + match[3] + '*' * (match[4] || '').length + match[5] end end diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index ecedbfb2a4f..cb6f60ab79b 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -8,12 +8,12 @@ module TimeHelper if minutes >= 1 if seconds % 60 == 0 - pluralize(minutes, "minute") + n_('%d minute', '%d minutes', minutes) % minutes else - [pluralize(minutes, "minute"), pluralize(seconds, "second")].to_sentence + [n_('%d minute', '%d minutes', minutes) % minutes, n_('%d second', '%d seconds', seconds) % seconds].to_sentence end else - pluralize(seconds, "second") + n_('%d second', '%d seconds', seconds) % seconds end end diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index 11d09a79dcf..e0e6229bc6d 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -77,14 +77,10 @@ module TimeboxesHelper end def milestone_progress_bar(milestone) - options = { - class: 'progress-bar bg-success', - style: "width: #{milestone.percent_complete}%;" - } - - content_tag :div, class: 'progress' do - content_tag :div, nil, options - end + render Pajamas::ProgressComponent.new( + value: milestone.percent_complete, + variant: :success + ) end def milestone_time_for(date, date_type) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index ecf29c41100..520cde9ecee 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -130,12 +130,12 @@ module TodosHelper def todos_filter_params { - state: params[:state], + state: params[:state].presence, project_id: params[:project_id], author_id: params[:author_id], type: params[:type], action_id: params[:action_id] - } + }.compact end def todos_filter_empty? @@ -168,22 +168,22 @@ module TodosHelper def todo_actions_options [ - { id: '', text: 'Any Action' }, - { id: Todo::ASSIGNED, text: 'Assigned' }, - { id: Todo::REVIEW_REQUESTED, text: 'Review requested' }, - { id: Todo::MENTIONED, text: 'Mentioned' }, - { id: Todo::MARKED, text: 'Added' }, - { id: Todo::BUILD_FAILED, text: 'Pipelines' } + { id: '', text: s_('Todos|Any Action') }, + { id: Todo::ASSIGNED, text: s_('Todos|Assigned') }, + { id: Todo::REVIEW_REQUESTED, text: s_('Todos|Review requested') }, + { id: Todo::MENTIONED, text: s_('Todos|Mentioned') }, + { id: Todo::MARKED, text: s_('Todos|Added') }, + { id: Todo::BUILD_FAILED, text: s_('Todos|Pipelines') } ] end def todo_types_options [ - { id: '', text: 'Any Type' }, - { id: 'Issue', text: 'Issue' }, - { id: 'MergeRequest', text: 'Merge request' }, - { id: 'DesignManagement::Design', text: 'Design' }, - { id: 'AlertManagement::Alert', text: 'Alert' } + { id: '', text: s_('Todos|Any Type') }, + { id: 'Issue', text: s_('Todos|Issue') }, + { id: 'MergeRequest', text: s_('Todos|Merge request') }, + { id: 'DesignManagement::Design', text: s_('Todos|Design') }, + { id: 'AlertManagement::Alert', text: s_('Todos|Alert') } ] end diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index d8baa185370..a9fd219bbac 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -76,8 +76,8 @@ module Users user_dismissed?(WEB_HOOK_DISABLED, last_failure, project: project) end - def show_merge_request_settings_callout? - !user_dismissed?(MERGE_REQUEST_SETTINGS_MOVED_CALLOUT) + def show_merge_request_settings_callout?(project) + !user_dismissed?(MERGE_REQUEST_SETTINGS_MOVED_CALLOUT) && project.merge_requests_enabled? end def ultimate_feature_removal_banner_dismissed?(project) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 271fa47dd97..4f345fdeb9c 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -287,6 +287,21 @@ module UsersHelper } ] end + + # the keys should match the user model defined roles in app/models/user.rb + def localized_user_roles + { + software_developer: s_('User|Software Developer'), + development_team_lead: s_('User|Development Team Lead'), + devops_engineer: s_('User|Devops Engineer'), + systems_administrator: s_('User|Systems Administrator'), + security_analyst: s_('User|Security Analyst'), + data_analyst: s_('User|Data Analyst'), + product_manager: s_('User|Product Manager'), + product_designer: s_('User|Product Designer'), + other: s_('User|Other') + }.with_indifferent_access.freeze + end end UsersHelper.prepend_mod_with('UsersHelper') diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index d6ffd3deafe..017a1861905 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -59,14 +59,16 @@ module WikiHelper end end - def wiki_sort_controls(wiki, sort, direction) - sort ||= Wiki::TITLE_ORDER + def wiki_sort_controls(wiki, direction) link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort' reversed_direction = direction == 'desc' ? 'asc' : 'desc' icon_class = direction == 'desc' ? 'highest' : 'lowest' + title = direction == 'desc' ? _('Sort direction: Descending') : _('Sort direction: Ascending') - link_to(wiki_path(wiki, action: :pages, sort: sort, direction: reversed_direction), - type: 'button', class: link_class, title: _('Sort direction')) do + link_options = { action: :pages, direction: reversed_direction } + + link_to(wiki_path(wiki, **link_options), + type: 'button', class: link_class, title: title) do sprite_icon("sort-#{icon_class}") end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 8fe471a48f2..65ea90d0b5d 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -94,6 +94,18 @@ module Emails end end + def access_token_revoked_email(user, token_name) + return unless user&.active? + + @user = user + @token_name = token_name + @target_url = profile_personal_access_tokens_url + + Gitlab::I18n.with_locale(@user.preferred_language) do + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked"))) + end + end + def ssh_key_expired_email(user, fingerprints) return unless user&.active? @@ -131,6 +143,18 @@ module Emails end end + def two_factor_otp_attempt_failed_email(user, ip, time = Time.current) + @user = user + @ip = ip + @time = time + + Gitlab::I18n.with_locale(@user.preferred_language) do + email_with_layout( + to: @user.notification_email_or_default, + subject: subject(_("Attempted sign in to %{host} using a wrong two-factor authentication code") % { host: Gitlab.config.gitlab.host })) + end + end + def disabled_two_factor_email(user) return unless user diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index c5e60ecaadd..206518e582b 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -181,6 +181,10 @@ class NotifyPreview < ActionMailer::Preview Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current).message end + def two_factor_otp_attempt_failed_email + Notify.two_factor_otp_attempt_failed_email(user, '127.0.0.1').message + end + def new_email_address_added_email Notify.new_email_address_added_email(user, 'someone@gitlab.com').message end @@ -209,6 +213,18 @@ class NotifyPreview < ActionMailer::Preview Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message end + def project_was_exported_email + Notify.project_was_exported_email(user, project).message + end + + def request_review_merge_request_email + Notify.request_review_merge_request_email(user.id, merge_request.id, user.id).message + end + + def project_was_moved_email + Notify.project_was_moved_email(project.id, user.id, "gitlab/gitlab").message + end + private def project diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index edb9a2053b1..361b1a8dca9 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -120,7 +120,7 @@ class ApplicationSetting < ApplicationRecord if: :help_page_support_url_column_exists? validates :help_page_documentation_base_url, - length: { maximum: 255, message: _("is too long (maximum is %{count} characters)") }, + length: { maximum: 255, message: N_("is too long (maximum is %{count} characters)") }, allow_blank: true, addressable_url: true @@ -148,7 +148,7 @@ class ApplicationSetting < ApplicationRecord if: :akismet_enabled validates :spam_check_api_key, - length: { maximum: 2000, message: _('is too long (maximum is %{count} characters)') }, + length: { maximum: 2000, message: N_('is too long (maximum is %{count} characters)') }, allow_blank: true validates :unique_ips_limit_per_user, @@ -228,7 +228,7 @@ class ApplicationSetting < ApplicationRecord validates :default_artifacts_expire_in, presence: true, duration: true validates :container_expiration_policies_enable_historic_entries, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :container_registry_token_expire_delay, presence: true, @@ -320,8 +320,8 @@ class ApplicationSetting < ApplicationRecord validates :personal_access_token_prefix, format: { with: %r{\A[a-zA-Z0-9_+=/@:.-]+\z}, - message: _("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") }, - length: { maximum: 20, message: _('is too long (maximum is %{count} characters)') }, + message: N_("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") }, + length: { maximum: 20, message: N_('is too long (maximum is %{count} characters)') }, allow_blank: true validates :commit_email_hostname, format: { with: /\A[^@]+\z/ } @@ -369,7 +369,7 @@ class ApplicationSetting < ApplicationRecord validates :email_restrictions, untrusted_regexp: true - validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") } + validates :hashed_storage_enabled, inclusion: { in: [true], message: N_("Hashed storage can't be disabled anymore for new projects") } validates :container_registry_delete_tags_service_timeout, :container_registry_cleanup_tags_service_max_list_size, @@ -377,7 +377,7 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :container_registry_expiration_policies_caching, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :container_registry_import_max_tags_count, :container_registry_import_max_retries, @@ -404,11 +404,18 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :invisible_captcha_enabled, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :invitation_flow_enforcement, + validates :invitation_flow_enforcement, :can_create_group, allow_nil: false, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } + + # rubocop:disable Cop/StaticTranslationDefinition + validates :deactivate_dormant_users_period, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 90, message: _("'%{value}' days of inactivity must be greater than or equal to 90") }, + if: :deactivate_dormant_users? + # rubocop:enable Cop/StaticTranslationDefinition Gitlab::SSHPublicKey.supported_types.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } @@ -513,11 +520,11 @@ class ApplicationSetting < ApplicationRecord rsa_key: true, allow_nil: true validates :rate_limiting_response_text, - length: { maximum: 255, message: _('is too long (maximum is %{count} characters)') }, + length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') }, allow_blank: true validates :jira_connect_application_key, - length: { maximum: 255, message: _('is too long (maximum is %{count} characters)') }, + length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') }, allow_blank: true with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do @@ -561,7 +568,7 @@ class ApplicationSetting < ApplicationRecord allow_nil: false validates :admin_mode, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :external_pipeline_validation_service_url, addressable_url: true, allow_blank: true @@ -574,7 +581,7 @@ class ApplicationSetting < ApplicationRecord inclusion: { in: ApplicationSetting.whats_new_variants.keys } validates :floc_enabled, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } enum sidekiq_job_limiter_mode: { Gitlab::SidekiqMiddleware::SizeLimiter::Validator::TRACK_MODE => 0, @@ -589,7 +596,7 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :sentry_enabled, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :sentry_dsn, addressable_url: true, presence: true, length: { maximum: 255 }, if: :sentry_enabled? @@ -601,7 +608,7 @@ class ApplicationSetting < ApplicationRecord if: :sentry_enabled? validates :error_tracking_enabled, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } validates :error_tracking_api_url, presence: true, addressable_url: true, @@ -667,9 +674,10 @@ class ApplicationSetting < ApplicationRecord attr_encrypted :arkose_labs_public_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :jitsu_administrator_password, encryption_options_base_32_aes_256_gcm validates :disable_feed_token, - inclusion: { in: [true, false], message: _('must be a boolean value') } + inclusion: { in: [true, false], message: N_('must be a boolean value') } before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? @@ -791,6 +799,10 @@ class ApplicationSetting < ApplicationRecord ::AsciidoctorExtensions::Kroki::SUPPORTED_DIAGRAM_NAMES.include?(diagram_type) end + def personal_access_tokens_disabled? + false + end + private def parsed_grafana_url diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 4d377855dea..dee4bd07fd9 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -240,7 +240,8 @@ module ApplicationSettingImplementation search_rate_limit: 30, search_rate_limit_unauthenticated: 10, users_get_by_id_limit: 300, - users_get_by_id_limit_allowlist: [] + users_get_by_id_limit_allowlist: [], + can_create_group: true } end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 5430575ace7..e9530a80d9f 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -73,4 +73,8 @@ class AwardEmoji < ApplicationRecord awardable.expire_etag_cache if awardable.is_a?(Note) awardable.try(:update_upvotes_count) if upvote? end + + def to_ability_name + 'emoji' + end end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index e0a616b5fb4..a2542e669e1 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -116,8 +116,20 @@ class BulkImports::Entity < ApplicationRecord "/#{pluralized_name}/#{encoded_source_full_path}" end + def base_xid_resource_url_path + "/#{pluralized_name}/#{source_xid}" + end + + def base_resource_path + if source_xid.present? + base_xid_resource_url_path + else + base_resource_url_path + end + end + def export_relations_url_path - "#{base_resource_url_path}/export_relations" + "#{base_resource_path}/export_relations" end def relation_download_url_path(relation) @@ -125,7 +137,7 @@ class BulkImports::Entity < ApplicationRecord end def wikis_url_path - "#{base_resource_url_path}/wikis" + "#{base_resource_path}/wikis" end def project? @@ -149,6 +161,13 @@ class BulkImports::Entity < ApplicationRecord end def validate_imported_entity_type + if project_entity? && !BulkImports::Features.project_migration_enabled?(destination_namespace) + errors.add( + :base, + s_('BulkImport|invalid entity source type') + ) + end + if group.present? && project_entity? errors.add( :group, diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb index 4fea62edb2a..cbd7b189007 100644 --- a/app/models/bulk_imports/export_status.rb +++ b/app/models/bulk_imports/export_status.rb @@ -30,14 +30,18 @@ module BulkImports private - attr_reader :client, :entity, :relation + attr_reader :client, :entity, :relation, :pipeline_tracker def export_status strong_memoize(:export_status) do fetch_export_status&.find { |item| item['relation'] == relation } + rescue BulkImports::NetworkError => e + raise BulkImports::RetryPipelineError.new(e.message, 2.seconds) if e.retriable?(pipeline_tracker) + + default_error_response(e.message) + rescue StandardError => e + default_error_response(e.message) end - rescue StandardError => e - { 'status' => Export::FAILED, 'error' => e.message } end def fetch_export_status @@ -47,5 +51,9 @@ module BulkImports def status_endpoint File.join(entity.export_relations_url_path, 'status') end + + def default_error_response(message) + { 'status' => Export::FAILED, 'error' => message } + end end end diff --git a/app/models/bulk_imports/failure.rb b/app/models/bulk_imports/failure.rb index a6f7582c3b0..44d16618c77 100644 --- a/app/models/bulk_imports/failure.rb +++ b/app/models/bulk_imports/failure.rb @@ -10,4 +10,24 @@ class BulkImports::Failure < ApplicationRecord optional: false validates :entity, presence: true + + def relation + pipeline_relation || default_relation + end + + private + + def pipeline_relation + klass = pipeline_class.constantize + + return unless klass.ancestors.include?(BulkImports::Pipeline) + + klass.relation + rescue NameError + nil + end + + def default_relation + pipeline_class.demodulize.chomp('Pipeline').underscore + end end diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index fa38b7617d2..357f4629078 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -60,6 +60,8 @@ class BulkImports::Tracker < ApplicationRecord event :retry do transition started: :enqueued + # To avoid errors when retrying a pipeline in case of network errors + transition enqueued: :enqueued end event :enqueue do diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 4e58f877217..b8511536e32 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -108,10 +108,12 @@ module Ci validates :ref, presence: true scope :not_interruptible, -> do - joins(:metadata).where.not('ci_builds_metadata.id' => Ci::BuildMetadata.scoped_build.with_interruptible.select(:id)) + joins(:metadata) + .where.not(Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) }) end scope :unstarted, -> { where(runner_id: nil) } + scope :with_downloadable_artifacts, -> do where('EXISTS (?)', Ci::JobArtifact.select(1) @@ -120,6 +122,14 @@ module Ci ) end + scope :with_erasable_artifacts, -> do + where('EXISTS (?)', + Ci::JobArtifact.select(1) + .where('ci_builds.id = ci_job_artifacts.job_id') + .where(file_type: Ci::JobArtifact.erasable_file_types) + ) + end + scope :in_pipelines, ->(pipelines) do where(pipeline: pipelines) end @@ -178,7 +188,7 @@ module Ci scope :license_management_jobs, -> { where(name: %i(license_management license_scanning)) } # handle license rename https://gitlab.com/gitlab-org/gitlab/issues/8911 scope :with_secure_reports_from_config_options, -> (job_types) do - joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) + joins(:metadata).where("#{Ci::BuildMetadata.quoted_table_name}.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) end scope :with_coverage, -> { where.not(coverage: nil) } @@ -218,7 +228,7 @@ module Ci yaml_variables when environment coverage_regex description tag_list protected needs_attributes job_variables_attributes resource_group scheduling_type - ci_stage partition_id].freeze + ci_stage partition_id id_tokens].freeze end end @@ -407,18 +417,10 @@ module Ci pipeline.manual_actions.reject { |action| action.name == self.name } end - def environment_manual_actions - pipeline.manual_actions.filter { |action| action.expanded_environment_name == self.expanded_environment_name } - end - def other_scheduled_actions pipeline.scheduled_actions.reject { |action| action.name == self.name } end - def environment_scheduled_actions - pipeline.scheduled_actions.filter { |action| action.expanded_environment_name == self.expanded_environment_name } - end - def pages_generator? Gitlab.config.pages.enabled && self.name == 'pages' @@ -445,8 +447,7 @@ module Ci def prevent_rollback_deployment? strong_memoize(:prevent_rollback_deployment) do - Feature.enabled?(:prevent_outdated_deployment_jobs, project) && - starts_environment? && + starts_environment? && project.ci_forward_deployment_enabled? && deployment&.older_than_last_successful_deployment? end @@ -1195,6 +1196,14 @@ module Ci end def job_jwt_variables + if project.ci_cd_settings.opt_in_jwt? + id_tokens_variables + else + legacy_jwt_variables.concat(id_tokens_variables) + end + end + + def legacy_jwt_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless Feature.enabled?(:ci_job_jwt, project) @@ -1208,6 +1217,20 @@ module Ci end end + def id_tokens_variables + return [] unless id_tokens? + + Gitlab::Ci::Variables::Collection.new.tap do |variables| + id_tokens.each do |var_name, token_data| + token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['id_token']['aud']) + + variables.append(key: var_name, value: token, public: false, masked: true) + end + rescue OpenSSL::PKey::RSAError, Gitlab::Ci::Jwt::NoSigningKeyError => e + Gitlab::ErrorTracking.track_exception(e) + end + end + def cache_for_online_runners(&block) Rails.cache.fetch( ['has-online-runners', id], diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 3bdf2f90acb..33092e881f0 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -6,11 +6,14 @@ module Ci class BuildMetadata < Ci::ApplicationRecord BuildTimeout = Struct.new(:value, :source) + include Ci::Partitionable include Presentable include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize self.table_name = 'ci_builds_metadata' + self.primary_key = 'id' + partitionable scope: :build belongs_to :build, class_name: 'CommitStatus' belongs_to :project @@ -27,7 +30,7 @@ module Ci chronic_duration_attr_reader :timeout_human_readable, :timeout - scope :scoped_build, -> { where('ci_builds_metadata.build_id = ci_builds.id') } + scope :scoped_build, -> { where("#{quoted_table_name}.build_id = #{Ci::Build.quoted_table_name}.id") } scope :with_interruptible, -> { where(interruptible: true) } scope :with_exposed_artifacts, -> { where(has_exposed_artifacts: true) } diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index c2ab8ca0929..3fdf07123e6 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -19,6 +19,11 @@ module Ci validates :target_project, presence: true validate :not_self_referential_link + enum direction: { + outbound: 0, + inbound: 1 + } + def self.for_source_and_target(source_project, target_project) self.find_by(source_project: source_project, target_project: target_project) end diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 26a49d6a730..1aa49b95201 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -23,7 +23,7 @@ module Ci def includes?(target_project) # if the setting is disabled any project is considered to be in scope. - return true unless source_project.ci_job_token_scope_enabled? + return true unless source_project.ci_outbound_job_token_scope_enabled? target_project.id == source_project.id || Ci::JobToken::ProjectScopeLink.from_project(source_project).to_project(target_project).exists? diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 1e328c3c573..950e0a583bc 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -112,6 +112,8 @@ module Ci has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline + has_one :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :pipeline + has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id has_many :latest_builds_report_results, through: :latest_builds, source: :report_results has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :pipeline, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -119,6 +121,7 @@ module Ci accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :full_path, to: :project, prefix: true + delegate :title, to: :pipeline_metadata, allow_nil: true validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } @@ -614,6 +617,15 @@ module Ci # auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation # execute_async - if true cancel the children asyncronously def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true) + Gitlab::AppJsonLogger.info( + event: 'pipeline_cancel_running', + pipeline_id: id, + auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, + cascade_to_children: cascade_to_children, + execute_async: execute_async, + **Gitlab::ApplicationContext.current + ) + update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) @@ -760,8 +772,14 @@ module Ci # There is no ActiveRecord relation between Ci::Pipeline and notes # as they are related to a commit sha. This method helps importing # them using the +Gitlab::ImportExport::Project::RelationFactory+ class. - def notes=(notes) - notes.each do |note| + def notes=(notes_to_save) + notes_to_save.reject! do |note_to_save| + notes.any? do |note| + [note_to_save.note, note_to_save.created_at.to_i] == [note.note, note.created_at.to_i] + end + end + + notes_to_save.each do |note| note[:id] = nil note[:commit_id] = sha note[:noteable_id] = self['id'] @@ -850,7 +868,6 @@ module Ci variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) variables.append(key: 'CI_COMMIT_BRANCH', value: ref) if branch? - variables.append(key: 'CI_COMMIT_TAG', value: ref) if tag? variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) @@ -863,7 +880,8 @@ module Ci variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) - variables.append(key: 'CI_BUILD_TAG', value: ref) if tag? + + variables.concat(predefined_commit_tag_variables) end end end @@ -888,6 +906,20 @@ module Ci end end + def predefined_commit_tag_variables + strong_memoize(:predefined_commit_ref_variables) do + Gitlab::Ci::Variables::Collection.new.tap do |variables| + next variables unless tag? + + variables.append(key: 'CI_COMMIT_TAG', value: ref) + variables.append(key: 'CI_COMMIT_TAG_MESSAGE', value: project.repository.find_tag(ref).message) + + # legacy variable + variables.append(key: 'CI_BUILD_TAG', value: ref) + end + end + end + def queued_duration return unless started_at @@ -972,8 +1004,8 @@ module Ci # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 expanded_environment_names = builds_in_self_and_project_descendants.joins(:metadata) - .where.not('ci_builds_metadata.expanded_environment_name' => nil) - .distinct('ci_builds_metadata.expanded_environment_name') + .where.not(Ci::BuildMetadata.table_name => { expanded_environment_name: nil }) + .distinct("#{Ci::BuildMetadata.quoted_table_name}.expanded_environment_name") .limit(100) .pluck(:expanded_environment_name) @@ -1162,6 +1194,10 @@ module Ci complete? && builds.latest.with_exposed_artifacts.exists? end + def has_erasable_artifacts? + complete? && builds.latest.with_erasable_artifacts.exists? + end + def branch_updated? strong_memoize(:branch_updated) do push_details.branch_updated? @@ -1328,9 +1364,9 @@ module Ci self.builds.latest.build_matchers(project) end - def authorized_cluster_agents - strong_memoize(:authorized_cluster_agents) do - ::Clusters::AgentAuthorizationsFinder.new(project).execute.map(&:agent) + def cluster_agent_authorizations + strong_memoize(:cluster_agent_authorizations) do + ::Clusters::AgentAuthorizationsFinder.new(project).execute end end diff --git a/app/models/ci/pipeline_metadata.rb b/app/models/ci/pipeline_metadata.rb new file mode 100644 index 00000000000..c96b395b45f --- /dev/null +++ b/app/models/ci/pipeline_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ci + class PipelineMetadata < Ci::ApplicationRecord + self.primary_key = :pipeline_id + + belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_metadata + belongs_to :project, class_name: "Project", inverse_of: :pipeline_metadata + + validates :pipeline, presence: true + validates :project, presence: true + validates :title, presence: true, length: { minimum: 1, maximum: 255 } + end +end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 28d9edcc135..3be627989b1 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -14,7 +14,7 @@ module Ci include Presentable include EachBatch - add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced? + add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration enum access_level: { not_protected: 0, @@ -99,27 +99,26 @@ module Ci } scope :belonging_to_group, -> (group_id) { - joins(:runner_namespaces) - .where(ci_runner_namespaces: { namespace_id: group_id }) + joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: group_id }) } scope :belonging_to_group_or_project_descendants, -> (group_id) { group_ids = Ci::NamespaceMirror.by_group_and_descendants(group_id).select(:namespace_id) project_ids = Ci::ProjectMirror.by_namespace_id(group_ids).select(:project_id) - group_runners = joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: group_ids }) - project_runners = joins(:runner_projects).where(ci_runner_projects: { project_id: project_ids }) + group_runners = belonging_to_group(group_ids) + project_runners = belonging_to_project(project_ids).distinct - union_sql = ::Gitlab::SQL::Union.new([group_runners, project_runners]).to_sql - - from("(#{union_sql}) #{table_name}") + from_union( + [group_runners, project_runners], + remove_duplicates: false + ) } scope :belonging_to_group_and_ancestors, -> (group_id) { group_self_and_ancestors_ids = ::Group.find_by(id: group_id)&.self_and_ancestor_ids - joins(:runner_namespaces) - .where(ci_runner_namespaces: { namespace_id: group_self_and_ancestors_ids }) + belonging_to_group(group_self_and_ancestors_ids) } scope :belonging_to_parent_group_of_project, -> (project_id) { @@ -153,6 +152,17 @@ module Ci ) end + scope :usable_from_scope, -> (group) do + from_union( + [ + belonging_to_group(group.ancestor_ids), + belonging_to_group_or_project_descendants(group.id), + group.shared_runners + ], + remove_duplicates: false + ) + end + scope :assignable_for, ->(project) do # FIXME: That `to_sql` is needed to workaround a weird Rails bug. # Without that, placeholders would miss one and couldn't match. @@ -205,7 +215,7 @@ module Ci validates :maintenance_note, length: { maximum: 1024 } - alias_attribute :maintenance_note, :maintainer_note + alias_attribute :maintenance_note, :maintainer_note # NOTE: Need to keep until REST v5 is implemented # Searches for runners matching the given query. # @@ -335,7 +345,7 @@ module Ci end # DEPRECATED - # TODO Remove in %16.0 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 + # TODO Remove in v5 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 def deprecated_rest_status return :stale if stale? @@ -470,10 +480,6 @@ module Ci end end - def self.token_expiration_enforced? - Feature.enabled?(:enforce_runner_token_expires_at) - end - private scope :with_upgrade_status, ->(upgrade_status) do diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb index 9a35f1876c9..ffff7eebbee 100644 --- a/app/models/ci/secure_file.rb +++ b/app/models/ci/secure_file.rb @@ -7,6 +7,7 @@ module Ci FILE_SIZE_LIMIT = 5.megabytes.freeze CHECKSUM_ALGORITHM = 'sha256' + PARSABLE_EXTENSIONS = %w[cer p12 mobileprovision].freeze self.limit_scope = :project self.limit_name = 'project_ci_secure_files' @@ -16,6 +17,7 @@ module Ci validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT } validates :checksum, :file_store, :name, :project_id, presence: true validates :name, uniqueness: { scope: :project } + validates :metadata, json_schema: { filename: "ci_secure_file_metadata" }, allow_nil: true after_initialize :generate_key_data before_validation :assign_checksum @@ -23,6 +25,8 @@ module Ci scope :order_by_created_at, -> { order(created_at: :desc) } scope :project_id_in, ->(ids) { where(project_id: ids) } + serialize :metadata, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize + default_value_for(:file_store) { Ci::SecureFileUploader.default_store } mount_file_store_uploader Ci::SecureFileUploader @@ -31,6 +35,41 @@ module Ci CHECKSUM_ALGORITHM end + def file_extension + File.extname(name).delete_prefix('.') + end + + def metadata_parsable? + PARSABLE_EXTENSIONS.include?(file_extension) + end + + def metadata_parser + return unless metadata_parsable? + + case file_extension + when 'cer' + Gitlab::Ci::SecureFiles::Cer.new(file.read) + when 'p12' + Gitlab::Ci::SecureFiles::P12.new(file.read) + when 'mobileprovision' + Gitlab::Ci::SecureFiles::MobileProvision.new(file.read) + end + end + + def update_metadata! + return unless metadata_parser + + begin + parser = metadata_parser + self.metadata = parser.metadata + self.expires_at = parser.metadata[:expires_at] + save! + rescue StandardError => err + Gitlab::AppLogger.error("Secure File Parser Failure (#{id}): #{err.message} - #{parser.error}.") + nil + end + end + private def assign_checksum diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb index 9f7f653ed65..a365ccdc568 100644 --- a/app/models/clusters/agents/implicit_authorization.rb +++ b/app/models/clusters/agents/implicit_authorization.rb @@ -16,7 +16,7 @@ module Clusters end def config - nil + {} end end end diff --git a/app/models/concerns/approvable.rb b/app/models/concerns/approvable.rb index 1566c53217d..55e138d84fb 100644 --- a/app/models/concerns/approvable.rb +++ b/app/models/concerns/approvable.rb @@ -50,11 +50,11 @@ module Approvable approvals.where(user: user).any? end - def can_be_approved_by?(user) + def eligible_for_approval_by?(user) user && !approved_by?(user) && user.can?(:approve_merge_request, self) end - def can_be_unapproved_by?(user) + def eligible_for_unapproval_by?(user) user && approved_by?(user) && user.can?(:approve_merge_request, self) end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 88f577c3e23..14be924f9da 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -174,6 +174,13 @@ module AtomicInternalId # # bulk_insert(attributes) # end + # + # - track_#{scope}_#{column}! + # This method can be used to set a new greatest IID value during import operations. + # + # Example: + # + # MyClass.track_project_iid!(project, value) def define_singleton_internal_id_methods(scope, column, init) define_singleton_method("with_#{scope}_#{column}_supply") do |scope_value, &block| subject = find_by(scope => scope_value) || self @@ -183,6 +190,16 @@ module AtomicInternalId supply = Supply.new(-> { InternalId.generate_next(subject, scope_attrs, usage, init) }) block.call(supply) end + + define_singleton_method("track_#{scope}_#{column}!") do |scope_value, value| + InternalId.track_greatest( + self, + ::AtomicInternalId.scope_attrs(scope_value), + ::AtomicInternalId.scope_usage(self), + value, + init + ) + end end end diff --git a/app/models/concerns/boards/listable.rb b/app/models/concerns/boards/listable.rb index b9827a79422..b09ef7e612d 100644 --- a/app/models/concerns/boards/listable.rb +++ b/app/models/concerns/boards/listable.rb @@ -13,7 +13,7 @@ module Boards scope :ordered, -> { order(:list_type, :position) } scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } - scope :without_types, ->(list_types) { where.not(list_type: list_types) } + scope :with_types, ->(list_types) { where(list_type: list_types) } class << self def preload_preferences_for_user(lists, user) diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 9ee0fd1db1d..ec0cf36d875 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -237,3 +237,5 @@ module CacheMarkdownField end end end + +CacheMarkdownField.prepend_mod diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 71b26b70bbf..ff884984099 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -80,7 +80,7 @@ module Ci end def id_tokens? - !!metadata&.id_tokens? + metadata&.id_tokens.present? end def id_tokens=(value) diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index 710ee1ba64f..df803180e77 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -19,7 +19,32 @@ module Ci extend ActiveSupport::Concern include ::Gitlab::Utils::StrongMemoize + module Testing + InclusionError = Class.new(StandardError) + + PARTITIONABLE_MODELS = %w[ + CommitStatus + Ci::BuildMetadata + Ci::Stage + Ci::JobArtifact + Ci::PipelineVariable + Ci::Pipeline + ].freeze + + def self.check_inclusion(klass) + return if PARTITIONABLE_MODELS.include?(klass.name) + + raise Partitionable::Testing::InclusionError, + "#{klass} must be included in PARTITIONABLE_MODELS" + + rescue InclusionError => e + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) + end + end + included do + Partitionable::Testing.check_inclusion(self) + before_validation :set_partition_id, on: :create validates :partition_id, presence: true @@ -37,6 +62,8 @@ module Ci def partitionable(scope:) define_method(:partition_scope_value) do strong_memoize(:partition_scope_value) do + next Ci::Pipeline.current_partition_value if respond_to?(:importing?) && importing? + record = scope.to_proc.call(self) record.respond_to?(:partition_id) ? record.partition_id : record end diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 64d178b7507..03e062a9855 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -95,7 +95,7 @@ module CounterAttribute next if increment_value == 0 transaction do - unsafe_update_counters(id, attribute => increment_value) + update_counters_with_lease({ attribute => increment_value }) redis_state { |redis| redis.del(flushed_key) } new_db_value = reset.read_attribute(attribute) end @@ -130,9 +130,18 @@ module CounterAttribute end end - def clear_counter!(attribute) + def update_counters_with_lease(increments) + detect_race_on_record(log_fields: { caller: __method__, attributes: increments.keys }) do + self.class.update_counters(id, increments) + end + end + + def reset_counter!(attribute) if counter_attribute_enabled?(attribute) - redis_state { |redis| redis.del(counter_key(attribute)) } + detect_race_on_record(log_fields: { caller: __method__, attributes: attribute }) do + update!(attribute => 0) + clear_counter!(attribute) + end log_clear_counter(attribute) end @@ -164,14 +173,20 @@ module CounterAttribute private + def database_lock_key + "project:{#{project_id}}:#{self.class}:#{id}" + end + def steal_increments(increment_key, flushed_key) redis_state do |redis| redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key]) end end - def unsafe_update_counters(id, increments) - self.class.update_counters(id, increments) + def clear_counter!(attribute) + redis_state do |redis| + redis.del(counter_key(attribute)) + end end def execute_after_flush_callbacks @@ -192,6 +207,44 @@ module CounterAttribute # a worker is already updating the counters end + # detect_race_on_record uses a lease to monitor access + # to the project statistics row. This is needed to detect + # concurrent attempts to increment columns, which could result in a + # race condition. + # + # As the purpose is to detect and warn concurrent attempts, + # it falls back to direct update on the row if it fails to obtain the lease. + # + # It does not guarantee that there will not be any concurrent updates. + def detect_race_on_record(log_fields: {}) + return yield unless Feature.enabled?(:counter_attribute_db_lease_for_update, project) + + # Ensure attributes is always an array before we log + log_fields[:attributes] = Array(log_fields[:attributes]) + + Gitlab::AppLogger.info( + message: 'Acquiring lease for project statistics update', + project_statistics_id: id, + project_id: project.id, + **log_fields, + **Gitlab::ApplicationContext.current + ) + + in_lock(database_lock_key, retries: 0) do + yield + end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + Gitlab::AppLogger.warn( + message: 'Concurrent project statistics update detected', + project_statistics_id: id, + project_id: project.id, + **log_fields, + **Gitlab::ApplicationContext.current + ) + + yield + end + def log_increment_counter(attribute, increment, new_value) payload = Gitlab::ApplicationContext.current.merge( message: 'Increment counter attribute', diff --git a/app/models/concerns/has_wiki.rb b/app/models/concerns/has_wiki.rb index 89bcabafb84..53016ce62f4 100644 --- a/app/models/concerns/has_wiki.rb +++ b/app/models/concerns/has_wiki.rb @@ -8,7 +8,7 @@ module HasWiki end def create_wiki - wiki.wiki + wiki.create_wiki_repository true rescue Wiki::CouldNotCreateWikiError errors.add(:base, _('Failed to create wiki')) diff --git a/app/models/concerns/integrations/base_data_fields.rb b/app/models/concerns/integrations/base_data_fields.rb index 2870922d90d..4319d63abb9 100644 --- a/app/models/concerns/integrations/base_data_fields.rb +++ b/app/models/concerns/integrations/base_data_fields.rb @@ -5,8 +5,6 @@ module Integrations extend ActiveSupport::Concern included do - # TODO: Once we rename the tables we can't rely on `table_name` anymore. - # https://gitlab.com/gitlab-org/gitlab/-/issues/331953 belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: :integration_id validates :integration, presence: true diff --git a/app/models/concerns/integrations/has_web_hook.rb b/app/models/concerns/integrations/has_web_hook.rb index 5fd71f3d72f..e622faf4a51 100644 --- a/app/models/concerns/integrations/has_web_hook.rb +++ b/app/models/concerns/integrations/has_web_hook.rb @@ -6,7 +6,7 @@ module Integrations included do after_save :update_web_hook!, if: :activated? - has_one :service_hook, inverse_of: :integration, foreign_key: :service_id + has_one :service_hook, inverse_of: :integration, foreign_key: :integration_id end # Return the URL to be used for the webhook. diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b81a9b51e1c..f8389865f91 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -33,6 +33,7 @@ module Issuable DESCRIPTION_LENGTH_MAX = 1.megabyte DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes SEARCHABLE_FIELDS = %w(title description).freeze + MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 200 STATE_ID_MAP = { opened: 1, @@ -95,6 +96,7 @@ module Issuable # to avoid breaking the existing Issuables which may have their descriptions longer validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create validate :description_max_length_for_new_records_is_valid, on: :update + validate :validate_assignee_size_length, unless: :importing? before_validation :truncate_description_on_import! @@ -166,6 +168,11 @@ module Issuable def locking_enabled? false end + + def max_number_of_assignees_or_reviewers_message + # Assignees will be included in https://gitlab.com/gitlab-org/gitlab/-/issues/368936 + format(_("total must be less than or equal to %{size}"), size: MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS) + end end # We want to use optimistic lock for cases when only title or description are involved @@ -227,11 +234,19 @@ module Issuable def truncate_description_on_import! self.description = description&.slice(0, Issuable::DESCRIPTION_LENGTH_MAX) if importing? end + + def validate_assignee_size_length + return true unless Feature.enabled?(:limit_assignees_per_issuable) + return true unless assignees.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + + errors.add :assignees, + -> (_object, _data) { self.class.max_number_of_assignees_or_reviewers_message } + end end class_methods do def participant_includes - [:assignees, :author, { notes: [:author, :award_emoji] }] + [:author, :award_emoji, { notes: [:author, :award_emoji, :system_note_metadata] }] end # Searches for records with a matching title. @@ -383,10 +398,12 @@ module Issuable milestone_table = Milestone.arel_table grouping_columns << milestone_table[:id] grouping_columns << milestone_table[:due_date] - elsif %w(merged_at_desc merged_at_asc).include?(sort) + elsif %w(merged_at_desc merged_at_asc merged_at).include?(sort) + grouping_columns << MergeRequest::Metrics.arel_table[:id] grouping_columns << MergeRequest::Metrics.arel_table[:merged_at] - elsif %w(closed_at_desc closed_at_asc).include?(sort) - grouping_columns << MergeRequest::Metrics.arel_table[:closed_at] + elsif %w(closed_at_desc closed_at_asc closed_at).include?(sort) + grouping_columns << MergeRequest::Metrics.arel_table[:id] + grouping_columns << MergeRequest::Metrics.arel_table[:latest_closed_at] end grouping_columns @@ -431,7 +448,16 @@ module Issuable end def assignee_or_author?(user) - author_id == user.id || assignees.exists?(user.id) + author_id == user.id || assignee?(user) + end + + def assignee?(user) + # Necessary so we can preload the association and avoid N + 1 queries + if assignees.loaded? + assignees.to_a.include?(user) + else + assignees.exists?(user.id) + end end def today? @@ -630,6 +656,14 @@ module Issuable def draftless_title_changed(old_title) old_title != title end + + def read_ability_for(participable_source) + return super if participable_source == self + + name = participable_source.try(:issuable_ability_name) || :read_issuable_participables + + { name: name, subject: self } + end end Issuable.prepend_mod_with('Issuable') diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 8130adf05f1..6035cb87c9b 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -152,7 +152,9 @@ module Participable end def source_visible_to_user?(source, user) - Ability.allowed?(user, "read_#{source.model_name.element}".to_sym, source) + ability = read_ability_for(source) + + Ability.allowed?(user, ability[:name], ability[:subject]) end def filter_by_ability(participants) @@ -172,6 +174,14 @@ module Participable participant.can?(:read_project, project) end end + + # Returns Hash containing ability name and subject needed to read a specific participable. + # Should be overridden if a different ability is required. + def read_ability_for(participable_source) + name = participable_source.try(:to_ability_name) || participable_source.model_name.element + + { name: "read_#{name}".to_sym, subject: participable_source } + end end Participable.prepend_mod_with('Participable') diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 5b759dedb26..262839a3fa6 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -17,6 +17,9 @@ module Routable def self.find_by_full_path(path, follow_redirects: false, route_scope: Route, redirect_route_scope: RedirectRoute) return unless path.present? + # Convert path to string to prevent DB error: function lower(integer) does not exist + path = path.to_s + # Case sensitive match first (it's cheaper and the usual case) # If we didn't have an exact match, we perform a case insensitive search # diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index d53594eb5af..5b74e88429c 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -3,13 +3,10 @@ module Timebox extend ActiveSupport::Concern - include AtomicInternalId include CacheMarkdownField include Gitlab::SQL::Pattern - include IidRoutes include Referable include StripAttribute - include FromUnion TimeboxStruct = Struct.new(:title, :name, :id, :class_name) do # Ensure these models match the interface required for exporting @@ -42,39 +39,19 @@ module Timebox alias_method :timebox_id, :id - validates :group, presence: true, unless: :project - validates :project, presence: true, unless: :group - - validate :timebox_type_check validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } validate :dates_within_4_digits cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description, issuable_reference_expansion_enabled: true - belongs_to :project - belongs_to :group - has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests - scope :of_projects, ->(ids) { where(project_id: ids) } - scope :of_groups, ->(ids) { where(group_id: ids) } scope :closed, -> { with_state(:closed) } - scope :for_projects, -> { where(group: nil).includes(:project) } scope :with_title, -> (title) { where(title: title) } - scope :for_projects_and_groups, -> (projects, groups) do - projects = projects.compact if projects.is_a? Array - projects = [] if projects.nil? - - groups = groups.compact if groups.is_a? Array - groups = [] if groups.nil? - - from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false) - end - # A timebox is within the timeframe (start_date, end_date) if it overlaps # with that timeframe: # @@ -132,10 +109,6 @@ module Timebox end end - def count_by_state - reorder(nil).group(:state).count - end - def predefined_id?(id) [Any.id, None.id, Upcoming.id, Started.id].include?(id) end @@ -145,29 +118,8 @@ module Timebox end end - ## - # Returns the String necessary to reference a Timebox in Markdown. Group - # timeboxes only support name references, and do not support cross-project - # references. - # - # format - Symbol format to use (default: :iid, optional: :name) - # - # Examples: - # - # Milestone.first.to_reference # => "%1" - # Iteration.first.to_reference(format: :name) # => "*iteration:\"goal\"" - # Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1" - # Iteration.first.to_reference(same_namespace_project) # => "gitlab-foss*iteration:1" - # - def to_reference(from = nil, format: :name, full: false) - format_reference = timebox_format_reference(format) - reference = "#{self.class.reference_prefix}#{format_reference}" - - if project - "#{project.to_reference_base(from, full: full)}#{reference}" - else - reference - end + def to_reference + raise NotImplementedError end def reference_link_text(from = nil) @@ -182,20 +134,12 @@ module Timebox model_name.singular end - def group_timebox? - group_id.present? - end - - def project_timebox? - project_id.present? - end - def safe_title title.to_slug.normalize.to_s end def resource_parent - group || project + raise NotImplementedError end def to_ability_name @@ -203,13 +147,7 @@ module Timebox end def merge_requests_enabled? - if group_timebox? - # Assume that groups have at least one project with merge requests enabled. - # Otherwise, we would need to load all of the projects from the database. - true - elsif project_timebox? - project&.merge_requests_enabled? - end + raise NotImplementedError end def weight_available? @@ -218,28 +156,6 @@ module Timebox private - def timebox_format_reference(format = :iid) - raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format) - - if group_timebox? && format == :iid - raise ArgumentError, _('Cannot refer to a group %{timebox_type} by an internal id!') % { timebox_type: timebox_name } - end - - if format == :name && !name.include?('"') - %("#{name}") - else - iid - end - end - - # Timebox should be either a project timebox or a group timebox - def timebox_type_check - if group_id && project_id - field = project_id_changed? ? :project_id : :group_id - errors.add(field, _("%{timebox_name} should belong either to a project or a group.") % { timebox_name: timebox_name }) - end - end - def start_date_should_be_less_than_due_date if due_date <= start_date errors.add(:due_date, _("must be greater than start date")) diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 94ac2405f61..2563fd484f1 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -4,6 +4,7 @@ class DeployKey < Key include FromUnion include IgnorableColumns include PolicyActor + include Presentable has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :deploy_keys_projects diff --git a/app/models/deployment.rb b/app/models/deployment.rb index dafcbc593be..20841bc14cd 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -105,6 +105,7 @@ class Deployment < ApplicationRecord after_transition any => :running do |deployment| next unless deployment.project.ci_forward_deployment_enabled? + next if Feature.enabled?(:prevent_outdated_deployment_jobs, deployment.project) deployment.run_after_commit do Deployments::DropOlderDeploymentsWorker.perform_async(id) @@ -282,27 +283,11 @@ class Deployment < ApplicationRecord end def manual_actions - environment_manual_actions - end - - def other_manual_actions - @other_manual_actions ||= deployable.try(:other_manual_actions) - end - - def environment_manual_actions - @environment_manual_actions ||= deployable.try(:environment_manual_actions) + @manual_actions ||= deployable.try(:other_manual_actions) end def scheduled_actions - environment_scheduled_actions - end - - def environment_scheduled_actions - @environment_scheduled_actions ||= deployable.try(:environment_scheduled_actions) - end - - def other_scheduled_actions - @other_scheduled_actions ||= deployable.try(:other_scheduled_actions) + @scheduled_actions ||= deployable.try(:other_scheduled_actions) end def playable_build diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb index 0877c9dddec..a1defb2594f 100644 --- a/app/models/diff_viewer/server_side.rb +++ b/app/models/diff_viewer/server_side.rb @@ -10,6 +10,9 @@ module DiffViewer end def prepare! + return if Feature.enabled?(:disable_load_entire_blob_for_diff_viewer, diff_file.repository.project) + + # TODO: remove this after resolving #342703 diff_file.old_blob&.load_all_data! diff_file.new_blob&.load_all_data! end diff --git a/app/models/environment.rb b/app/models/environment.rb index 4b98cd02e3b..2d3f342953f 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -71,7 +71,7 @@ class Environment < ApplicationRecord validate :safe_external_url validate :merge_request_not_changed - delegate :manual_actions, :other_manual_actions, to: :last_deployment, allow_nil: true + delegate :manual_actions, to: :last_deployment, allow_nil: true delegate :auto_rollback_enabled?, to: :project scope :available, -> { with_state(:available) } @@ -332,9 +332,9 @@ class Environment < ApplicationRecord end def actions_for(environment) - return [] unless other_manual_actions + return [] unless manual_actions - other_manual_actions.select do |action| + manual_actions.select do |action| action.expanded_environment_name == environment end end @@ -441,11 +441,15 @@ class Environment < ApplicationRecord end def auto_stop_in=(value) - return unless value + if value.nil? + # Handles edge case when auto_stop_at is already set and the new value is nil. + # Possible by setting `auto_stop_in: null` in the CI configuration yml. + self.auto_stop_at = nil - parser = ::Gitlab::Ci::Build::DurationParser.new(value) + return + end - return if parser.seconds_from_now.nil? && auto_stop_at.nil? + parser = ::Gitlab::Ci::Build::DurationParser.new(value) self.auto_stop_at = parser.seconds_from_now rescue ChronicDuration::DurationParseError => ex @@ -540,7 +544,7 @@ class Environment < ApplicationRecord self.class.tiers[:development] when /(test|tst|int|ac(ce|)pt|qa|qc|control|quality)/i self.class.tiers[:testing] - when /(st(a|)g|mod(e|)l|pre|demo)/i + when /(st(a|)g|mod(e|)l|pre|demo|non)/i self.class.tiers[:staging] when /(pr(o|)d|live)/i self.class.tiers[:production] diff --git a/app/models/event.rb b/app/models/event.rb index a20ca0dc423..4c1793d3f13 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -10,9 +10,6 @@ class Event < ApplicationRecord include UsageStatistics include ShaAttribute - # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/358088 - default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope - ACTIONS = HashWithIndifferentAccess.new( created: 1, updated: 2, @@ -281,6 +278,7 @@ class Event < ApplicationRecord "opened" end end + # rubocop: enable Metrics/CyclomaticComplexity # rubocop: enable Metrics/PerceivedComplexity @@ -448,9 +446,9 @@ class Event < ApplicationRecord def design_action_names { - created: _('added'), - updated: _('updated'), - destroyed: _('removed') + created: 'added', + updated: 'updated', + destroyed: 'removed' } end diff --git a/app/models/group.rb b/app/models/group.rb index 1445e71b0bc..38623d91705 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -904,11 +904,7 @@ class Group < Namespace end def packages_policy_subject - if Feature.enabled?(:read_package_policy_rule, self) - ::Packages::Policies::Group.new(self) - else - self - end + ::Packages::Policies::Group.new(self) end def update_two_factor_requirement_for_members diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index 7005c8593bd..15949570f9c 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -8,7 +8,7 @@ class GroupGroupLink < ApplicationRecord validates :shared_group, presence: true validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id], - message: _('The group has already been shared with this group') } + message: N_('The group has already been shared with this group') } validates :shared_with_group, presence: true validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true diff --git a/app/models/group_label.rb b/app/models/group_label.rb index ff14529c6e6..0d2eb524929 100644 --- a/app/models/group_label.rb +++ b/app/models/group_label.rb @@ -2,6 +2,7 @@ class GroupLabel < Label belongs_to :group + belongs_to :parent_container, foreign_key: :group_id, class_name: 'Group' validates :group, presence: true diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index bcbf43ee38b..dcba136d163 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -55,13 +55,6 @@ class ProjectHook < WebHook redis.set(key, time) if !prev || prev < time end end - - private - - override :web_hooks_disable_failed? - def web_hooks_disable_failed? - Feature.enabled?(:web_hooks_disable_failed, project) - end end ProjectHook.prepend_mod_with('ProjectHook') diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 80e167b350b..27119d3a95a 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -4,7 +4,7 @@ class ServiceHook < WebHook include Presentable extend ::Gitlab::Utils::Override - belongs_to :integration, foreign_key: :service_id + belongs_to :integration validates :integration, presence: true def execute(data, hook_name = 'service_hook') diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 84ee23d77ce..71794964c99 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -7,7 +7,7 @@ class WebHook < ApplicationRecord MAX_FAILURES = 100 FAILURE_THRESHOLD = 3 # three strikes - INITIAL_BACKOFF = 10.minutes + INITIAL_BACKOFF = 1.minute MAX_BACKOFF = 1.day BACKOFF_GROWTH_FACTOR = 2.0 @@ -53,18 +53,24 @@ class WebHook < ApplicationRecord where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current) end + def self.web_hooks_disable_failed?(hook) + Feature.enabled?(:web_hooks_disable_failed, hook.parent) + end + def executable? !temporarily_disabled? && !permanently_disabled? end def temporarily_disabled? return false unless web_hooks_disable_failed? + return false if recent_failures <= FAILURE_THRESHOLD disabled_until.present? && disabled_until >= Time.current end def permanently_disabled? return false unless web_hooks_disable_failed? + return false if disabled_until.present? recent_failures > FAILURE_THRESHOLD end @@ -112,17 +118,26 @@ class WebHook < ApplicationRecord save(validate: false) end + # Don't actually back-off until FAILURE_THRESHOLD failures have been seen + # we mark the grace-period using the recent_failures counter def backoff! return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?) - assign_attributes(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES)) + attrs = { recent_failures: recent_failures + 1 } + + if recent_failures >= FAILURE_THRESHOLD + attrs[:backoff_count] = backoff_count.succ.clamp(1, MAX_FAILURES) + attrs[:disabled_until] = next_backoff.from_now + end + + assign_attributes(attrs) save(validate: false) end def failed! return unless recent_failures < MAX_FAILURES - assign_attributes(recent_failures: recent_failures + 1) + assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: recent_failures + 1) save(validate: false) end @@ -186,7 +201,7 @@ class WebHook < ApplicationRecord private def web_hooks_disable_failed? - Feature.enabled?(:web_hooks_disable_failed) + self.class.web_hooks_disable_failed?(self) end def initialize_url_variables diff --git a/app/models/incident_management/timeline_event.rb b/app/models/incident_management/timeline_event.rb index dd0d3c6585d..735d4e4298c 100644 --- a/app/models/incident_management/timeline_event.rb +++ b/app/models/incident_management/timeline_event.rb @@ -18,7 +18,13 @@ module IncidentManagement validates :project, :incident, :occurred_at, presence: true validates :action, presence: true, length: { maximum: 128 } - validates :note, :note_html, presence: true, length: { maximum: 10_000 } + validates :note, presence: true, length: { maximum: 10_000 } + validates :note_html, length: { maximum: 10_000 } + + has_many :timeline_event_tag_links, class_name: 'IncidentManagement::TimelineEventTagLink' + has_many :timeline_event_tags, + class_name: 'IncidentManagement::TimelineEventTag', + through: :timeline_event_tag_links scope :order_occurred_at_asc_id_asc, -> { reorder(occurred_at: :asc, id: :asc) } end diff --git a/app/models/incident_management/timeline_event_tag.rb b/app/models/incident_management/timeline_event_tag.rb new file mode 100644 index 00000000000..cde3afcaa16 --- /dev/null +++ b/app/models/incident_management/timeline_event_tag.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module IncidentManagement + class TimelineEventTag < ApplicationRecord + self.table_name = 'incident_management_timeline_event_tags' + + belongs_to :project, inverse_of: :incident_management_timeline_event_tags + + has_many :timeline_event_tag_links, + class_name: 'IncidentManagement::TimelineEventTagLink' + + has_many :timeline_events, + class_name: 'IncidentManagement::TimelineEvent', + through: :timeline_event_tag_links + + validates :name, presence: true, format: { with: /\A[^,]+\z/ } + validates :name, uniqueness: { scope: :project_id } + validates :name, length: { maximum: 255 } + end +end diff --git a/app/models/incident_management/timeline_event_tag_link.rb b/app/models/incident_management/timeline_event_tag_link.rb new file mode 100644 index 00000000000..912339717a8 --- /dev/null +++ b/app/models/incident_management/timeline_event_tag_link.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module IncidentManagement + class TimelineEventTagLink < ApplicationRecord + self.table_name = 'incident_management_timeline_event_tag_links' + + belongs_to :timeline_event_tag, class_name: 'IncidentManagement::TimelineEventTag' + + belongs_to :timeline_event, class_name: 'IncidentManagement::TimelineEvent' + end +end diff --git a/app/models/integration.rb b/app/models/integration.rb index aecf9529a14..23688a87cbd 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -147,6 +147,8 @@ class Integration < ApplicationRecord fields << ::Integrations::Field.new(name: name, integration_class: self, **attrs) case storage + when :attribute + # noop when :properties prop_accessor(name) when :data_fields @@ -155,7 +157,7 @@ class Integration < ApplicationRecord raise ArgumentError, "Unknown field storage: #{storage}" end - boolean_accessor(name) if attrs[:type] == 'checkbox' + boolean_accessor(name) if attrs[:type] == 'checkbox' && storage != :attribute end # :nocov: diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index c9407aa738e..ab0fdbd777f 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -15,7 +15,77 @@ module Integrations TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags + field :datadog_site, + placeholder: DEFAULT_DOMAIN, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe + } + end + + field :api_url, + exposes_secrets: true, + title: -> { s_('DatadogIntegration|API URL') }, + help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } + + field :api_key, + type: 'password', + title: -> { _('API key') }, + non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, + linkClose: '</a>'.html_safe + } + end, + required: true + + field :archive_trace_events, + storage: :attribute, + type: 'checkbox', + title: -> { s_('Logs') }, + checkbox_label: -> { s_('Enable logs collection') }, + help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } + + field :datadog_service, + title: -> { s_('DatadogIntegration|Service') }, + placeholder: 'gitlab-ci', + help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') } + + field :datadog_env, + title: -> { s_('DatadogIntegration|Environment') }, + placeholder: 'ci', + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + end + + field :datadog_tags, + type: 'textarea', + title: -> { s_('DatadogIntegration|Tags') }, + placeholder: "tag:value\nanother_tag:value", + help: -> do + ERB::Util.html_escape( + s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + end before_validation :strip_properties @@ -68,87 +138,6 @@ module Integrations 'datadog' end - def fields - [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_DOMAIN, - help: ERB::Util.html_escape( - s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe - }, - required: false - }, - { - type: 'text', - name: 'api_url', - title: s_('DatadogIntegration|API URL'), - help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), - required: false - }, - { - type: 'password', - name: 'api_key', - title: _('API key'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: ERB::Util.html_escape( - s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') - ) % { - linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, - linkClose: '</a>'.html_safe - }, - required: true - }, - { - type: 'checkbox', - name: 'archive_trace_events', - title: s_('Logs'), - checkbox_label: s_('Enable logs collection'), - help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), - required: false - }, - { - type: 'text', - name: 'datadog_service', - title: s_('DatadogIntegration|Service'), - placeholder: 'gitlab-ci', - help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') - }, - { - type: 'text', - name: 'datadog_env', - title: s_('DatadogIntegration|Environment'), - placeholder: 'ci', - help: ERB::Util.html_escape( - s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - }, - { - type: 'textarea', - name: 'datadog_tags', - title: s_('DatadogIntegration|Tags'), - placeholder: "tag:value\nanother_tag:value", - help: ERB::Util.html_escape( - s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - } - ] - end - override :hook_url def hook_url url = api_url.presence || sprintf(URL_TEMPLATE, datadog_domain: datadog_domain) diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 58eabcfd378..01a04743d5d 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -3,14 +3,33 @@ require 'uri' module Integrations class Harbor < Integration - prop_accessor :url, :project_name, :username, :password - validates :url, public_url: true, presence: true, addressable_url: { allow_localhost: false, allow_local_network: false }, if: :activated? validates :project_name, presence: true, if: :activated? validates :username, presence: true, if: :activated? validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated? - before_validation :reset_username_and_password + field :url, + title: -> { s_('HarborIntegration|Harbor URL') }, + placeholder: 'https://demo.goharbor.io', + help: -> { s_('HarborIntegration|Base URL of the Harbor instance.') }, + exposes_secrets: true, + required: true + + field :project_name, + title: -> { s_('HarborIntegration|Harbor project name') }, + help: -> { s_('HarborIntegration|The name of the project in Harbor.') } + + field :username, + title: -> { s_('HarborIntegration|Harbor username') }, + required: true + + field :password, + type: 'password', + title: -> { s_('HarborIntegration|Harbor password') }, + help: -> { s_('HarborIntegration|Password for your Harbor username.') }, + non_empty_password_title: -> { s_('HarborIntegration|Enter new Harbor password') }, + non_empty_password_help: -> { s_('HarborIntegration|Leave blank to use your current password.') }, + required: true def title 'Harbor' @@ -21,7 +40,7 @@ module Integrations end def help - s_("HarborIntegration|After the Harbor integration is activated, global variables '$HARBOR_USERNAME', '$HARBOR_HOST', '$HARBOR_OCI', '$HARBOR_PASSWORD', '$HARBOR_URL' and '$HARBOR_PROJECT' will be created for CI/CD use.") + s_("HarborIntegration|After the Harbor integration is activated, global variables `$HARBOR_USERNAME`, `$HARBOR_HOST`, `$HARBOR_OCI`, `$HARBOR_PASSWORD`, `$HARBOR_URL` and `$HARBOR_PROJECT` will be created for CI/CD use.") end def hostname @@ -46,40 +65,6 @@ module Integrations client.ping end - def fields - [ - { - type: 'text', - name: 'url', - title: s_('HarborIntegration|Harbor URL'), - placeholder: 'https://demo.goharbor.io', - help: s_('HarborIntegration|Base URL of the Harbor instance.'), - required: true - }, - { - type: 'text', - name: 'project_name', - title: s_('HarborIntegration|Harbor project name'), - help: s_('HarborIntegration|The name of the project in Harbor.') - }, - { - type: 'text', - name: 'username', - title: s_('HarborIntegration|Harbor username'), - required: true - }, - { - type: 'password', - name: 'password', - title: s_('HarborIntegration|Harbor password'), - help: s_('HarborIntegration|Password for your Harbor username.'), - non_empty_password_title: s_('HarborIntegration|Enter new Harbor password'), - non_empty_password_help: s_('HarborIntegration|Leave blank to use your current password.'), - required: true - } - ] - end - def ci_variables return [] unless activated? @@ -100,15 +85,5 @@ module Integrations def client @client ||= ::Gitlab::Harbor::Client.new(self) end - - def reset_username_and_password - if url_changed? && !password_touched? - self.password = nil - end - - if url_changed? && !username_touched? - self.username = nil - end - end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 754591b8017..ea7acf9a5d1 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -40,10 +40,15 @@ class Issue < ApplicationRecord SORTING_PREFERENCE_FIELD = :issues_sort - # Types of issues that should be displayed on lists across the app - # for example, project issues list, group issues list and issue boards. - # Some issue types, like test cases, should be hidden by default. - TYPES_FOR_LIST = %w(issue incident).freeze + # Types of issues that should be displayed on issue lists across the app + # for example, project issues list, group issues list, and issues dashboard. + # + # This should be kept consistent with the enums used for the GraphQL issue list query in + # https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/assets/javascripts/issues/list/constants.js#L154-158 + TYPES_FOR_LIST = %w(issue incident test_case task).freeze + + # Types of issues that should be displayed on issue board lists + TYPES_FOR_BOARD_LIST = %w(issue incident).freeze belongs_to :project belongs_to :namespace, inverse_of: :issues @@ -107,6 +112,7 @@ class Issue < ApplicationRecord enum issue_type: WorkItems::Type.base_types alias_method :issuing_parent, :project + alias_attribute :issuing_parent_id, :project_id alias_attribute :external_author, :service_desk_reply_to @@ -270,6 +276,10 @@ class Issue < ApplicationRecord end end + def self.participant_includes + [:assignees] + super + end + def next_object_by_relative_position(ignoring: nil, order: :asc) array_mapping_scope = -> (id_expression) do relation = Issue.where(Issue.arel_table[:project_id].eq(id_expression)) diff --git a/app/models/iteration.rb b/app/models/iteration.rb index 71ecbcf1c1a..ed73793c78f 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -2,6 +2,12 @@ # Placeholder class for model that is implemented in EE class Iteration < ApplicationRecord + include IgnorableColumns + + # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/372125 + # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/372126 + ignore_column :project_id, remove_with: '15.6', remove_after: '2022-09-17' + self.table_name = 'sprints' def self.reference_prefix diff --git a/app/models/jira_connect/public_key.rb b/app/models/jira_connect/public_key.rb new file mode 100644 index 00000000000..8959884861b --- /dev/null +++ b/app/models/jira_connect/public_key.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module JiraConnect + class PublicKey + # Public keys are created with JWT tokens via JiraConnect::CreateAsymmetricJwtService + # They need to be available for third party applications to verify the token. + # This should happen right after the application received the token so public keys + # only need to exist for a few minutes. + REDIS_EXPIRY_TIME = 5.minutes.to_i.freeze + + attr_reader :key, :uuid + + def self.create!(key:) + new(key: key, uuid: Gitlab::UUID.v5(SecureRandom.hex)).save! + end + + def self.find(uuid) + Gitlab::Redis::SharedState.with do |redis| + key = redis.get(redis_key(uuid)) + + raise ActiveRecord::RecordNotFound if key.nil? + + new(key: key, uuid: uuid) + end + end + + def initialize(key:, uuid:) + key = OpenSSL::PKey.read(key) unless key.is_a?(OpenSSL::PKey::RSA) + + @key = key.to_s + @uuid = uuid + rescue OpenSSL::PKey::PKeyError + raise ArgumentError, 'Invalid public key' + end + + def save! + Gitlab::Redis::SharedState.with do |redis| + redis.set(self.class.redis_key(uuid), key, ex: REDIS_EXPIRY_TIME) + end + + self + end + + def self.redis_key(uuid) + "JiraConnect:public_key:uuid=#{uuid}" + end + end +end diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index 0a2d3ba0749..23813fa138f 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -21,6 +21,9 @@ class JiraConnectInstallation < ApplicationRecord }) } + scope :direct_installations, -> { joins(:subscriptions) } + scope :proxy_installations, -> { where.not(instance_url: nil) } + def client Atlassian::JiraConnect::Client.new(base_url, shared_secret) end @@ -30,4 +33,20 @@ class JiraConnectInstallation < ApplicationRecord instance_url end + + def audience_url + return unless proxy? + + Gitlab::Utils.append_path(instance_url, '/-/jira_connect') + end + + def audience_installed_event_url + return unless proxy? + + Gitlab::Utils.append_path(instance_url, '/-/jira_connect/events/installed') + end + + def proxy? + instance_url.present? + end end diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb index 76b5f1def6a..97d6cd00fb8 100644 --- a/app/models/jira_import_state.rb +++ b/app/models/jira_import_state.rb @@ -24,7 +24,7 @@ class JiraImportState < ApplicationRecord validates :project, uniqueness: { conditions: -> { where.not(status: STATUSES.values_at(:failed, :finished)) }, - message: _('Cannot have multiple Jira imports running at the same time') + message: N_('Cannot have multiple Jira imports running at the same time') } before_save :ensure_error_message_size diff --git a/app/models/label.rb b/app/models/label.rb index 6608a0573cb..483d51099b1 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -42,6 +42,7 @@ class Label < ApplicationRecord scope :order_name_asc, -> { reorder(title: :asc) } scope :order_name_desc, -> { reorder(title: :desc) } scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) } + scope :with_preloaded_container, -> { preload(parent_container: :route) } scope :top_labels_by_target, -> (target_relation) { label_id_column = arel_table[:id] @@ -59,6 +60,13 @@ class Label < ApplicationRecord .distinct } + scope :for_targets, ->(target_relation) do + joins(:label_links) + .merge(LabelLink.where(target: target_relation)) + .select(arel_table[Arel.star], LabelLink.arel_table[:target_id]) + .with_preloaded_container + end + def self.prioritized(project) joins(:priorities) .where(label_priorities: { project_id: project }) diff --git a/app/models/member.rb b/app/models/member.rb index c5351d5447b..ff1d8f18c25 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -55,7 +55,7 @@ class Member < ApplicationRecord validate :signup_email_valid?, on: :create, if: ->(member) { member.invite_email.present? } validates :user_id, uniqueness: { - message: _('project bots cannot be added to other groups / projects') + message: N_('project bots cannot be added to other groups / projects') }, if: :project_bot? validate :access_level_inclusion @@ -627,7 +627,6 @@ class Member < ApplicationRecord end def blocking_refresh - return true unless Feature.enabled?(:allow_non_blocking_member_refresh) return true if @blocking_refresh.nil? @blocking_refresh diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb index 2e8532fa739..b4e3d6874ef 100644 --- a/app/models/members/member_role.rb +++ b/app/models/members/member_role.rb @@ -4,6 +4,15 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass has_many :members belongs_to :namespace - validates :namespace_id, presence: true + validates :namespace, presence: true validates :base_access_level, presence: true + validate :belongs_to_top_level_namespace + + private + + def belongs_to_top_level_namespace + return if !namespace || namespace.root? + + errors.add(:namespace, s_("must be top-level namespace")) + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a57cb97e936..fb20d91fa20 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -41,8 +41,6 @@ class MergeRequest < ApplicationRecord 'Ci::CompareCodequalityReportsService' => ->(project) { true } }.freeze - MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 100 - belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" @@ -73,6 +71,11 @@ class MergeRequest < ApplicationRecord belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff' manual_inverse_association :latest_merge_request_diff, :merge_request + # method overriden in EE + def suggested_reviewer_users + User.none + end + # This is the same as latest_merge_request_diff unless: # 1. There are arguments - in which case we might be trying to force-reload. # 2. This association is already loaded. @@ -238,6 +241,12 @@ class MergeRequest < ApplicationRecord Gitlab::Timeless.timeless(merge_request, &block) end + after_transition any => [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking, :can_be_merged, :cannot_be_merged] do |merge_request, transition| + if Feature.enabled?(:trigger_mr_subscription_on_merge_status_change, merge_request.project) + GraphqlTriggers.merge_request_merge_status_updated(merge_request) + end + end + # rubocop: disable CodeReuse/ServiceClass after_transition [:unchecked, :checking] => :cannot_be_merged do |merge_request, transition| if merge_request.notify_conflict? @@ -269,7 +278,7 @@ class MergeRequest < ApplicationRecord validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] validate :validate_fork, unless: :closed_or_merged_without_fork? validate :validate_target_project, on: :create - validate :validate_reviewer_and_assignee_size_length, unless: :importing? + validate :validate_reviewer_size_length, unless: :importing? scope :by_source_or_target_branch, ->(branch_name) do where("source_branch = :branch OR target_branch = :branch", branch: branch_name) @@ -438,6 +447,7 @@ class MergeRequest < ApplicationRecord # we'd eventually rename the column for avoiding confusions, but in the mean time # please use `auto_merge_enabled` alias instead of `merge_when_pipeline_succeeds`. alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds + alias_attribute :issuing_parent_id, :target_project_id alias_method :issuing_parent, :target_project delegate :builds_with_coverage, to: :head_pipeline, prefix: true, allow_nil: true @@ -602,7 +612,7 @@ class MergeRequest < ApplicationRecord end def self.participant_includes - [:reviewers, :award_emoji] + super + [:assignees, :reviewers] + super end def committers @@ -988,18 +998,12 @@ class MergeRequest < ApplicationRecord 'Source project is not a fork of the target project' end - def self.max_number_of_assignees_or_reviewers_message - # Assignees will be included in https://gitlab.com/gitlab-org/gitlab/-/issues/368936 - _("total must be less than or equal to %{size}") % { size: MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS } - end - - def validate_reviewer_and_assignee_size_length - # Assigness will be added in a subsequent MR https://gitlab.com/gitlab-org/gitlab/-/issues/368936 + def validate_reviewer_size_length return true unless Feature.enabled?(:limit_reviewer_and_assignee_size) return true unless reviewers.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS errors.add :reviewers, - -> (_object, _data) { MergeRequest.max_number_of_assignees_or_reviewers_message } + -> (_object, _data) { self.class.max_number_of_assignees_or_reviewers_message } end def merge_ongoing? @@ -1989,6 +1993,10 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass end + def can_suggest_reviewers? + false # overridden in EE + end + private attr_accessor :skip_fetch_ref diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index 36902e43a77..04b322ef5a6 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -25,6 +25,10 @@ class MergeRequestDiffFile < ApplicationRecord return '' if fetched_diff.blank? encode_utf8(fetched_diff) if fetched_diff.respond_to?(:encoding) + rescue StandardError => e + log_exception('Failed fetching merge request diff', e) + + '' end def diff @@ -75,15 +79,19 @@ class MergeRequestDiffFile < ApplicationRecord content rescue StandardError => e + log_exception('Cached external diff export failed', e) + + diff + end + + def log_exception(message, exception) log_payload = { - message: 'Cached external diff export failed', + message: message, merge_request_diff_file_id: id, merge_request_diff_id: merge_request_diff&.id } - Gitlab::ExceptionLogFormatter.format!(e, log_payload) + Gitlab::ExceptionLogFormatter.format!(exception, log_payload) Gitlab::AppLogger.warn(log_payload) - - diff end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index ff4fadb0f13..da07d8dd9fc 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true class Milestone < ApplicationRecord + include AtomicInternalId include Sortable include Timebox include Milestoneish include FromUnion include Importable + include IidRoutes prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule @@ -13,6 +15,9 @@ class Milestone < ApplicationRecord ALL = [::Timebox::None, ::Timebox::Any, ::Timebox::Started, ::Timebox::Upcoming].freeze end + belongs_to :project + belongs_to :group + has_many :milestone_releases has_many :releases, through: :milestone_releases @@ -30,13 +35,28 @@ class Milestone < ApplicationRecord .order(:project_id, :group_id, :due_date) end + scope :of_projects, ->(ids) { where(project_id: ids) } + scope :for_projects, -> { where(group: nil).includes(:project) } + scope :for_projects_and_groups, -> (projects, groups) do + projects = projects.compact if projects.is_a? Array + projects = [] if projects.nil? + + groups = groups.compact if groups.is_a? Array + groups = [] if groups.nil? + + from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false) + end + scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } scope :reorder_by_due_date_asc, -> { reorder(arel_table[:due_date].asc.nulls_last) } scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) } scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) } + validates :group, presence: true, unless: :project + validates :project, presence: true, unless: :group validates :title, presence: true validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } + validate :parent_type_check validate :uniqueness_of_title, if: :title_changed? state_machine :state, initial: :active do @@ -176,10 +196,18 @@ class Milestone < ApplicationRecord # TODO: remove after all code paths use `timebox_id` # https://gitlab.com/gitlab-org/gitlab/-/issues/215688 alias_method :milestoneish_id, :timebox_id - # TODO: remove after all code paths use (group|project)_timebox? - # https://gitlab.com/gitlab-org/gitlab/-/issues/215690 - alias_method :group_milestone?, :group_timebox? - alias_method :project_milestone?, :project_timebox? + + def group_milestone? + group_id.present? + end + + def project_milestone? + project_id.present? + end + + def resource_parent + group || project + end def parent if group_milestone? @@ -193,8 +221,63 @@ class Milestone < ApplicationRecord group_milestone? && parent.subgroup? end + def merge_requests_enabled? + if group_milestone? + # Assume that groups have at least one project with merge requests enabled. + # Otherwise, we would need to load all of the projects from the database. + true + elsif project_milestone? + project&.merge_requests_enabled? + end + end + + ## + # Returns the String necessary to reference a milestone in Markdown. Group + # milestones only support name references, and do not support cross-project + # references. + # + # format - Symbol format to use (default: :iid, optional: :name) + # + # Examples: + # + # Milestone.first.to_reference # => "%1" + # Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1" + # + def to_reference(from = nil, format: :name, full: false) + format_reference = timebox_format_reference(format) + reference = "#{self.class.reference_prefix}#{format_reference}" + + if project + "#{project.to_reference_base(from, full: full)}#{reference}" + else + reference + end + end + private + def timebox_format_reference(format = :iid) + raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format) + + if group_milestone? && format == :iid + raise ArgumentError, _('Cannot refer to a group milestone by an internal id!') + end + + if format == :name && !name.include?('"') + %("#{name}") + else + iid + end + end + + # Milestone should be either a project milestone or a group milestone + def parent_type_check + return unless group_id && project_id + + field = project_id_changed? ? :project_id : :group_id + errors.add(field, _("milestone should belong either to a project or a group.") % { timebox_name: timebox_name }) + end + def issues_finder_params { project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact end diff --git a/app/models/ml/candidate_param.rb b/app/models/ml/candidate_param.rb index cbdddcc8a1a..a259e059379 100644 --- a/app/models/ml/candidate_param.rb +++ b/app/models/ml/candidate_param.rb @@ -3,6 +3,7 @@ module Ml class CandidateParam < ApplicationRecord validates :candidate, presence: true + validates :name, uniqueness: { scope: :candidate } validates :name, :value, length: { maximum: 250 }, presence: true belongs_to :candidate, class_name: 'Ml::Candidate' diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index e4e9baac4c8..a32099e8a0c 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -13,10 +13,6 @@ module Ml has_internal_id :iid, scope: :project - def artifact_location - 'not_implemented' - end - class << self def by_project_id_and_iid(project_id, iid) find_by(project_id: project_id, iid: iid) @@ -26,8 +22,8 @@ module Ml find_by(project_id: project_id, name: name) end - def has_record?(project_id, name) - where(project_id: project_id, name: name).exists? + def by_project_id(project_id) + where(project_id: project_id) end end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 0ffd5c446d3..42f362876bb 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -130,6 +130,10 @@ class Namespace < ApplicationRecord to: :namespace_settings, allow_nil: true delegate :show_diff_preview_in_email, :show_diff_preview_in_email?, :show_diff_preview_in_email=, to: :namespace_settings + delegate :maven_package_requests_forwarding, + :pypi_package_requests_forwarding, + :npm_package_requests_forwarding, + to: :package_settings after_save :reload_namespace_details diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb index ed61c807519..cd7d4fc409a 100644 --- a/app/models/namespace/aggregation_schedule.rb +++ b/app/models/namespace/aggregation_schedule.rb @@ -6,13 +6,20 @@ class Namespace::AggregationSchedule < ApplicationRecord self.primary_key = :namespace_id - DEFAULT_LEASE_TIMEOUT = 1.5.hours.to_i REDIS_SHARED_KEY = 'gitlab:update_namespace_statistics_delay' belongs_to :namespace after_create :schedule_root_storage_statistics + def self.default_lease_timeout + if Feature.enabled?(:remove_namespace_aggregator_delay) + 30.minutes.to_i + else + 1.hour.to_i + end + end + def schedule_root_storage_statistics run_after_commit_or_now do try_obtain_lease do @@ -20,7 +27,7 @@ class Namespace::AggregationSchedule < ApplicationRecord .perform_async(namespace_id) Namespaces::RootStatisticsWorker - .perform_in(DEFAULT_LEASE_TIMEOUT, namespace_id) + .perform_in(self.class.default_lease_timeout, namespace_id) end end end @@ -29,7 +36,7 @@ class Namespace::AggregationSchedule < ApplicationRecord # Used by ExclusiveLeaseGuard def lease_timeout - DEFAULT_LEASE_TIMEOUT + self.class.default_lease_timeout end # Used by ExclusiveLeaseGuard diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index dbbf9f4944a..a5643ab9f79 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Namespace::Detail < ApplicationRecord + include IgnorableColumns + + ignore_column :free_user_cap_over_limt_notified_at, remove_with: '15.7', remove_after: '2022-11-22' + belongs_to :namespace, inverse_of: :namespace_details validates :namespace, presence: true validates :description, length: { maximum: 255 } diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index 881b2f3acb3..22c3e41ff21 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true class Namespace::PackageSetting < ApplicationRecord + include CascadingNamespaceSettingAttribute + self.primary_key = :namespace_id self.table_name = 'namespace_package_settings' + cascading_attr :maven_package_requests_forwarding + cascading_attr :npm_package_requests_forwarding + cascading_attr :pypi_package_requests_forwarding + PackageSettingNotImplemented = Class.new(StandardError) PACKAGES_WITH_SETTINGS = %w[maven generic].freeze diff --git a/app/models/note.rb b/app/models/note.rb index daac489757b..e444111119b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -22,6 +22,7 @@ class Note < ApplicationRecord include ThrottledTouch include FromUnion include Sortable + include EachBatch ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze @@ -693,7 +694,7 @@ class Note < ApplicationRecord # Method necesary while we transition into the new format for task system notes # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923 def note - return super unless system? && for_issue? && super.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) + return super unless system? && for_issue? && super&.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) super.sub!('task', 'checklist item') end @@ -701,11 +702,15 @@ class Note < ApplicationRecord # Method necesary while we transition into the new format for task system notes # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923 def note_html - return super unless system? && for_issue? && super.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) + return super unless system? && for_issue? && super&.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) super.sub!('task', 'checklist item') end + def issuable_ability_name + confidential? ? :read_internal_note : :read_note + end + private def system_note_viewable_by?(user) diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index caa24377791..20d5a5ae1a1 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -97,8 +97,6 @@ class NotificationRecipient end def email_blocked? - return false if Feature.disabled?(:block_emails_with_failures) - recipient_email = user.notification_email_for(@group) Gitlab::ApplicationRateLimiter.peek(:permanent_email_failure, scope: recipient_email) || diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index b4c09d99bb0..317db51f4ef 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -114,13 +114,18 @@ class Packages::Package < ApplicationRecord ) end + scope :with_case_insensitive_version, ->(version) do + where('LOWER(version) = ?', version.downcase) + end + scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } scope :with_version, ->(version) { where(version: version) } scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } scope :with_package_type, ->(package_type) { where(package_type: package_type) } scope :without_package_type, ->(package_type) { where.not(package_type: package_type) } scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) } - scope :including_project_route, -> { includes(project: { namespace: :route }) } + scope :including_project_route, -> { includes(project: :route) } + scope :including_project_namespace_route, -> { includes(project: { namespace: :route }) } scope :including_tags, -> { includes(:tags) } scope :including_dependency_links, -> { includes(dependency_links: :dependency) } diff --git a/app/models/packages/rpm/repository_file.rb b/app/models/packages/rpm/repository_file.rb new file mode 100644 index 00000000000..4b5fa59c6ee --- /dev/null +++ b/app/models/packages/rpm/repository_file.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module Packages + module Rpm + class RepositoryFile < ApplicationRecord + include EachBatch + include UpdateProjectStatistics + include FileStoreMounter + include Packages::Installable + + INSTALLABLE_STATUSES = [:default].freeze + + enum status: { default: 0, pending_destruction: 1, processing: 2, error: 3 } + + belongs_to :project, inverse_of: :repository_files + + validates :project, presence: true + validates :file, presence: true + validates :file_name, presence: true + + mount_file_store_uploader Packages::Rpm::RepositoryFileUploader + + update_project_statistics project_statistics_name: :packages_size + end + end +end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index e7d455085c0..c1056d4f6cb 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -32,7 +32,9 @@ module Pages { type: 'zip', - path: deployment.file.url_or_file_path(expire_at: 1.day.from_now), + path: deployment.file.url_or_file_path( + expire_at: ::Gitlab::Pages::CacheControl::DEPLOYMENT_EXPIRATION.from_now + ), global_id: global_id, sha256: deployment.file_sha256, file_size: deployment.size, diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 9ed25c56ed6..f0ed1822da6 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -5,6 +5,8 @@ class PersonalAccessToken < ApplicationRecord include TokenAuthenticatable include Sortable include EachBatch + include CreatedAtFilterable + include Gitlab::SQL::Pattern extend ::Gitlab::Utils::Override add_authentication_token_field :token, digest: true @@ -24,7 +26,6 @@ class PersonalAccessToken < ApplicationRecord scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) } scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") } - scope :created_before, -> (date) { where("personal_access_tokens.created_at < :date", date: date) } scope :last_used_before_or_unused, -> (date) { where("personal_access_tokens.created_at < :date AND (last_used_at < :date OR last_used_at IS NULL)", date: date) } scope :with_impersonation, -> { where(impersonation: true) } scope :without_impersonation, -> { where(impersonation: false) } @@ -38,6 +39,8 @@ class PersonalAccessToken < ApplicationRecord scope :order_expires_at_asc_id_desc, -> { reorder(expires_at: :asc, id: :desc) } scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) } scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) } + scope :last_used_before, -> (date) { where("last_used_at <= ?", date) } + scope :last_used_after, -> (date) { where("last_used_at >= ?", date) } validates :scopes, presence: true validate :validate_scopes @@ -90,6 +93,10 @@ class PersonalAccessToken < ApplicationRecord Gitlab::CurrentSettings.current_application_settings.personal_access_token_prefix end + def self.search(query) + fuzzy_search(query, [:name]) + end + override :format_token def format_token(token) "#{self.class.token_prefix}#{token}" diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb index 722d588d8bc..b6e73c1cd02 100644 --- a/app/models/preloaders/labels_preloader.rb +++ b/app/models/preloaders/labels_preloader.rb @@ -21,8 +21,10 @@ module Preloaders def preload_all preloader = ActiveRecord::Associations::Preloader.new + preloader.preload(labels, parent_container: :route) preloader.preload(labels.select { |l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] }) preloader.preload(labels.select { |l| l.is_a? GroupLabel }, { group: :route }) + labels.each do |label| label.lazy_subscription(user) label.lazy_subscription(user, project) if project.present? diff --git a/app/models/preloaders/project_root_ancestor_preloader.rb b/app/models/preloaders/project_root_ancestor_preloader.rb index 8d04e71774c..1e935249407 100644 --- a/app/models/preloaders/project_root_ancestor_preloader.rb +++ b/app/models/preloaders/project_root_ancestor_preloader.rb @@ -21,7 +21,8 @@ module Preloaders ActiveRecord::Associations::Preloader.new.preload(@projects, :namespace) @projects.each do |project| - project.namespace.root_ancestor = root_ancestors_by_id[project.id]&.first + root_ancestor = root_ancestors_by_id[project.id]&.first + project.namespace.root_ancestor = root_ancestor if root_ancestor.present? end end diff --git a/app/models/project.rb b/app/models/project.rb index c5fad189f87..7b61010ab01 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -32,7 +32,6 @@ class Project < ApplicationRecord include FeatureGate include OptionallySearch include FromUnion - include IgnorableColumns include Repositories::CanHousekeepRepository include EachBatch include GitlabRoutingHelper @@ -49,8 +48,6 @@ class Project < ApplicationRecord BoardLimitExceeded = Class.new(StandardError) ExportLimitExceeded = Class.new(StandardError) - ignore_columns :build_coverage_regex, remove_after: '2022-10-22', remove_with: '15.5' - STATISTICS_ATTRIBUTE = 'repositories_count' UNKNOWN_IMPORT_URL = 'http://unknown.git' # Hashed Storage versions handle rolling out new storage to project and dependents models: @@ -239,6 +236,9 @@ class Project < ApplicationRecord # Packages has_many :packages, class_name: 'Packages::Package' has_many :package_files, through: :packages, class_name: 'Packages::PackageFile' + # repository_files must be destroyed by ruby code in order to properly remove carrierwave uploads + has_many :repository_files, inverse_of: :project, class_name: 'Packages::Rpm::RepositoryFile', + dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::ProjectDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :packages_cleanup_policy, class_name: 'Packages::Cleanup::Policy', inverse_of: :project @@ -262,11 +262,11 @@ class Project < ApplicationRecord has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus' + has_many :incident_management_timeline_event_tags, inverse_of: :project, class_name: 'IncidentManagement::TimelineEventTag' has_many :labels, class_name: 'ProjectLabel' has_many :integrations has_many :events has_many :milestones - has_many :iterations # Projects with a very large number of notes may time out destroying them # through the foreign key. Additionally, the deprecated attachment uploader @@ -353,6 +353,7 @@ class Project < ApplicationRecord has_many :stages, class_name: 'Ci::Stage', inverse_of: :project has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project + has_many :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :project has_many :pending_builds, class_name: 'Ci::PendingBuild' has_many :builds, class_name: 'Ci::Build', inverse_of: :project has_many :processables, class_name: 'Ci::Processable', inverse_of: :project @@ -476,7 +477,8 @@ class Project < ApplicationRecord delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :forward_deployment_enabled, :forward_deployment_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true - delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true + delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci_outbound, allow_nil: true + delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=, to: :ci_cd_settings, prefix: :ci, allow_nil: true @@ -492,12 +494,17 @@ class Project < ApplicationRecord delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage + delegate :maven_package_requests_forwarding, + :pypi_package_requests_forwarding, + :npm_package_requests_forwarding, + to: :namespace + # Validations validates :creator, presence: true, on: :create validates :description, length: { maximum: 2000 }, allow_blank: true validates :ci_config_path, format: { without: %r{(\.{2}|\A/)}, - message: _('cannot include leading slash or directory traversal.') }, + message: N_('cannot include leading slash or directory traversal.') }, length: { maximum: 255 }, allow_blank: true validates :name, @@ -693,13 +700,13 @@ class Project < ApplicationRecord enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } chronic_duration_attr :build_timeout_human_readable, :build_timeout, - default: 3600, error_message: _('Maximum job timeout has a value which could not be accepted') + default: 3600, error_message: N_('Maximum job timeout has a value which could not be accepted') validates :build_timeout, allow_nil: true, numericality: { greater_than_or_equal_to: 10.minutes, less_than: MAX_BUILD_TIMEOUT, only_integer: true, - message: _('needs to be between 10 minutes and 1 month') } + message: N_('needs to be between 10 minutes and 1 month') } # Used by Projects::CleanupService to hold a map of rewritten object IDs mount_uploader :bfg_object_map, AttachmentUploader @@ -1280,6 +1287,8 @@ class Project < ApplicationRecord valid?(:import_url) || errors.messages[:import_url].nil? end + # TODO: rename to build_or_assign_import_data as it doesn't save record + # https://gitlab.com/gitlab-org/gitlab/-/issues/377319 def create_or_update_import_data(data: nil, credentials: nil) return if data.nil? && credentials.nil? @@ -2720,6 +2729,7 @@ class Project < ApplicationRecord ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] end + # DO NOT USE. This method will be deprecated soon def uses_external_project_ci_config? !!(ci_config_path =~ %r{@.+/.+}) end @@ -2844,6 +2854,7 @@ class Project < ApplicationRecord repository.gitlab_ci_yml_for(sha, ci_config_path_or_default) end + # DO NOT USE. This method will be deprecated soon def ci_config_external_project Project.find_by_full_path(ci_config_path.split('@', 2).last) end @@ -2886,12 +2897,18 @@ class Project < ApplicationRecord ci_cd_settings.allow_fork_pipelines_to_run_in_parent_project? end - def ci_job_token_scope_enabled? + def ci_outbound_job_token_scope_enabled? return false unless ci_cd_settings ci_cd_settings.job_token_scope_enabled? end + def ci_inbound_job_token_scope_enabled? + return false unless ci_cd_settings + + ci_cd_settings.inbound_job_token_scope_enabled? + end + def restrict_user_defined_variables? return false unless ci_cd_settings @@ -2939,12 +2956,6 @@ class Project < ApplicationRecord end end - def remove_project_authorizations(user_ids, per_batch = 1000) - user_ids.each_slice(per_batch) do |user_ids_batch| - project_authorizations.where(user_id: user_ids_batch).delete_all - end - end - def enforced_runner_token_expiration_interval all_parent_groups = Gitlab::ObjectHierarchy.new(Group.where(id: group)).base_and_ancestors all_group_settings = NamespaceSetting.where(namespace_id: all_parent_groups) @@ -3023,11 +3034,7 @@ class Project < ApplicationRecord end def packages_policy_subject - if Feature.enabled?(:read_package_policy_rule, group) - ::Packages::Policies::Project.new(self) - else - self - end + ::Packages::Policies::Project.new(self) end def destroy_deployment_by_id(deployment_id) @@ -3040,6 +3047,16 @@ class Project < ApplicationRecord pages_domains.count < Gitlab::CurrentSettings.max_pages_custom_domains_per_project end + # overridden in EE + def can_suggest_reviewers? + false + end + + # overridden in EE + def suggested_reviewers_available? + false + end + private # overridden in EE diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 5c6fdec16ca..8b43e5e5d63 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ProjectAuthorization < ApplicationRecord + BATCH_SIZE = 1000 + SLEEP_DELAY = 0.1 + extend SuppressCompositePrimaryKeyWarning include FromUnion @@ -26,11 +29,45 @@ class ProjectAuthorization < ApplicationRecord super(attributes, unique_by: connection.schema_cache.primary_keys(table_name)) end - def self.insert_all_in_batches(attributes, per_batch = 1000) + def self.insert_all_in_batches(attributes, per_batch = BATCH_SIZE) + add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: per_batch) + attributes.each_slice(per_batch) do |attributes_batch| insert_all(attributes_batch) + perform_delay if add_delay + end + end + + def self.delete_all_in_batches_for_project(project:, user_ids:, per_batch: BATCH_SIZE) + add_delay = add_delay_between_batches?(entire_size: user_ids.size, batch_size: per_batch) + + user_ids.each_slice(per_batch) do |user_ids_batch| + project.project_authorizations.where(user_id: user_ids_batch).delete_all + perform_delay if add_delay + end + end + + def self.delete_all_in_batches_for_user(user:, project_ids:, per_batch: BATCH_SIZE) + add_delay = add_delay_between_batches?(entire_size: project_ids.size, batch_size: per_batch) + + project_ids.each_slice(per_batch) do |project_ids_batch| + user.project_authorizations.where(project_id: project_ids_batch).delete_all + perform_delay if add_delay end end + + private_class_method def self.add_delay_between_batches?(entire_size:, batch_size:) + # The reason for adding a delay is to give the replica database enough time to + # catch up with the primary when large batches of records are being added/removed. + # Hance, we add a delay only if the GitLab installation has a replica database configured. + entire_size > batch_size && + !::Gitlab::Database::LoadBalancing.primary_only? && + Feature.enabled?(:enable_minor_delay_during_project_authorizations_refresh) + end + + private_class_method def self.perform_delay + sleep(SLEEP_DELAY) + end end ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 38740aa20dd..d7a5d0d9d84 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -22,10 +22,6 @@ class ProjectCiCdSetting < ApplicationRecord chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval - def forward_deployment_enabled? - super && ::Feature.enabled?(:forward_deployment_enabled, project) - end - def keep_latest_artifacts_available? # The project level feature can only be enabled when the feature is enabled instance wide Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact? && keep_latest_artifact? diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index 2ba3c74df5b..9f9447c1de2 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -9,7 +9,7 @@ class ProjectGroupLink < ApplicationRecord validates :project_id, presence: true validates :group, presence: true - validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") } + validates :group_id, uniqueness: { scope: [:project_id], message: N_("already shared with this group") } validates :group_access, presence: true validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true validate :different_group diff --git a/app/models/project_label.rb b/app/models/project_label.rb index d0b16cc98b4..dc647901b46 100644 --- a/app/models/project_label.rb +++ b/app/models/project_label.rb @@ -4,6 +4,7 @@ class ProjectLabel < Label MAX_NUMBER_OF_PRIORITIES = 1 belongs_to :project + belongs_to :parent_container, foreign_key: :project_id, class_name: 'Project' validates :project, presence: true diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index f5c346eda30..6d40544fad4 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -21,6 +21,7 @@ class ProjectSetting < ApplicationRecord validates :merge_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH } validates :squash_commit_template, length: { maximum: Project::MAX_COMMIT_TEMPLATE_LENGTH } validates :target_platforms, inclusion: { in: ALLOWED_TARGET_PLATFORMS } + validates :suggested_reviewers_enabled, inclusion: { in: [true, false] } validate :validates_mr_default_target_self diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index a91e0291438..f108e43015e 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -27,6 +27,16 @@ class ProjectStatistics < ApplicationRecord snippets_size: %i[storage_size] }.freeze NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size, :uploads_size, :container_registry_size].freeze + STORAGE_SIZE_COMPONENTS = [ + :repository_size, + :wiki_size, + :lfs_objects_size, + :build_artifacts_size, + :packages_size, + :snippets_size, + :pipeline_artifacts_size, + :uploads_size + ].freeze scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } @@ -39,17 +49,18 @@ class ProjectStatistics < ApplicationRecord def refresh!(only: []) return if Gitlab::Database.read_only? - COLUMNS_TO_REFRESH.each do |column, generator| - if only.empty? || only.include?(column) - public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend - end + columns_to_update = only.empty? ? COLUMNS_TO_REFRESH : COLUMNS_TO_REFRESH & only + columns_to_update.each do |column| + public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend end if only.empty? || only.any? { |column| NAMESPACE_RELATABLE_COLUMNS.include?(column) } schedule_namespace_aggregation_worker end - save! + detect_race_on_record(log_fields: { caller: __method__, attributes: columns_to_update }) do + save! + end end def update_commit_count @@ -97,21 +108,13 @@ class ProjectStatistics < ApplicationRecord end def update_storage_size - storage_size = repository_size + - wiki_size + - lfs_objects_size + - build_artifacts_size + - packages_size + - snippets_size + - pipeline_artifacts_size + - uploads_size - - self.storage_size = storage_size + self.storage_size = storage_size_components.sum { |component| method(component).call } end def refresh_storage_size! - update_storage_size - save! + detect_race_on_record(log_fields: { caller: __method__, attributes: :storage_size }) do + update!(storage_size: storage_size_sum) + end end # Since this incremental update method does not call update_storage_size above through before_save, @@ -129,35 +132,41 @@ class ProjectStatistics < ApplicationRecord if counter_attribute_enabled?(key) project_statistics.delayed_increment_counter(key, amount) else - legacy_increment_statistic(project, key, amount) + project_statistics.legacy_increment_statistic(key, amount) end end end - def self.legacy_increment_statistic(project, key, amount) - where(project_id: project.id).columns_to_increment(key, amount) + def self.incrementable_attribute?(key) + INCREMENTABLE_COLUMNS.key?(key) || counter_attribute_enabled?(key) + end + + def legacy_increment_statistic(key, amount) + increment_columns!(key, amount) Namespaces::ScheduleAggregationWorker.perform_async( # rubocop: disable CodeReuse/Worker project.namespace_id) end - def self.columns_to_increment(key, amount) - updates = ["#{key} = COALESCE(#{key}, 0) + (#{amount})"] - - if (additional = INCREMENTABLE_COLUMNS[key]) - additional.each do |column| - updates << "#{column} = COALESCE(#{column}, 0) + (#{amount})" - end - end + private - update_all(updates.join(', ')) + def storage_size_components + STORAGE_SIZE_COMPONENTS end - def self.incrementable_attribute?(key) - INCREMENTABLE_COLUMNS.key?(key) || counter_attribute_enabled?(key) + def storage_size_sum + storage_size_components.map { |component| "COALESCE (#{component}, 0)" }.join(' + ').freeze end - private + def increment_columns!(key, amount) + increments = { key => amount } + additional = INCREMENTABLE_COLUMNS.fetch(key, []) + additional.each do |column| + increments[column] = amount + end + + update_counters_with_lease(increments) + end def schedule_namespace_aggregation_worker run_after_commit do diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb index e66e1d5b42f..2ffc7478178 100644 --- a/app/models/projects/build_artifacts_size_refresh.rb +++ b/app/models/projects/build_artifacts_size_refresh.rb @@ -80,9 +80,7 @@ module Projects end def reset_project_statistics! - statistics = project.statistics - statistics.update!(build_artifacts_size: 0) - statistics.clear_counter!(:build_artifacts_size) + project.statistics.reset_counter!(:build_artifacts_size) end def next_batch(limit:) diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index b3a918d8952..dfd5c315f6e 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -95,6 +95,10 @@ class ProtectedBranch < ApplicationRecord def self.downcase_humanized_name name.underscore.humanize.downcase end + + def default_branch? + name == project.default_branch + end end ProtectedBranch.prepend_mod_with('ProtectedBranch') diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index de240e40316..df75c557717 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -2,4 +2,6 @@ class ProtectedBranch::MergeAccessLevel < ApplicationRecord include ProtectedBranchAccess + # default value for the access_level column + GITLAB_DEFAULT_ACCESS_LEVEL = Gitlab::Access::MAINTAINER end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 5248834a2f2..6076fab20b7 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -2,6 +2,8 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord include ProtectedBranchAccess + # default value for the access_level column + GITLAB_DEFAULT_ACCESS_LEVEL = Gitlab::Access::MAINTAINER belongs_to :deploy_key diff --git a/app/models/repository.rb b/app/models/repository.rb index ee1bea0e8d2..3413b3e3424 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -48,22 +48,19 @@ class Repository # For example, for entry `:commit_count` there's a method called `commit_count` which # stores its data in the `commit_count` cache key. CACHED_METHODS = %i(size commit_count readme_path contribution_guide - changelog license_blob license_key gitignore + changelog license_blob license_licensee license_gitaly gitignore gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref merged_branch_names has_visible_content? issue_template_names_hash merge_request_template_names_hash user_defined_metrics_dashboard_paths xcode_project? has_ambiguous_refs?).freeze - # Methods that use cache_method but only memoize the value - MEMOIZED_CACHED_METHODS = %i(license).freeze - # Certain method caches should be refreshed when certain types of files are # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to # the corresponding methods to call for refreshing caches. METHOD_CACHES_FOR_FILE_TYPES = { readme: %i(readme_path), changelog: :changelog, - license: %i(license_blob license_key license), + license: %i(license_blob license_licensee license_gitaly), contributing: :contribution_guide, gitignore: :gitignore, gitlab_ci: :gitlab_ci_yml, @@ -650,25 +647,30 @@ class Repository cache_method :license_blob def license_key - return unless exists? - - raw_repository.license_short_name + license&.key end - cache_method :license_key def license - return unless license_key + if Feature.enabled?(:license_from_gitaly) + license_gitaly + else + license_licensee + end + end - licensee_object = Licensee::License.new(license_key) + def license_licensee + return unless exists? - return if licensee_object.name.blank? + raw_repository.license(false) + end + cache_method :license_licensee - licensee_object - rescue Licensee::InvalidLicense => e - Gitlab::ErrorTracking.track_exception(e) - nil + def license_gitaly + return unless exists? + + raw_repository.license(true) end - memoize_method :license + cache_method :license_gitaly def gitignore file_on_head(:gitignore) @@ -787,8 +789,8 @@ class Repository Commit.order_by(collection: commits, order_by: order_by, sort: sort) end - def branch_names_contains(sha) - raw_repository.branch_names_contains_sha(sha) + def branch_names_contains(sha, limit: 0) + raw_repository.branch_names_contains_sha(sha, limit: limit) end def tag_names_contains(sha, limit: 0) diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 0a59d9cef9b..a1753df9294 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -115,7 +115,7 @@ class ResourceLabelEvent < ResourceEvent end def discussion_id_key - [self.class.name, created_at, user_id] + [self.class.name, created_at.to_f, user_id] end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 9b7c37dd23e..9ec685c5580 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -350,7 +350,7 @@ class Snippet < ApplicationRecord end def can_cache_field?(field) - field != :content || MarkupHelper.gitlab_markdown?(file_name) + field != :content || Gitlab::MarkupHelper.gitlab_markdown?(file_name) end def hexdigest diff --git a/app/models/tree.rb b/app/models/tree.rb index 941d0394b94..c6adf5c263c 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Tree - include Gitlab::MarkupHelper include Gitlab::Utils::StrongMemoize attr_accessor :repository, :sha, :path, :entries, :cursor @@ -24,11 +23,11 @@ class Tree end previewable_readmes = available_readmes.select do |blob| - previewable?(blob.name) + Gitlab::MarkupHelper.previewable?(blob.name) end plain_readmes = available_readmes.select do |blob| - plain?(blob.name) + Gitlab::MarkupHelper.plain?(blob.name) end # Prioritize previewable over plain readmes diff --git a/app/models/user.rb b/app/models/user.rb index 3f07e1b1ec0..6d198fc755b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -60,7 +60,7 @@ class User < ApplicationRecord default_value_for :admin, false default_value_for(:external) { Gitlab::CurrentSettings.user_default_external } - default_value_for :can_create_group, gitlab_config.default_can_create_group + default_value_for(:can_create_group) { Gitlab::CurrentSettings.can_create_group } default_value_for :can_create_team, false default_value_for :hide_no_ssh_key, false default_value_for :hide_no_password, false @@ -79,6 +79,7 @@ class User < ApplicationRecord otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base devise :two_factor_backupable, otp_number_of_backup_codes: 10 + devise :two_factor_backupable_pbkdf2 serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize devise :lockable, :recoverable, :rememberable, :trackable, @@ -168,6 +169,10 @@ class User < ApplicationRecord through: :group_members, source: :group alias_attribute :masters_groups, :maintainers_groups + has_many :developer_maintainer_owned_groups, + -> { where(members: { access_level: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, + through: :group_members, + source: :group has_many :reporter_developer_maintainer_owned_groups, -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, through: :group_members, @@ -193,6 +198,10 @@ class User < ApplicationRecord has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :legacy_assigned_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :assignee_id # rubocop:disable Cop/ActiveRecordDependent + has_many :merged_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :merged_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :closed_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :latest_closed_by_id # rubocop:disable Cop/ActiveRecordDependent + has_many :updated_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent has_many :updated_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent has_many :closed_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :closed_by_id # rubocop:disable Cop/ActiveRecordDependent has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent @@ -205,14 +214,15 @@ class User < ApplicationRecord has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :builds, class_name: 'Ci::Build' has_many :pipelines, class_name: 'Ci::Pipeline' - has_many :todos + has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :authored_todos, class_name: 'Todo', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :triggers, class_name: 'Ci::Trigger', foreign_key: :owner_id has_many :issue_assignees, inverse_of: :assignee - has_many :merge_request_assignees, inverse_of: :assignee - has_many :merge_request_reviewers, inverse_of: :reviewer + has_many :merge_request_assignees, inverse_of: :assignee, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :merge_request_reviewers, inverse_of: :reviewer, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request has_many :created_custom_emoji, class_name: 'CustomEmoji', inverse_of: :creator @@ -223,7 +233,6 @@ class User < ApplicationRecord has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' has_many :project_callouts, class_name: 'Users::ProjectCallout' - has_many :namespace_callouts, class_name: 'Users::NamespaceCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -235,6 +244,7 @@ class User < ApplicationRecord has_one :user_highest_role has_one :user_canonical_email has_one :credit_card_validation, class_name: '::Users::CreditCardValidation' + has_one :phone_number_validation, class_name: '::Users::PhoneNumberValidation' has_one :atlassian_identity, class_name: 'Atlassian::Identity' has_one :banned_user, class_name: '::Users::BannedUser' @@ -245,6 +255,8 @@ class User < ApplicationRecord has_many :timelogs has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent # # Validations @@ -274,10 +286,10 @@ class User < ApplicationRecord validate :check_username_format, if: :username_changed? validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, - message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } + message: ->(*) { _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } } validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids, - message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } + message: ->(*) { _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } } validates :website_url, allow_blank: true, url: true, if: :website_url_changed? @@ -289,6 +301,7 @@ class User < ApplicationRecord before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_validation :ensure_namespace_correct before_save :ensure_namespace_correct # in case validation is skipped + before_save :ensure_user_detail_assigned after_validation :set_username_errors after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook @@ -338,8 +351,10 @@ class User < ApplicationRecord :setup_for_company, :setup_for_company=, :render_whitespace_in_code, :render_whitespace_in_code=, :markdown_surround_selection, :markdown_surround_selection=, + :markdown_automatic_lists, :markdown_automatic_lists=, :diffs_deletion_color, :diffs_deletion_color=, :diffs_addition_color, :diffs_addition_color=, + :use_legacy_web_ide, :use_legacy_web_ide=, to: :user_preference delegate :path, to: :namespace, allow_nil: true, prefix: true @@ -934,6 +949,7 @@ class User < ApplicationRecord # that the password is the user's password def valid_password?(password) return false unless password_allowed?(password) + return false if password_automatically_set? return super if Feature.enabled?(:pbkdf2_password_encryption) Devise::Encryptor.compare(self.class, encrypted_password, password) @@ -943,6 +959,22 @@ class User < ApplicationRecord false end + def generate_otp_backup_codes! + if Gitlab::FIPS.enabled? + generate_otp_backup_codes_pbkdf2! + else + super + end + end + + def invalidate_otp_backup_code!(code) + if Gitlab::FIPS.enabled? && pbkdf2? + invalidate_otp_backup_code_pdkdf2!(code) + else + super(code) + end + end + # This method should be removed once the :pbkdf2_password_encryption feature flag is removed. def password=(new_password) if Feature.enabled?(:pbkdf2_password_encryption) && Feature.enabled?(:pbkdf2_password_encryption_write, self) @@ -1129,12 +1161,6 @@ class User < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass - def remove_project_authorizations(project_ids, per_batch = 1000) - project_ids.each_slice(per_batch) do |project_ids_batch| - project_authorizations.where(project_id: project_ids_batch).delete_all - end - end - def authorized_projects(min_access_level = nil) # We're overriding an association, so explicitly call super with no # arguments or it would be passed as `force_reload` to the association @@ -1565,6 +1591,11 @@ class User < ApplicationRecord end end + # Temporary, will be removed when user_detail fields are fully migrated + def ensure_user_detail_assigned + user_detail.assign_changed_fields_from_user if UserDetail.user_fields_changed?(self) + end + def set_username_errors namespace_path_errors = self.errors.delete(:"namespace.path") @@ -1647,8 +1678,9 @@ class User < ApplicationRecord begin followee = Users::UserFollowUser.create(follower_id: self.id, followee_id: user.id) self.followees.reset if followee.persisted? + followee rescue ActiveRecord::RecordNotUnique - false + nil end end @@ -1737,7 +1769,7 @@ class User < ApplicationRecord end def authorized_project_mirrors(level) - projects = Ci::ProjectMirror.by_project_id(ci_project_mirrors_for_project_members(level)) + projects = Ci::ProjectMirror.by_project_id(ci_project_ids_for_project_members(level)) namespace_projects = Ci::ProjectMirror.by_namespace_id(ci_namespace_mirrors_for_group_members(level).select(:namespace_id)) @@ -2075,14 +2107,6 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end - # Deprecated: do not use. See: https://gitlab.com/gitlab-org/gitlab/-/issues/371017 - def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil) - source_feature_name = "#{feature_name}_#{namespace.id}" - callout = namespace_callouts_by_feature_name[source_feature_name] - - callout_dismissed?(callout, ignore_dismissal_earlier_than) - end - def dismissed_callout_for_project?(feature_name:, project:, ignore_dismissal_earlier_than: nil) callout = project_callouts.find_by(feature_name: feature_name, project: project) @@ -2115,11 +2139,6 @@ class User < ApplicationRecord .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) end - def find_or_initialize_namespace_callout(feature_name, namespace_id) - namespace_callouts - .find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id) - end - def find_or_initialize_project_callout(feature_name, project_id) project_callouts .find_or_initialize_by(feature_name: ::Users::ProjectCallout.feature_names[feature_name], project_id: project_id) @@ -2198,6 +2217,12 @@ class User < ApplicationRecord private + def pbkdf2? + return false unless otp_backup_codes&.any? + + otp_backup_codes.first.start_with?("$pbkdf2-sha512$") + end + # To enable JiHu repository to modify the default language options def default_preferred_language 'en' @@ -2209,7 +2234,7 @@ class User < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass - def ci_project_mirrors_for_project_members(level) + def ci_project_ids_for_project_members(level) project_members.where('access_level >= ?', level).pluck(:source_id) end @@ -2246,10 +2271,6 @@ class User < ApplicationRecord @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) end - def namespace_callouts_by_feature_name - @namespace_callouts_by_feature_name ||= namespace_callouts.index_by(&:source_feature_name) - end - def authorized_groups_without_shared_membership Group.from_union( [ @@ -2298,7 +2319,7 @@ class User < ApplicationRecord self.projects_limit = 0 else # Only revert these back to the default if they weren't specifically changed in this update. - self.can_create_group = gitlab_config.default_can_create_group unless can_create_group_changed? + self.can_create_group = Gitlab::CurrentSettings.can_create_group unless can_create_group_changed? self.projects_limit = Gitlab::CurrentSettings.default_projects_limit unless projects_limit_changed? end end @@ -2363,7 +2384,7 @@ class User < ApplicationRecord end def ci_owned_project_runners_from_project_members - project_ids = ci_project_mirrors_for_project_members(Gitlab::Access::MAINTAINER) + project_ids = ci_project_ids_for_project_members(Gitlab::Access::MAINTAINER) Ci::Runner .joins(:runner_projects) diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index b9b69d12729..2e662faea6a 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -2,9 +2,6 @@ class UserDetail < ApplicationRecord extend ::Gitlab::Utils::Override - include IgnorableColumns - - ignore_columns :other_role, remove_after: '2022-07-22', remove_with: '15.3' REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze @@ -15,15 +12,55 @@ class UserDetail < ApplicationRecord validates :job_title, length: { maximum: 200 } validates :bio, length: { maximum: 255 }, allow_blank: true + DEFAULT_FIELD_LENGTH = 500 + + validates :linkedin, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :twitter, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :location, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :organization, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true + + before_validation :sanitize_attrs before_save :prevent_nil_bio enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true + def self.user_fields_changed?(user) + (%w[linkedin skype twitter website_url location organization] & user.changed).any? + end + + def sanitize_attrs + %i[linkedin skype twitter website_url].each do |attr| + value = self[attr] + self[attr] = Sanitize.clean(value) if value.present? + end + %i[location organization].each do |attr| + value = self[attr] + self[attr] = Sanitize.clean(value).gsub('&', '&') if value.present? + end + end + + def assign_changed_fields_from_user + self.linkedin = trim_field(user.linkedin) if user.linkedin_changed? + self.twitter = trim_field(user.twitter) if user.twitter_changed? + self.skype = trim_field(user.skype) if user.skype_changed? + self.website_url = trim_field(user.website_url) if user.website_url_changed? + self.location = trim_field(user.location) if user.location_changed? + self.organization = trim_field(user.organization) if user.organization_changed? + end + private def prevent_nil_bio self.bio = '' if bio_changed? && bio.nil? end + + def trim_field(value) + return '' unless value + + value.first(DEFAULT_FIELD_LENGTH) + end end UserDetail.prepend_mod_with('UserDetail') diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 9b4c0a2527a..c6ebd550daf 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -22,6 +22,7 @@ class UserPreference < ApplicationRecord validates :diffs_deletion_color, :diffs_addition_color, format: { with: ColorsHelper::HEX_COLOR_PATTERN }, allow_blank: true + validates :use_legacy_web_ide, allow_nil: false, inclusion: { in: [true, false] } ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' @@ -29,7 +30,6 @@ class UserPreference < ApplicationRecord default_value_for :time_display_relative, value: true, allows_nil: false default_value_for :time_format_in_24h, value: false, allows_nil: false default_value_for :render_whitespace_in_code, value: false, allows_nil: false - default_value_for :markdown_surround_selection, value: true, allows_nil: false class << self def notes_filters diff --git a/app/models/users/banned_user.rb b/app/models/users/banned_user.rb index c52b6d4b728..615668e2b55 100644 --- a/app/models/users/banned_user.rb +++ b/app/models/users/banned_user.rb @@ -7,6 +7,6 @@ module Users belongs_to :user validates :user, presence: true - validates :user_id, uniqueness: { message: _("banned user already exists") } + validates :user_id, uniqueness: { message: N_("banned user already exists") } end end diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 03841ee48fa..ae6950d800c 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -61,7 +61,8 @@ module Users namespace_storage_limit_banner_alert_threshold: 57, # EE-only namespace_storage_limit_banner_error_threshold: 58, # EE-only project_quality_summary_feedback: 59, # EE-only - merge_request_settings_moved_callout: 60 + merge_request_settings_moved_callout: 60, + new_top_level_group_alert: 61 } validates :feature_name, diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb deleted file mode 100644 index 4e655a96b57..00000000000 --- a/app/models/users/namespace_callout.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Users - class NamespaceCallout < ApplicationRecord - include Users::Calloutable - - self.table_name = 'user_namespace_callouts' - - belongs_to :namespace - - enum feature_name: { - invite_members_banner: 1, - approaching_seat_count_threshold: 2, # EE-only - storage_enforcement_banner_first_enforcement_threshold: 3, # EE-only - storage_enforcement_banner_second_enforcement_threshold: 4, # EE-only - storage_enforcement_banner_third_enforcement_threshold: 5, # EE-only - storage_enforcement_banner_fourth_enforcement_threshold: 6, # EE-only - preview_user_over_limit_free_plan_alert: 7, # EE-only - user_reached_limit_free_plan_alert: 8, # EE-only - web_hook_disabled: 9 - } - - validates :namespace, presence: true - validates :feature_name, - presence: true, - uniqueness: { scope: [:user_id, :namespace_id] }, - inclusion: { in: NamespaceCallout.feature_names.keys } - - def source_feature_name - "#{feature_name}_#{namespace_id}" - end - end -end diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb new file mode 100644 index 00000000000..f6123c01fd0 --- /dev/null +++ b/app/models/users/phone_number_validation.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Users + class PhoneNumberValidation < ApplicationRecord + self.primary_key = :user_id + self.table_name = 'user_phone_number_validations' + + belongs_to :user, foreign_key: :user_id + belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id + + validates :country, + presence: true, + length: { maximum: 3 } + + validates :international_dial_code, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 999 + } + + validates :phone_number, + presence: true, + format: { + with: /\A\d+\Z/, + message: -> (object, data) { _('can contain only digits') } + }, + length: { maximum: 12 } + + validates :telesign_reference_xid, + length: { maximum: 255 } + + def self.related_to_banned_user?(international_dial_code, phone_number) + joins(:banned_user).where( + international_dial_code: international_dial_code, + phone_number: phone_number + ).exists? + end + end +end diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb index 98dacbe394a..c73b3a4ee71 100644 --- a/app/models/users/project_callout.rb +++ b/app/models/users/project_callout.rb @@ -11,7 +11,11 @@ module Users enum feature_name: { awaiting_members_banner: 1, # EE-only web_hook_disabled: 2, - ultimate_feature_removal_banner: 3 + ultimate_feature_removal_banner: 3, + storage_enforcement_banner_first_enforcement_threshold: 4, # EE-only + storage_enforcement_banner_second_enforcement_threshold: 5, # EE-only + storage_enforcement_banner_third_enforcement_threshold: 6, # EE-only + storage_enforcement_banner_fourth_enforcement_threshold: 7 # EE-only } validates :project, presence: true diff --git a/app/models/users/user_follow_user.rb b/app/models/users/user_follow_user.rb index a94239a746c..5a82a81364a 100644 --- a/app/models/users/user_follow_user.rb +++ b/app/models/users/user_follow_user.rb @@ -1,7 +1,22 @@ # frozen_string_literal: true module Users class UserFollowUser < ApplicationRecord + MAX_FOLLOWEE_LIMIT = 300 + belongs_to :follower, class_name: 'User' belongs_to :followee, class_name: 'User' + + validate :max_follow_limit + + private + + def max_follow_limit + followee_count = self.class.where(follower_id: follower_id).limit(MAX_FOLLOWEE_LIMIT).count + return if followee_count < MAX_FOLLOWEE_LIMIT + + errors.add(:base, format( + _("You can't follow more than %{limit} users. To follow more users, unfollow some others."), + limit: MAX_FOLLOWEE_LIMIT)) + end end end diff --git a/app/models/wiki.rb b/app/models/wiki.rb index fac79a8194a..b718c3a096f 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -9,6 +9,8 @@ class Wiki extend ActiveModel::Naming + DuplicatePageError = Class.new(StandardError) + MARKUPS = { # rubocop:disable Style/MultilineIfModifier markdown: { name: 'Markdown', @@ -109,11 +111,34 @@ class Wiki end def sluggified_title(title) - title = Gitlab::EncodingHelper.encode_utf8_no_detect(title) - title = File.expand_path(title, '/') + title = Gitlab::EncodingHelper.encode_utf8_no_detect(title.to_s.strip) + title = File.absolute_path(title, '/') title = Pathname.new(title).relative_path_from('/').to_s title.tr(' ', '-') end + + def canonicalize_filename(filename) + ::File.basename(filename, ::File.extname(filename)).tr('-', ' ') + end + + def cname(name, char_white_sub = '-', char_other_sub = '-') + name.to_s.gsub(/\s/, char_white_sub).gsub(/[<>+]/, char_other_sub) + end + + def preview_slug(title, format) + ext = format == :markdown ? "md" : format.to_s + name = cname(title) + '.' + ext + canonical_name = canonicalize_filename(name) + + path = + if name.include?('/') + name.sub(%r{/[^/]+$}, '/') + else + '' + end + + path + cname(canonical_name, '-', '-') + end end def initialize(container, user = nil) @@ -145,14 +170,6 @@ class Wiki container.path + '.wiki' end - # Returns the Gitlab::Git::Wiki object. - def wiki - strong_memoize(:wiki) do - create_wiki_repository - Gitlab::Git::Wiki.new(repository.raw) - end - end - def create_wiki_repository repository.create_if_not_exists(default_branch) @@ -173,7 +190,7 @@ class Wiki end def empty? - !repository_exists? || list_pages(limit: 1).empty? + !repository_exists? || list_page_paths.empty? end def exists? @@ -190,15 +207,9 @@ class Wiki # # Returns an Array of GitLab WikiPage instances or an # empty Array if this Wiki has no pages. - def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false) - wiki.list_pages( - limit: limit, - sort: sort, - direction_desc: direction == DIRECTION_DESC, - load_content: load_content - ).map do |page| - WikiPage.new(self, page) - end + def list_pages(limit: 0, direction: DIRECTION_ASC, load_content: false) + create_wiki_repository unless repository_exists? + list_pages_with_repository_rpcs(limit: limit, direction: direction, load_content: load_content) end def sidebar_entries(limit: Gitlab::WikiPages::MAX_SIDEBAR_PAGES, **options) @@ -217,19 +228,15 @@ class Wiki # # Returns an initialized WikiPage instance or nil def find_page(title, version = nil, load_content: true) - if find_page_with_repository_rpcs? - create_wiki_repository unless repository_exists? - find_page_with_repository_rpcs(title, version, load_content: load_content) - else - find_page_with_legacy_wiki_service(title, version, load_content: load_content) - end + create_wiki_repository unless repository_exists? + find_page_with_repository_rpcs(title, version, load_content: load_content) end def find_sidebar(version = nil) find_page(SIDEBAR, version) end - def find_file(name, version = 'HEAD', load_content: true) + def find_file(name, version = default_branch, load_content: true) data_limit = load_content ? -1 : 0 blobs = repository.blobs_at([[version, name]], blob_size_limit: data_limit) @@ -256,7 +263,7 @@ class Wiki raise_duplicate_page_error! end end - rescue Gitlab::Git::Wiki::DuplicatePageError => e + rescue DuplicatePageError => e @error_message = _("Duplicate page: %{error_message}" % { error_message: e.message }) false @@ -272,6 +279,7 @@ class Wiki extension = page.format != format.to_sym ? default_extension : File.extname(page.path).downcase[1..] capture_git_error(:updated) do + create_wiki_repository unless repository_exists? repository.update_file( user, sluggified_full_path(title, extension), @@ -290,6 +298,7 @@ class Wiki return unless page capture_git_error(:deleted) do + create_wiki_repository unless repository_exists? repository.delete_file(user, page.path, **multi_commit_options(:deleted, message, page.title)) after_wiki_activity @@ -306,8 +315,10 @@ class Wiki [title, title_array.join("/")] end + # TODO: This method is redundant. Should be replaced by create_wiki_repository def ensure_repository - raise CouldNotCreateWikiError unless wiki.repository_exists? + create_wiki_repository + raise CouldNotCreateWikiError unless repository_exists? end def hook_attrs @@ -343,7 +354,7 @@ class Wiki override :default_branch def default_branch - super || Gitlab::Git::Wiki.default_ref(container) + super || Gitlab::DefaultBranch.value(object: container) end def wiki_base_path @@ -423,11 +434,11 @@ class Wiki escaped_title = Regexp.escape(sluggified_title(title)) regex = Regexp.new("^#{escaped_title}\.#{ALLOWED_EXTENSIONS_REGEX}$", 'i') - repository.ls_files('HEAD').any? { |s| s =~ regex } + repository.ls_files(default_branch).any? { |s| s =~ regex } end def raise_duplicate_page_error! - raise Gitlab::Git::Wiki::DuplicatePageError, _('A page with that title already exists') + raise ::Wiki::DuplicatePageError, _('A page with that title already exists') end def sluggified_full_path(title, extension) @@ -439,27 +450,12 @@ class Wiki end def canonicalize_filename(filename) - Gitlab::Git::Wiki::GollumSlug.canonicalize_filename(filename) - end - - def find_page_with_legacy_wiki_service(title, version, load_content: false) - page_title, page_dir = page_title_and_dir(title) - - if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content) - WikiPage.new(self, page) - end + self.class.canonicalize_filename(filename) end def find_matched_file(title, version) escaped_path = RE2::Regexp.escape(sluggified_title(title)) - # We could not use ALLOWED_EXTENSIONS_REGEX constant or similar regexp with - # Regexp.union. The result combination complicated modifiers: - # /(?i-mx:md|mkdn?|mdown|markdown)|(?i-mx:rdoc).../ - # Regexp used by Gitaly is Go's Regexp package. It does not support those - # features. So, we have to compose another more-friendly regexp to pass to - # Gitaly side. - extension_regexp = Wiki::MARKUPS.map { |_, format| format[:extension_regex].source }.join("|") - path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)^#{escaped_path}\\.(#{extension_regexp})$") + path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)^#{escaped_path}\\.(#{file_extension_regexp})$") matched_files = repository.search_files_by_regexp(path_regexp, version) return if matched_files.blank? @@ -473,11 +469,11 @@ class Wiki end def check_page_historical(path, commit) - repository.last_commit_for_path('HEAD', path).id != commit.id + repository.last_commit_for_path(default_branch, path)&.id != commit&.id end def find_page_with_repository_rpcs(title, version, load_content: true) - version = version.presence || 'HEAD' + version = version.presence || default_branch path = find_matched_file(title, version) return if path.blank? @@ -487,27 +483,81 @@ class Wiki format = find_page_format(path) page = Gitlab::Git::WikiPage.new( - url_path: sluggified_title(path.sub(/\.[^.]+\z/, "")), + url_path: sluggified_title(strip_extension(path)), title: canonicalize_filename(path), format: format, path: sluggified_title(path), raw_data: blob.data, name: canonicalize_filename(path), - historical: version == 'HEAD' ? false : check_page_historical(path, commit), + historical: version == default_branch ? false : check_page_historical(path, commit), version: Gitlab::Git::WikiPageVersion.new(commit, format) ) WikiPage.new(self, page) end - def find_page_with_repository_rpcs? - group = - if container.is_a?(::Group) - container - else - container.group - end + def file_extension_regexp + # We could not use ALLOWED_EXTENSIONS_REGEX constant or similar regexp with + # Regexp.union. The result combination complicated modifiers: + # /(?i-mx:md|mkdn?|mdown|markdown)|(?i-mx:rdoc).../ + # Regexp used by Gitaly is Go's Regexp package. It does not support those + # features. So, we have to compose another more-friendly regexp to pass to + # Gitaly side. + Wiki::MARKUPS.map { |_, format| format[:extension_regex].source }.join("|") + end + + def strip_extension(path) + path.sub(/\.[^.]+\z/, "") + end - Feature.enabled?(:wiki_find_page_with_normal_repository_rpcs, group, type: :development) + def list_page_paths + return [] if repository.empty? + + path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)\\.(#{file_extension_regexp})$") + repository.search_files_by_regexp(path_regexp, default_branch) + end + + def list_pages_with_repository_rpcs(limit:, direction:, load_content:) + paths = list_page_paths + return [] if paths.empty? + + pages = paths.map do |path| + page = Gitlab::Git::WikiPage.new( + url_path: sluggified_title(strip_extension(path)), + title: canonicalize_filename(path), + format: find_page_format(path), + path: sluggified_title(path), + raw_data: '', + name: canonicalize_filename(path), + historical: false + ) + WikiPage.new(self, page) + end + sort_pages!(pages, direction) + pages = pages.take(limit) if limit > 0 + fetch_pages_content!(pages) if load_content + + pages + end + + # After migrating to normal repository RPCs, it's very expensive to sort the + # pages by created_at. We have to either ListLastCommitsForTree RPC call or + # N+1 LastCommitForPath. Either are efficient for a large repository. + # Therefore, we decide to sort the title only. + def sort_pages!(pages, direction) + # Sort by path to ensure the files inside a sub-folder are grouped and sorted together + pages.sort_by!(&:path) + pages.reverse! if direction == DIRECTION_DESC + end + + def fetch_pages_content!(pages) + blobs = + repository + .blobs_at(pages.map { |page| [default_branch, page.path] } ) + .to_h { |blob| [blob.path, blob.data] } + + pages.each do |page| + page.raw_content = blobs[page.path] + end end end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 63c60f5a89e..24b0b94eeb7 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -73,7 +73,7 @@ class WikiPage # The escaped URL path of this page. def slug - attributes[:slug].presence || wiki.wiki.preview_slug(title, format) + attributes[:slug].presence || ::Wiki.preview_slug(title, format) end alias_method :id, :slug # required to use build_stubbed @@ -99,6 +99,13 @@ class WikiPage attributes[:content] ||= page&.text_data end + def raw_content=(content) + return if page.nil? + + page.raw_data = content + attributes[:content] = page.text_data + end + # The hierarchy of the directory this page is contained in. def directory wiki.page_title_and_dir(slug)&.last.to_s @@ -118,7 +125,7 @@ class WikiPage def version return unless persisted? - @version ||= @page.version + @version ||= @page.version || last_version end def path @@ -138,7 +145,7 @@ class WikiPage default_per_page = Kaminari.config.default_per_page offset = [options[:page].to_i - 1, 0].max * options.fetch(:per_page, default_per_page) - wiki.repository.commits('HEAD', + wiki.repository.commits(wiki.default_branch, path: page.path, limit: options.fetch(:limit, default_per_page), offset: offset) @@ -147,11 +154,11 @@ class WikiPage def count_versions return [] unless persisted? - wiki.wiki.count_page_versions(page.path) + wiki.repository.count_commits(ref: wiki.default_branch, path: page.path) end def last_version - @last_version ||= versions(limit: 1).first + @last_version ||= wiki.repository.last_commit_for_path(wiki.default_branch, page.path) if page end def last_commit_sha diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index a52dac446ea..1c23b367489 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -20,8 +20,8 @@ module Ci end with_options scope: :user, score: 5 - condition(:any_developer_groups_inheriting_shared_runners) do - @user.developer_groups.with_shared_runners_enabled.any? + condition(:any_developer_maintainer_owned_groups_inheriting_shared_runners) do + @user.developer_maintainer_owned_groups.with_shared_runners_enabled.any? end with_options scope: :user, score: 5 @@ -31,7 +31,7 @@ module Ci with_options score: 10 condition(:any_associated_projects_in_group_runner_inheriting_group_runners) do - # Check if any projects where user is a developer are inheriting group runners + # Check if any projects where user is a developer+ are inheriting group runners @subject.groups&.any? do |group| group.all_projects .with_group_runners_enabled @@ -48,13 +48,10 @@ module Ci rule { admin | owned_runner }.policy do enable :read_builds - end - - rule { admin | owned_runner }.policy do enable :read_runner end - rule { is_instance_runner & any_developer_groups_inheriting_shared_runners }.policy do + rule { is_instance_runner & any_developer_maintainer_owned_groups_inheriting_shared_runners }.policy do enable :read_runner end diff --git a/app/policies/group_label_policy.rb b/app/policies/group_label_policy.rb index 9f3acd44b23..4a848e44fec 100644 --- a/app/policies/group_label_policy.rb +++ b/app/policies/group_label_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class GroupLabelPolicy < BasePolicy - delegate { @subject.group } + delegate { @subject.parent_container } end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 96da0518dc0..7a0fb10928a 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -35,15 +35,15 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy with_options scope: :subject, score: 0 condition(:request_access_enabled) { @subject.request_access_enabled } - condition(:create_projects_disabled) do + condition(:create_projects_disabled, scope: :subject) do @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS end - condition(:developer_maintainer_access) do + condition(:developer_maintainer_access, scope: :subject) do @subject.project_creation_level == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS end - condition(:maintainer_can_create_group) do + condition(:maintainer_can_create_group, scope: :subject) do @subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS end @@ -51,7 +51,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? } end - condition(:dependency_proxy_available) do + condition(:dependency_proxy_available, scope: :subject) do @subject.dependency_proxy_feature_available? end @@ -59,7 +59,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy access_level(for_any_session: true) >= GroupMember::GUEST || valid_dependency_proxy_deploy_token end - condition(:observability_enabled) do + condition(:observability_enabled, scope: :subject) do Feature.enabled?(:observability_group_tab, @subject) end @@ -80,10 +80,11 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy with_scope :subject condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? } + with_scope :subject condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? } - condition(:group_runner_registration_allowed) do - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') + condition(:group_runner_registration_allowed, scope: :global) do + Gitlab::CurrentSettings.valid_runner_registrars.include?('group') end rule { can?(:read_group) & design_management_enabled }.policy do @@ -149,6 +150,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :admin_crm_organization enable :admin_crm_contact enable :read_cluster + + enable :read_group_all_available_runners end rule { reporter }.policy do @@ -204,6 +207,9 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :destroy_deploy_token enable :update_runners_registration_token enable :owner_access + + enable :read_billing + enable :edit_billing end rule { can?(:read_nested_project_resources) }.policy do diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index e864ce8752a..df065b24e64 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -22,12 +22,6 @@ class IssuablePolicy < BasePolicy enable :reopen_issue end - # This rule replicates permissions in NotePolicy#can_read_confidential and it's used in - # TodoPolicy for performance reasons - rule { can?(:reporter_access) | assignee_or_author | admin }.policy do - enable :read_confidential_notes - end - rule { can?(:read_merge_request) & assignee_or_author }.policy do enable :update_merge_request enable :reopen_merge_request @@ -58,6 +52,12 @@ class IssuablePolicy < BasePolicy rule { can_read_issuable }.policy do enable :read_issuable + enable :read_issuable_participables + end + + # This rule replicates permissions in NotePolicy#can_read_confidential + rule { can?(:reporter_access) | assignee_or_author | admin }.policy do + enable :read_internal_note end end diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 0a0a35d41cc..87db228a698 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -81,6 +81,10 @@ class IssuePolicy < IssuablePolicy rule { can?(:set_issue_metadata) & can_read_crm_contacts }.policy do enable :set_issue_crm_contacts end + + rule { can?(:reporter_access) }.policy do + enable :mark_note_as_confidential + end end IssuePolicy.prepend_mod_with('IssuePolicy') diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb index 028247497e5..89158578ac1 100644 --- a/app/policies/namespaces/user_namespace_policy.rb +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -15,6 +15,8 @@ module Namespaces enable :read_statistics enable :create_jira_connect_subscription enable :admin_package + enable :read_billing + enable :edit_billing end rule { ~can_create_personal_project }.prevent :create_projects diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index 1bffcc5aea2..dbfc63a0069 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -20,7 +20,8 @@ class NotePolicy < BasePolicy condition(:confidential, scope: :subject) { @subject.confidential? } - # If this condition changes IssuablePolicy#read_confidential_notes should be updated too + # Should be matched with IssuablePolicy#read_internal_note + # and EpicPolicy#read_internal_note condition(:can_read_confidential) do access_level >= Gitlab::Access::REPORTER || @subject.noteable_assignee_or_author?(@user) || admin? end diff --git a/app/policies/project_label_policy.rb b/app/policies/project_label_policy.rb index 5ce896ecaf2..6656d5990a5 100644 --- a/app/policies/project_label_policy.rb +++ b/app/policies/project_label_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class ProjectLabelPolicy < BasePolicy - delegate { @subject.project } + delegate { @subject.parent_container } end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index fb162d03955..77bdf9d62fc 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -222,7 +222,7 @@ class ProjectPolicy < BasePolicy end condition(:project_runner_registration_allowed) do - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('project') + Gitlab::CurrentSettings.valid_runner_registrars.include?('project') end condition :registry_enabled do @@ -399,7 +399,7 @@ class ProjectPolicy < BasePolicy prevent(:admin_feature_flags_client) end - rule { split_operations_visibility_permissions & releases_disabled }.policy do + rule { releases_disabled }.policy do prevent(*create_read_update_admin_destroy(:release)) end @@ -574,6 +574,7 @@ class ProjectPolicy < BasePolicy rule { issues_disabled & merge_requests_disabled }.policy do prevent(*create_read_update_admin_destroy(:label)) prevent(*create_read_update_admin_destroy(:milestone)) + prevent(:read_cycle_analytics) end rule { snippets_disabled }.policy do @@ -793,7 +794,7 @@ class ProjectPolicy < BasePolicy rule { project_bot }.enable :project_bot_access - rule { can?(:read_all_resources) }.enable :read_resource_access_tokens + rule { can?(:read_all_resources) & resource_access_token_feature_available }.enable :read_resource_access_tokens rule { can?(:admin_project) & resource_access_token_feature_available }.policy do enable :read_resource_access_tokens diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb index 5c24964f24a..d63eb9407f8 100644 --- a/app/policies/todo_policy.rb +++ b/app/policies/todo_policy.rb @@ -16,7 +16,7 @@ class TodoPolicy < BasePolicy desc "User can read the todo's confidential note" condition(:can_read_todo_confidential_note) do - @user && @user.can?(:read_confidential_notes, @subject.target) + @user && @user.can?(:read_internal_note, @subject.target) end rule { own_todo & can_read_target }.enable :read_todo diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 71a05ef2c72..706608e3029 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -34,7 +34,9 @@ module Ci def runner_variables stop_expanding_file_vars = ::Feature.enabled?(:ci_stop_expanding_file_vars_for_runners, project) - variables.sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars).to_runner_variables + variables + .sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars, project: project) + .to_runner_variables end def refspecs diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 32a7d205f46..fed4ae7837b 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -92,7 +92,7 @@ module Ci if all_related_merge_requests.none? _("No related merge requests found.") else - _("%{count} related %{pluralized_subject}: %{links}" % { + (_("%{count} related %{pluralized_subject}: %{links}") % { count: all_related_merge_requests.count, pluralized_subject: n_('merge request', 'merge requests', all_related_merge_requests.count), links: all_related_merge_request_links(limit: limit).join(', ') diff --git a/app/presenters/deploy_key_presenter.rb b/app/presenters/deploy_key_presenter.rb new file mode 100644 index 00000000000..6f32487b6f6 --- /dev/null +++ b/app/presenters/deploy_key_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DeployKeyPresenter < KeyPresenter # rubocop:disable Gitlab/NamespacedClass + presents ::DeployKey, as: :deploy_key + + def humanized_error_message + super(type: :deploy_key) + end +end diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index 7fa87d33c0d..2f2fb1aa3ba 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -36,6 +36,8 @@ class EventPresenter < Gitlab::View::Presenter::Delegated 'Design' elsif wiki_page? 'Wiki Page' + elsif issue? || work_item? + target.issue_type elsif target_type.present? target_type.titleize else diff --git a/app/presenters/key_presenter.rb b/app/presenters/key_presenter.rb new file mode 100644 index 00000000000..e3eb5feedbf --- /dev/null +++ b/app/presenters/key_presenter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class KeyPresenter < Gitlab::View::Presenter::Delegated # rubocop:disable Gitlab/NamespacedClass + presents ::Key, as: :key_object + + def humanized_error_message(type: :key) + if !key_object.public_key.valid? + help_link = help_page_link(_('supported SSH public key.'), 'user/ssh', 'supported-ssh-key-types') + + _('%{type} must be a %{help_link}').html_safe % { type: type.to_s.titleize, help_link: help_link } + else + key_object.errors.full_messages.join(', ').html_safe + end + end + + private + + def help_page_link(title, path, anchor) + ActionController::Base.helpers.link_to(title, help_page_path(path, anchor: anchor), + target: '_blank', rel: 'noopener noreferrer') + end +end diff --git a/app/serializers/board_serializer.rb b/app/serializers/board_serializer.rb deleted file mode 100644 index 70a4c9ae282..00000000000 --- a/app/serializers/board_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class BoardSerializer < BaseSerializer - entity BoardSimpleEntity -end diff --git a/app/serializers/board_simple_entity.rb b/app/serializers/board_simple_entity.rb deleted file mode 100644 index ab625490966..00000000000 --- a/app/serializers/board_simple_entity.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class BoardSimpleEntity < Grape::Entity - expose :id - expose :name -end - -BoardSimpleEntity.prepend_mod_with('BoardSimpleEntity') diff --git a/app/serializers/current_board_entity.rb b/app/serializers/current_board_entity.rb deleted file mode 100644 index 530f7f5dea3..00000000000 --- a/app/serializers/current_board_entity.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class CurrentBoardEntity < Grape::Entity - expose :id - expose :name - expose :hide_backlog_list - expose :hide_closed_list -end - -CurrentBoardEntity.prepend_mod_with('CurrentBoardEntity') diff --git a/app/serializers/current_board_serializer.rb b/app/serializers/current_board_serializer.rb deleted file mode 100644 index c58c77194f2..00000000000 --- a/app/serializers/current_board_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class CurrentBoardSerializer < BaseSerializer - entity CurrentBoardEntity -end diff --git a/app/serializers/group_access_token_entity.rb b/app/serializers/group_access_token_entity.rb index ab1fbb8ab46..83e8284e4e2 100644 --- a/app/serializers/group_access_token_entity.rb +++ b/app/serializers/group_access_token_entity.rb @@ -11,7 +11,7 @@ class GroupAccessTokenEntity < AccessTokenEntityBase revoke_group_settings_access_token_path( id: token, - group_id: group.path) + group_id: group.full_path) end expose :role do |token, options| diff --git a/app/serializers/import/github_org_entity.rb b/app/serializers/import/github_org_entity.rb new file mode 100644 index 00000000000..a250a9b60f5 --- /dev/null +++ b/app/serializers/import/github_org_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Import + class GithubOrgEntity < Grape::Entity + expose :login, as: :name + expose :description + end +end diff --git a/app/serializers/import/github_org_serializer.rb b/app/serializers/import/github_org_serializer.rb new file mode 100644 index 00000000000..69a598e4b24 --- /dev/null +++ b/app/serializers/import/github_org_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Import + class GithubOrgSerializer < BaseSerializer + entity Import::GithubOrgEntity + end +end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 7ff75927fcd..3d94d2e2e9d 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -43,6 +43,10 @@ class IssueEntity < IssuableEntity can?(request.current_user, :create_note, issue) end + expose :can_create_confidential_note do |issue| + can?(request.current_user, :mark_note_as_confidential, issue) + end + expose :can_update do |issue| can?(request.current_user, :update_issue, issue) end diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb index 29bd26c3a15..07d7d19d1f3 100644 --- a/app/serializers/merge_request_noteable_entity.rb +++ b/app/serializers/merge_request_noteable_entity.rb @@ -51,7 +51,7 @@ class MergeRequestNoteableEntity < IssuableEntity end expose :can_approve do |merge_request| - merge_request.can_be_approved_by?(current_user) + merge_request.eligible_for_approval_by?(current_user) end end diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index 36825d14062..caf2e4c89b6 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -25,6 +25,10 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic # makes one query per merge request, whereas #approved_by? makes one per user options[:merge_request].approvals.any? { |app| app.user_id == user.id } end + + expose :suggested, if: satisfies(:present?) do |user, options| + options[:suggested] + end end MergeRequestUserEntity.prepend_mod_with('MergeRequestUserEntity') diff --git a/app/serializers/project_access_token_entity.rb b/app/serializers/project_access_token_entity.rb index 52bb7b05d4e..548fb24173a 100644 --- a/app/serializers/project_access_token_entity.rb +++ b/app/serializers/project_access_token_entity.rb @@ -11,7 +11,7 @@ class ProjectAccessTokenEntity < AccessTokenEntityBase revoke_namespace_project_settings_access_token_path( id: token, - namespace_id: project.namespace.path, + namespace_id: project.namespace.full_path, project_id: project.path) end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 5e7dab31e8a..5082b84978a 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -8,7 +8,7 @@ class UserSerializer < BaseSerializer merge_request = opts[:project].merge_requests.find_by_iid!(params[:merge_request_iid]) preload_max_member_access(merge_request.project, Array(resource)) - super(resource, opts.merge(merge_request: merge_request), MergeRequestUserEntity) + super(resource, opts.merge(merge_request: merge_request, suggested: params[:suggested]), MergeRequestUserEntity) else super end diff --git a/app/services/admin/set_feature_flag_service.rb b/app/services/admin/set_feature_flag_service.rb new file mode 100644 index 00000000000..d72a18a6a58 --- /dev/null +++ b/app/services/admin/set_feature_flag_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Admin + class SetFeatureFlagService + def initialize(feature_flag_name:, params:) + @name = feature_flag_name + @params = params + end + + def execute + unless params[:force] + error = validate_feature_flag_name + return ServiceResponse.error(message: error, reason: :invalid_feature_flag) if error + end + + flag_target = Feature::Target.new(params) + value = gate_value(params) + + case value + when true + enable!(flag_target) + when false + disable!(flag_target) + else + enable_partially!(value, params) + end + + feature_flag = Feature.get(name) # rubocop:disable Gitlab/AvoidFeatureGet + + ServiceResponse.success(payload: { feature_flag: feature_flag }) + rescue Feature::Target::UnknowTargetError => e + ServiceResponse.error(message: e.message, reason: :actor_not_found) + end + + private + + attr_reader :name, :params + + def enable!(flag_target) + if flag_target.gate_specified? + flag_target.targets.each { |target| Feature.enable(name, target) } + else + Feature.enable(name) + end + end + + def disable!(flag_target) + if flag_target.gate_specified? + flag_target.targets.each { |target| Feature.disable(name, target) } + else + Feature.disable(name) + end + end + + def enable_partially!(value, params) + if params[:key] == 'percentage_of_actors' + Feature.enable_percentage_of_actors(name, value) + else + Feature.enable_percentage_of_time(name, value) + end + end + + def validate_feature_flag_name + # overridden in EE + end + + def gate_value(params) + case params[:value] + when 'true' + true + when '0', 'false' + false + else + # https://github.com/jnunemaker/flipper/blob/master/lib/flipper/typecast.rb#L47 + if params[:value].to_s.include?('.') + params[:value].to_f + else + params[:value].to_i + end + end + end + end +end + +Admin::SetFeatureFlagService.prepend_mod diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb index 34c2003bd01..28e312a6fa3 100644 --- a/app/services/alert_management/create_alert_issue_service.rb +++ b/app/services/alert_management/create_alert_issue_service.rb @@ -21,7 +21,7 @@ module AlertManagement result = create_incident return result unless result.success? - issue = result.payload[:issue] + issue = result[:issue] perform_after_create_tasks(issue) result diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb index e0b8158417c..8d60fffd959 100644 --- a/app/services/authorized_project_update/project_recalculate_service.rb +++ b/app/services/authorized_project_update/project_recalculate_service.rb @@ -64,7 +64,12 @@ module AuthorizedProjectUpdate end def refresh_authorizations - project.remove_project_authorizations(user_ids_to_remove) if user_ids_to_remove.any? + if user_ids_to_remove.any? + ProjectAuthorization.delete_all_in_batches_for_project( + project: project, + user_ids: user_ids_to_remove) + end + ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any? end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 465025ef2e9..fcaa74555ca 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -50,7 +50,7 @@ module Boards end def set_issue_types - params[:issue_types] ||= Issue::TYPES_FOR_LIST + params[:issue_types] ||= Issue::TYPES_FOR_BOARD_LIST end def item_model diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb deleted file mode 100644 index d74320e92a3..00000000000 --- a/app/services/boards/lists/generate_service.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Boards - module Lists - class GenerateService < Boards::BaseService - def execute(board) - return false unless board.lists.movable.empty? - - List.transaction do - label_params.each do |params| - response = create_list(board, params) - - raise ActiveRecord::Rollback unless response.success? - end - end - - true - end - - private - - def create_list(board, params) - label = find_or_create_label(params) - Lists::CreateService.new(parent, current_user, label_id: label.id).execute(board) - end - - def find_or_create_label(params) - ::Labels::FindOrCreateService.new(current_user, parent, params).execute - end - - def label_params - [ - { name: 'To Do', color: '#F0AD4E' }, - { name: 'Doing', color: '#5CB85C' } - ] - end - end - end -end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index e81ef467a4e..cf15db4314c 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -9,23 +9,27 @@ module Boards end lists = board.lists.preload_associated_models + lists = lists.with_types(available_list_types_for(board)) return lists.id_in(params[:list_id]) if params[:list_id].present? - list_types = unavailable_list_types_for(board) - lists.without_types(list_types) + lists end private - def unavailable_list_types_for(board) - hidden_lists_for(board) + def available_list_types_for(board) + licensed_list_types(board) + visible_lists(board) end - def hidden_lists_for(board) - [].tap do |hidden| - hidden << ::List.list_types[:backlog] if board.hide_backlog_list? - hidden << ::List.list_types[:closed] if board.hide_closed_list? + def licensed_list_types(board) + [List.list_types[:label]] + end + + def visible_lists(board) + [].tap do |visible| + visible << ::List.list_types[:backlog] unless board.hide_backlog_list? + visible << ::List.list_types[:closed] unless board.hide_closed_list? end end end diff --git a/app/services/bulk_imports/create_pipeline_trackers_service.rb b/app/services/bulk_imports/create_pipeline_trackers_service.rb index af97aec09b5..f5b944e6df5 100644 --- a/app/services/bulk_imports/create_pipeline_trackers_service.rb +++ b/app/services/bulk_imports/create_pipeline_trackers_service.rb @@ -53,11 +53,13 @@ module BulkImports def log_skipped_pipeline(pipeline, minimum_version, maximum_version) logger.info( message: 'Pipeline skipped as source instance version not compatible with pipeline', - entity_id: entity.id, + bulk_import_entity_id: entity.id, + bulk_import_id: entity.bulk_import_id, pipeline_name: pipeline[:pipeline], minimum_source_version: minimum_version, maximum_source_version: maximum_version, - source_version: source_version.to_s + source_version: source_version.to_s, + importer: 'gitlab_migration' ) end diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index 31e1a822e78..d3c6dcca588 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -38,6 +38,8 @@ module BulkImports def execute bulk_import = create_bulk_import + Gitlab::Tracking.event(self.class.name, 'create', label: 'bulk_import_group') + BulkImportWorker.perform_async(bulk_import.id) ServiceResponse.success(payload: bulk_import) diff --git a/app/services/bulk_imports/repository_bundle_export_service.rb b/app/services/bulk_imports/repository_bundle_export_service.rb index 31a2ed6d1af..86159f5189d 100644 --- a/app/services/bulk_imports/repository_bundle_export_service.rb +++ b/app/services/bulk_imports/repository_bundle_export_service.rb @@ -9,13 +9,19 @@ module BulkImports end def execute - repository.bundle_to_disk(bundle_filepath) if repository.exists? + return unless repository_exists? + + repository.bundle_to_disk(bundle_filepath) end private attr_reader :repository, :export_path, :export_filename + def repository_exists? + repository.exists? && !repository.empty? + end + def bundle_filepath File.join(export_path, "#{export_filename}.bundle") end diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb index 7f5ee7b8624..315590bea31 100644 --- a/app/services/bulk_imports/uploads_export_service.rb +++ b/app/services/bulk_imports/uploads_export_service.rb @@ -22,8 +22,9 @@ module BulkImports subdir_path = export_subdir_path(upload) mkdir_p(subdir_path) download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename)) - rescue Errno::ENAMETOOLONG => e - # Do not fail entire export process if downloaded file has filename that exceeds 255 characters. + rescue StandardError => e + # Do not fail entire project export if something goes wrong during file download + # (e.g. downloaded file has filename that exceeds 255 characters). # Ignore raised exception, skip such upload, log the error and keep going with the export instead. Gitlab::ErrorTracking.log_exception(e, portable_id: portable.id, portable_class: portable.class.name, upload_id: upload.id) end diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb index 634c547a623..9d54207d75d 100644 --- a/app/services/ci/after_requeue_job_service.rb +++ b/app/services/ci/after_requeue_job_service.rb @@ -26,7 +26,7 @@ module Ci return legacy_dependent_jobs unless ::Feature.enabled?(:ci_requeue_with_dag_object_hierarchy, project) ordered_by_dag( - ::Ci::Processable + @processable.pipeline.processables .from_union(needs_dependent_jobs, stage_dependent_jobs) .skipped .ordered_by_stage diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index af175b8da1c..0b49beffcb5 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -26,6 +26,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::AssignPartition, Gitlab::Ci::Pipeline::Chain::Seed, Gitlab::Ci::Pipeline::Chain::Limit::Size, + Gitlab::Ci::Pipeline::Chain::Limit::ActiveJobs, Gitlab::Ci::Pipeline::Chain::Limit::Deployments, Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, @@ -36,7 +37,6 @@ module Ci Gitlab::Ci::Pipeline::Chain::CreateDeployments, Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations, Gitlab::Ci::Pipeline::Chain::Limit::Activity, - Gitlab::Ci::Pipeline::Chain::Limit::JobActivity, Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines, Gitlab::Ci::Pipeline::Chain::Metrics, Gitlab::Ci::Pipeline::Chain::TemplateUsage, @@ -140,7 +140,7 @@ module Ci end def create_namespace_onboarding_action - Namespaces::OnboardingPipelineCreatedWorker.perform_async(project.namespace_id) + Onboarding::PipelineCreatedWorker.perform_async(project.namespace_id) end def extra_options(content: nil, dry_run: false) diff --git a/app/services/ci/generate_kubeconfig_service.rb b/app/services/ci/generate_kubeconfig_service.rb index 894ab8e8505..347bc99dbf5 100644 --- a/app/services/ci/generate_kubeconfig_service.rb +++ b/app/services/ci/generate_kubeconfig_service.rb @@ -14,7 +14,8 @@ module Ci url: Gitlab::Kas.tunnel_url ) - agents.each do |agent| + agent_authorizations.each do |authorization| + agent = authorization.agent user = user_name(agent) template.add_user( @@ -24,6 +25,7 @@ module Ci template.add_context( name: context_name(agent), + namespace: context_namespace(authorization), cluster: cluster_name, user: user ) @@ -36,8 +38,8 @@ module Ci attr_reader :pipeline, :token, :template - def agents - pipeline.authorized_cluster_agents + def agent_authorizations + pipeline.cluster_agent_authorizations end def cluster_name @@ -52,6 +54,10 @@ module Ci [agent.project.full_path, agent.name].join(delimiter) end + def context_namespace(authorization) + authorization.config['default_namespace'] + end + def agent_token(agent) ['ci', agent.id, token].join(delimiter) end diff --git a/app/services/ci/job_artifacts/delete_service.rb b/app/services/ci/job_artifacts/delete_service.rb index 65cae03312e..c9d590eccc4 100644 --- a/app/services/ci/job_artifacts/delete_service.rb +++ b/app/services/ci/job_artifacts/delete_service.rb @@ -15,13 +15,23 @@ module Ci method: 'Ci::JobArtifacts::DeleteService#execute', project_id: build.project_id ) + return ServiceResponse.error( + message: 'Action temporarily disabled. The project this job belongs to is undergoing stats refresh.', + reason: :project_stats_refresh + ) end - # fix_expire_at is false because in this case we want to explicitly delete the job artifacts - # this flag is a workaround that will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/355833 - Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts.erasable, fix_expire_at: false).execute + result = Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts.erasable).execute - ServiceResponse.success + if result.fetch(:status) == :success + ServiceResponse.success(payload: + { + destroyed_artifacts_count: result.fetch(:destroyed_artifacts_count), + statistics_updates: result.fetch(:statistics_updates) + }) + else + ServiceResponse.error(message: result.fetch(:message)) + end end private diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb index fd13ed245cf..14e8dc41cf5 100644 --- a/app/services/ci/parse_dotenv_artifact_service.rb +++ b/app/services/ci/parse_dotenv_artifact_service.rb @@ -40,7 +40,7 @@ module Ci key, value = scan_line!(line) variables[key] = Ci::JobVariable.new(job_id: artifact.job_id, - source: :dotenv, key: key, value: value) + source: :dotenv, key: key, value: value, raw: false) end end diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb index 99877603554..9c6fdb7a405 100644 --- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb +++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb @@ -27,18 +27,13 @@ module Ci end def pipeline_artifact_params - attributes = { + { pipeline: pipeline, file_type: :code_coverage, file: carrierwave_file, - size: carrierwave_file['tempfile'].size + size: carrierwave_file['tempfile'].size, + locked: pipeline.locked } - - if ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, pipeline.project) - attributes[:locked] = pipeline.locked - end - - attributes end def carrierwave_file diff --git a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb index aeb68a75f88..a0746ef32b2 100644 --- a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb +++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb @@ -23,20 +23,15 @@ module Ci def artifact_attributes file = build_carrierwave_file! - attributes = { + { project_id: pipeline.project_id, file_type: :code_quality_mr_diff, file_format: Ci::PipelineArtifact::REPORT_TYPES.fetch(:code_quality_mr_diff), size: file["tempfile"].size, file: file, - expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now + expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now, + locked: pipeline.locked } - - if ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, pipeline.project) - attributes[:locked] = pipeline.locked - end - - attributes end def merge_requests diff --git a/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb index 17c039885e5..8dddf3c3f6c 100644 --- a/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb +++ b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb @@ -3,20 +3,26 @@ module Ci module PipelineArtifacts class DestroyAllExpiredService + include ::Gitlab::ExclusiveLeaseHelpers include ::Gitlab::LoopHelpers include ::Gitlab::Utils::StrongMemoize BATCH_SIZE = 100 - LOOP_TIMEOUT = 5.minutes LOOP_LIMIT = 1000 + LOOP_TIMEOUT = 5.minutes + LOCK_TIMEOUT = 10.minutes + EXCLUSIVE_LOCK_KEY = 'expired_pipeline_artifacts:destroy:lock' def initialize @removed_artifacts_count = 0 + @start_at = Time.current end def execute - loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do - destroy_artifacts_batch + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + destroy_unlocked_pipeline_artifacts + + legacy_destroy_pipeline_artifacts end @removed_artifacts_count @@ -24,10 +30,30 @@ module Ci private + def destroy_unlocked_pipeline_artifacts + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + artifacts = Ci::PipelineArtifact.expired_before(@start_at).artifact_unlocked.limit(BATCH_SIZE) + + break if artifacts.empty? + + destroy_batch(artifacts) + end + end + + def legacy_destroy_pipeline_artifacts + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + destroy_artifacts_batch + end + end + def destroy_artifacts_batch artifacts = ::Ci::PipelineArtifact.unlocked.expired.limit(BATCH_SIZE).to_a return false if artifacts.empty? + destroy_batch(artifacts) + end + + def destroy_batch(artifacts) artifacts.each(&:destroy!) increment_stats(artifacts.size) diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index e6ec65fcc91..22cd267806d 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -25,6 +25,8 @@ module Ci end def enqueue(build) + return build.drop!(:failed_outdated_deployment_job) if build.prevent_rollback_deployment? + build.enqueue end diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb index ae9b8bc8a16..abd32610cec 100644 --- a/app/services/ci/runners/register_runner_service.rb +++ b/app/services/ci/runners/register_runner_service.rb @@ -59,7 +59,7 @@ module Ci end def runner_registrar_valid?(type) - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type) + Gitlab::CurrentSettings.valid_runner_registrars.include?(type) end def token_scope diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb index 1fee31da4fc..574cdae6480 100644 --- a/app/services/ci/unlock_artifacts_service.rb +++ b/app/services/ci/unlock_artifacts_service.rb @@ -11,8 +11,6 @@ module Ci unlocked_pipeline_artifacts: 0 } - unlock_pipeline_artifacts_enabled = ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, ci_ref.project) - if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, ci_ref.project) loop do unlocked_pipelines = [] @@ -22,9 +20,7 @@ module Ci unlocked_pipelines = unlock_pipelines(ci_ref, before_pipeline) unlocked_job_artifacts = unlock_job_artifacts(unlocked_pipelines) - if unlock_pipeline_artifacts_enabled - results[:unlocked_pipeline_artifacts] += unlock_pipeline_artifacts(unlocked_pipelines) - end + results[:unlocked_pipeline_artifacts] += unlock_pipeline_artifacts(unlocked_pipelines) end break if unlocked_pipelines.empty? diff --git a/app/services/clusters/applications/destroy_service.rb b/app/services/clusters/applications/destroy_service.rb deleted file mode 100644 index d666682487b..00000000000 --- a/app/services/clusters/applications/destroy_service.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class DestroyService < ::Clusters::Applications::BaseService - def execute(_request) - instantiate_application.tap do |application| - break unless application.can_uninstall? - - application.make_scheduled! - - Clusters::Applications::UninstallWorker.perform_async(application.name, application.id) - end - end - - private - - def builder - cluster.public_send(application_class.association_name) # rubocop:disable GitlabSecurity/PublicSend - end - end - end -end diff --git a/app/services/clusters/applications/uninstall_service.rb b/app/services/clusters/applications/uninstall_service.rb deleted file mode 100644 index 50c8d806c14..00000000000 --- a/app/services/clusters/applications/uninstall_service.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class UninstallService < BaseHelmService - def execute - return unless app.scheduled? - - app.make_uninstalling! - uninstall - end - - private - - def uninstall - helm_api.uninstall(app.uninstall_command) - - Clusters::Applications::WaitForUninstallAppWorker.perform_in( - Clusters::Applications::WaitForUninstallAppWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => e - log_error(e) - app.make_errored!("Kubernetes error: #{e.error_code}") - rescue StandardError => e - log_error(e) - app.make_errored!('Failed to uninstall.') - end - end - end -end diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index c1c93aa604e..281b2508090 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -32,6 +32,8 @@ module Users end def groups + return [] unless current_user + current_user.authorized_groups.with_route.sort_by(&:path) end diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb index beb614c7b76..24ade9336b2 100644 --- a/app/services/concerns/work_items/widgetable_service.rb +++ b/app/services/concerns/work_items/widgetable_service.rb @@ -2,18 +2,22 @@ module WorkItems module WidgetableService - def execute_widgets(work_item:, callback:, widget_params: {}) + def execute_widgets(work_item:, callback:, widget_params: {}, service_params: {}) work_item.widgets.each do |widget| - widget_service(widget).try(callback, params: widget_params[widget.class.api_symbol]) + widget_service(widget, service_params).try(callback, params: widget_params[widget.class.api_symbol]) end end # rubocop:disable Gitlab/ModuleWithInstanceVariables - def widget_service(widget) + def widget_service(widget, service_params) @widget_services ||= {} return @widget_services[widget] if @widget_services.has_key?(widget) - @widget_services[widget] = widget_service_class(widget)&.new(widget: widget, current_user: current_user) + @widget_services[widget] = widget_service_class(widget)&.new( + widget: widget, + current_user: current_user, + service_params: service_params + ) end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/services/google_cloud/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb index e4a411d0fab..911cccca5ca 100644 --- a/app/services/google_cloud/enable_cloudsql_service.rb +++ b/app/services/google_cloud/enable_cloudsql_service.rb @@ -3,7 +3,7 @@ module GoogleCloud class EnableCloudsqlService < ::GoogleCloud::BaseService def execute - return no_projects_error if unique_gcp_project_ids.empty? + create_or_replace_project_vars(environment_name, 'GCP_PROJECT_ID', gcp_project_id, ci_var_protected?) unique_gcp_project_ids.each do |gcp_project_id| google_api_client.enable_cloud_sql_admin(gcp_project_id) @@ -18,8 +18,8 @@ module GoogleCloud private - def no_projects_error - error("No GCP projects found. Configure a service account or GCP_PROJECT_ID CI variable.") + def ci_var_protected? + ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name) end end end diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index db52a272bf2..4092ded67bc 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -26,6 +26,8 @@ module Groups end def execute + Gitlab::Tracking.event(self.class.name, 'create', label: 'import_group_from_file') + if valid_user_permissions? && import_file && restorers.all?(&:restore) notify_success diff --git a/app/services/import/github/cancel_project_import_service.rb b/app/services/import/github/cancel_project_import_service.rb new file mode 100644 index 00000000000..5dce5e73662 --- /dev/null +++ b/app/services/import/github/cancel_project_import_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Import + module Github + class CancelProjectImportService < ::BaseService + def execute + return error('Not Found', :not_found) unless authorized_to_read? + return error('Unauthorized access', :forbidden) unless authorized_to_cancel? + + if project.import_in_progress? + project.import_state.cancel + success(project: project) + else + error(cannot_cancel_error_message, :bad_request) + end + end + + private + + def authorized_to_read? + can?(current_user, :read_project, project) + end + + def authorized_to_cancel? + can?(current_user, :owner_access, project) + end + + def cannot_cancel_error_message + format( + _('The import cannot be canceled because it is %{project_status}'), + project_status: project.import_state.status + ) + end + end + end +end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 53297d2412c..a60963e28c7 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -9,21 +9,13 @@ module Import attr_reader :params, :current_user def execute(access_params, provider) - if blocked_url? - return log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request) - end - - unless authorized? - return error(_('This namespace has already been taken! Please choose another one.'), :unprocessable_entity) - end - - if oversized? - return error(oversize_error_message, :unprocessable_entity) - end + context_error = validate_context + return context_error if context_error project = create_project(access_params, provider) if project.persisted? + store_import_settings(project) success(project) elsif project.errors[:import_source_disabled].present? error(project.errors[:import_source_disabled], :forbidden) @@ -108,6 +100,16 @@ module Import private + def validate_context + if blocked_url? + log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request) + elsif !authorized? + error(_('This namespace has already been taken. Choose a different one.'), :unprocessable_entity) + elsif oversized? + error(oversize_error_message, :unprocessable_entity) + end + end + def log_error(exception) Gitlab::GithubImport::Logger.error( message: 'Import failed due to a GitHub error', @@ -126,6 +128,10 @@ module Import error(translated_message, http_status) end + + def store_import_settings(project) + Gitlab::GithubImport::Settings.new(project).write(params[:optional_stages]) + end end end diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb index ef66325fdcc..f44842650b7 100644 --- a/app/services/incident_management/incidents/create_service.rb +++ b/app/services/incident_management/incidents/create_service.rb @@ -15,7 +15,7 @@ module IncidentManagement end def execute - issue = Issues::CreateService.new( + create_result = Issues::CreateService.new( project: project, current_user: current_user, params: { @@ -29,22 +29,16 @@ module IncidentManagement ).execute if alert - return error(alert.errors.full_messages.to_sentence, issue) unless alert.valid? + return error(alert.errors.full_messages, create_result[:issue]) unless alert.valid? end - return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid? - - success(issue) + create_result end private attr_reader :title, :description, :severity, :alert - def success(issue) - ServiceResponse.success(payload: { issue: issue }) - end - def error(message, issue = nil) ServiceResponse.error(payload: { issue: issue }, message: message) end diff --git a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb index 58777848151..d495ec5cab6 100644 --- a/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb +++ b/app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb @@ -11,7 +11,7 @@ module IncidentManagement @issuable = issuable @param_errors = [] - super(project: issuable.project, current_user: current_user, params: Hash(params)) + super(project: issuable.project, current_user: current_user, params: params) end def execute diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb index 40ce9097c88..5422b4ad6d2 100644 --- a/app/services/incident_management/timeline_events/create_service.rb +++ b/app/services/incident_management/timeline_events/create_service.rb @@ -109,7 +109,6 @@ module IncidentManagement def add_system_note(timeline_event) return if auto_created - return unless Feature.enabled?(:incident_timeline, project) SystemNoteService.add_timeline_event(timeline_event) end diff --git a/app/services/incident_management/timeline_events/destroy_service.rb b/app/services/incident_management/timeline_events/destroy_service.rb index 90e95ae8869..e1c6bbbdb85 100644 --- a/app/services/incident_management/timeline_events/destroy_service.rb +++ b/app/services/incident_management/timeline_events/destroy_service.rb @@ -30,8 +30,6 @@ module IncidentManagement attr_reader :project, :timeline_event, :user, :incident def add_system_note(incident, user) - return unless Feature.enabled?(:incident_timeline, project) - SystemNoteService.delete_timeline_event(incident, user) end end diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb index 5c5de4717bc..012e2f0e260 100644 --- a/app/services/incident_management/timeline_events/update_service.rb +++ b/app/services/incident_management/timeline_events/update_service.rb @@ -38,8 +38,6 @@ module IncidentManagement end def add_system_note(timeline_event) - return unless Feature.enabled?(:incident_timeline, incident.project) - changes = was_changed(timeline_event) return if changes == :none diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 822e3cd787c..e84d1032e41 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -23,7 +23,7 @@ module Issuable with_csv_lines.each do |row, line_no| attributes = issuable_attributes_for(row) - if create_issuable(attributes).persisted? + if create_issuable(attributes)&.persisted? @results[:success] += 1 else @results[:error_lines].push(line_no) diff --git a/app/services/issuable/process_assignees.rb b/app/services/issuable/process_assignees.rb index 1ef6d3d9c42..72f727c134d 100644 --- a/app/services/issuable/process_assignees.rb +++ b/app/services/issuable/process_assignees.rb @@ -6,11 +6,11 @@ module Issuable class ProcessAssignees def initialize(assignee_ids:, add_assignee_ids:, remove_assignee_ids:, existing_assignee_ids: nil, extra_assignee_ids: nil) - @assignee_ids = assignee_ids - @add_assignee_ids = add_assignee_ids - @remove_assignee_ids = remove_assignee_ids - @existing_assignee_ids = existing_assignee_ids || [] - @extra_assignee_ids = extra_assignee_ids || [] + @assignee_ids = assignee_ids&.map(&:to_i) + @add_assignee_ids = add_assignee_ids&.map(&:to_i) + @remove_assignee_ids = remove_assignee_ids&.map(&:to_i) + @existing_assignee_ids = existing_assignee_ids&.map(&:to_i) || [] + @extra_assignee_ids = extra_assignee_ids&.map(&:to_i) || [] end def execute diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 70ad97f8436..e24ae8f59f0 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -67,22 +67,14 @@ class IssuableBaseService < ::BaseProjectService end def filter_assignees(issuable) - filter_assignees_with_key(issuable, :assignee_ids, :assignees) - filter_assignees_with_key(issuable, :add_assignee_ids, :add_assignees) - filter_assignees_with_key(issuable, :remove_assignee_ids, :remove_assignees) + filter_assignees_using_checks(issuable, :assignee_ids) + filter_assignees_using_checks(issuable, :add_assignee_ids) + filter_assignees_using_checks(issuable, :remove_assignee_ids) end - def filter_assignees_with_key(issuable, id_key, key) - if params[key] && params[id_key].blank? - params[id_key] = params[key].map(&:id) - end - + def filter_assignees_using_checks(issuable, id_key) return if params[id_key].blank? - filter_assignees_using_checks(issuable, id_key) - end - - def filter_assignees_using_checks(issuable, id_key) unless issuable.allows_multiple_assignees? params[id_key] = params[id_key].first(1) end @@ -154,10 +146,13 @@ class IssuableBaseService < ::BaseProjectService end def filter_escalation_status(issuable) + status_params = params.delete(:escalation_status) || {} + status_params.permit! if status_params.respond_to?(:permit!) + result = ::IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService.new( issuable, current_user, - params.delete(:escalation_status) + status_params ).execute return unless result.success? && result[:escalation_status].present? @@ -266,11 +261,23 @@ class IssuableBaseService < ::BaseProjectService # To be overridden by subclasses end - def after_update(issuable) + def prepare_update_params(issuable) # To be overridden by subclasses end + def after_update(issuable, old_associations) + handle_description_updated(issuable) + handle_label_changes(issuable, old_associations[:labels]) + end + + def handle_description_updated(issuable) + return unless issuable.previous_changes.include?('description') + + GraphqlTriggers.issuable_description_updated(issuable) + end + def update(issuable) + prepare_update_params(issuable) handle_quick_actions(issuable) filter_params(issuable) @@ -316,7 +323,7 @@ class IssuableBaseService < ::BaseProjectService affected_assignees = (old_associations[:assignees] + new_assignees) - (old_associations[:assignees] & new_assignees) invalidate_cache_counts(issuable, users: affected_assignees.compact) - after_update(issuable) + after_update(issuable, old_associations) issuable.create_new_cross_references!(current_user) execute_hooks( issuable, @@ -356,7 +363,8 @@ class IssuableBaseService < ::BaseProjectService handle_task_changes(issuable) invalidate_cache_counts(issuable, users: issuable.assignees.to_a) - after_update(issuable) + # not passing old_associations here to keep `update_task` as fast as possible + after_update(issuable, {}) execute_hooks(issuable, 'update', old_associations: nil) if issuable.is_a?(MergeRequest) @@ -531,6 +539,8 @@ class IssuableBaseService < ::BaseProjectService end def has_label_changes?(issuable, old_labels) + return false if old_labels.nil? + Set.new(issuable.labels) != Set.new(old_labels) end @@ -542,12 +552,15 @@ class IssuableBaseService < ::BaseProjectService # override if needed def handle_label_changes(issuable, old_labels) - return unless has_label_changes?(issuable, old_labels) + return false unless has_label_changes?(issuable, old_labels) # reset to preserve the label sort order (title ASC) issuable.labels.reset GraphqlTriggers.issuable_labels_updated(issuable) + + # return true here to avoid checking for label changes in sub classes + true end # override if needed diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index d75e74f3b19..28ea6b0ebf8 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -68,6 +68,19 @@ module Issues rebalance_if_needed(issue) end + def handle_escalation_status_change(issue) + return unless issue.supports_escalation? + + if issue.escalation_status + ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new( + issue, + current_user + ).execute + else + ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute + end + end + def issuable_for_positioning(id, positioning_scope) return unless id diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index 07dd9a98f89..8b05a1c2acd 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -75,7 +75,16 @@ module Issues # Skip creation of system notes for existing attributes of the issue when cloning with notes. # The system notes of the old issue are copied over so we don't want to end up with duplicate notes. # When cloning without notes, we want to generate system notes for the attributes that were copied. - CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: with_notes) + create_result = CreateService.new( + project: target_project, + current_user: current_user, + params: new_params, + spam_params: spam_params + ).execute(skip_system_notes: with_notes) + + raise CloneError, create_result.errors.join(', ') if create_result.error? && create_result[:issue].blank? + + create_result[:issue] end def queue_copy_designs diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 92cf4811439..89b35bbab24 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -4,6 +4,7 @@ module Issues class CreateService < Issues::BaseService include ResolveDiscussions prepend RateLimitedService + include ::Services::ReturnServiceResponses rate_limit key: :issues_create, opts: { scope: [:project, :current_user, :external_author] } @@ -20,6 +21,8 @@ module Issues end def execute(skip_system_notes: false) + return error(_('Operation not allowed'), 403) unless @current_user.can?(authorization_action, @project) + @issue = @build_service.execute handle_move_between_ids(@issue) @@ -27,7 +30,13 @@ module Issues @add_related_issue ||= params.delete(:add_related_issue) filter_resolve_discussion_params - create(@issue, skip_system_notes: skip_system_notes) + issue = create(@issue, skip_system_notes: skip_system_notes) + + if issue.persisted? + success(issue: issue) + else + error(issue.errors.full_messages, 422, pass_back: { issue: issue }) + end end def external_author @@ -47,7 +56,7 @@ module Issues issue.run_after_commit do NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s) Issues::PlacementWorker.perform_async(nil, issue.project_id) - Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.project.namespace_id) + Onboarding::IssueCreatedWorker.perform_async(issue.project.namespace_id) end end @@ -56,7 +65,7 @@ module Issues user_agent_detail_service.create handle_add_related_issue(issue) resolve_discussions_with_issue(issue) - create_escalation_status(issue) + handle_escalation_status_change(issue) create_timeline_event(issue) try_to_associate_contacts(issue) @@ -87,12 +96,12 @@ module Issues private - attr_reader :spam_params, :extra_params - - def create_escalation_status(issue) - ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation? + def authorization_action + :create_issue end + attr_reader :spam_params, :extra_params + def create_timeline_event(issue) return unless issue.incident? diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb index bce3ecc8bef..83e550583f6 100644 --- a/app/services/issues/import_csv_service.rb +++ b/app/services/issues/import_csv_service.rb @@ -14,6 +14,10 @@ module Issues private + def create_issuable(attributes) + super[:issue] + end + def create_issuable_class Issues::CreateService end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index edab62b1fdf..6366ff4076b 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -83,7 +83,16 @@ module Issues # Skip creation of system notes for existing attributes of the issue. The system notes of the old # issue are copied over so we don't want to end up with duplicate notes. - CreateService.new(project: @target_project, current_user: @current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: true) + create_result = CreateService.new( + project: @target_project, + current_user: @current_user, + params: new_params, + spam_params: spam_params + ).execute(skip_system_notes: true) + + raise MoveError, create_result.errors.join(', ') if create_result.error? && create_result[:issue].blank? + + create_result[:issue] end def queue_copy_designs diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 46c28d82ddc..e5feb4422f6 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -63,7 +63,6 @@ module Issues handle_assignee_changes(issue, old_assignees) handle_confidential_change(issue) - handle_label_changes(issue, old_labels) handle_added_labels(issue, old_labels) handle_milestone_change(issue) handle_added_mentions(issue, old_mentioned_users) @@ -201,15 +200,6 @@ module Issues ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issue.id, current_user.id) end - def handle_escalation_status_change(issue) - return unless issue.supports_escalation? && issue.escalation_status - - ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new( - issue, - current_user - ).execute - end - def create_confidentiality_note(issue) SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user) end diff --git a/app/services/jira_connect/create_asymmetric_jwt_service.rb b/app/services/jira_connect/create_asymmetric_jwt_service.rb new file mode 100644 index 00000000000..71aba6feddd --- /dev/null +++ b/app/services/jira_connect/create_asymmetric_jwt_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module JiraConnect + class CreateAsymmetricJwtService + ARGUMENT_ERROR_MESSAGE = 'jira_connect_installation is not a proxy installation' + + def initialize(jira_connect_installation) + raise ArgumentError, ARGUMENT_ERROR_MESSAGE unless jira_connect_installation.proxy? + + @jira_connect_installation = jira_connect_installation + end + + def execute + JWT.encode(jwt_claims, private_key, 'RS256', jwt_headers) + end + + private + + def jwt_claims + { aud: aud_claim, iss: iss_claim, qsh: qsh_claim } + end + + def aud_claim + @jira_connect_installation.audience_url + end + + def iss_claim + @jira_connect_installation.client_key + end + + def qsh_claim + Atlassian::Jwt.create_query_string_hash( + @jira_connect_installation.audience_installed_event_url, + 'POST', + @jira_connect_installation.audience_url + ) + end + + def private_key + @private_key ||= OpenSSL::PKey::RSA.generate(3072) + end + + def public_key_storage + @public_key_storage ||= JiraConnect::PublicKey.create!(key: private_key.public_key) + end + + def jwt_headers + { kid: public_key_storage.uuid } + end + end +end diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb index e3b110f8f26..2786a2e357e 100644 --- a/app/services/labels/promote_service.rb +++ b/app/services/labels/promote_service.rb @@ -9,7 +9,7 @@ module Labels return unless project.group && label.is_a?(ProjectLabel) - Label.transaction do + ProjectLabel.transaction do # use the existing group label if it exists group_label = find_or_create_group_label(label) @@ -50,7 +50,7 @@ module Labels .new(current_user, title: group_label.title, group_id: project.group.id) .execute(skip_authorization: true) .where.not(id: group_label) - .select(:id) # Can't use pluck() to avoid object-creation because of the batching + .select(:id, :project_id, :group_id, :type) # Can't use pluck() to avoid object-creation because of the batching end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 38bebc1d09d..aba075c3644 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -179,7 +179,7 @@ module Members def enqueue_onboarding_progress_action return unless member_created_namespace_id - Namespaces::OnboardingUserAddedWorker.perform_async(member_created_namespace_id) + Onboarding::UserAddedWorker.perform_async(member_created_namespace_id) end def result @@ -195,6 +195,8 @@ module Members end def publish_event! + return unless member_created_namespace_id + Gitlab::EventStore.publish( Members::MembersAddedEvent.new(data: { source_id: source.id, diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 0a8344c58db..ce79907e8a8 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -2,6 +2,8 @@ module Members class DestroyService < Members::BaseService + include Gitlab::ExclusiveLeaseHelpers + def execute(member, skip_authorization: false, skip_subresources: false, unassign_issuables: false, destroy_bot: false) unless skip_authorization raise Gitlab::Access::AccessDeniedError unless authorized?(member, destroy_bot) @@ -11,13 +13,26 @@ module Members end @skip_auth = skip_authorization + last_owner = true + + in_lock("delete_members:#{member.source.class}:#{member.source.id}") do + break if member.is_a?(GroupMember) && member.source.last_owner?(member.user) + + last_owner = false + member.destroy + member.user&.invalidate_cache_counts + end - return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user) + unless last_owner + delete_member_associations(member, skip_subresources, unassign_issuables) + end - member.destroy + member + end - member.user&.invalidate_cache_counts + private + def delete_member_associations(member, skip_subresources, unassign_issuables) if member.request? && member.user != current_user notification_service.decline_access_request(member) end @@ -28,12 +43,8 @@ module Members enqueue_unassign_issuables(member) if unassign_issuables after_execute(member: member) - - member end - private - def authorized?(member, destroy_bot) return can_destroy_bot_member?(member) if destroy_bot diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 64ae33c9b15..5761e34caff 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -3,7 +3,7 @@ module MergeRequests class ApprovalService < MergeRequests::BaseService def execute(merge_request) - return unless can_be_approved?(merge_request) + return unless eligible_for_approval?(merge_request) approval = merge_request.approvals.new(user: current_user) @@ -28,8 +28,8 @@ module MergeRequests private - def can_be_approved?(merge_request) - merge_request.can_be_approved_by?(current_user) + def eligible_for_approval?(merge_request) + merge_request.eligible_for_approval_by?(current_user) end def save_approval(approval) diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 6cefd9169f5..cfd7c645b7e 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -58,6 +58,7 @@ module MergeRequests new_reviewers = merge_request.reviewers - old_reviewers merge_request_activity_counter.track_users_review_requested(users: new_reviewers) merge_request_activity_counter.track_reviewers_changed_action(user: current_user) + trigger_merge_request_reviewers_updated(merge_request) end def cleanup_environments(merge_request) @@ -244,6 +245,10 @@ module MergeRequests Milestones::MergeRequestsCountService.new(milestone).delete_cache end + + def trigger_merge_request_reviewers_updated(merge_request) + GraphqlTriggers.merge_request_reviewers_updated(merge_request) + end end end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index f83b14c7269..da3a9652d69 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -23,6 +23,7 @@ module MergeRequests cleanup_environments(merge_request) abort_auto_merge(merge_request, 'merge request was closed') cleanup_refs(merge_request) + trigger_merge_request_merge_status_updated(merge_request) end merge_request @@ -38,5 +39,9 @@ module MergeRequests merge_request_metrics_service(merge_request).close(close_event) end end + + def trigger_merge_request_merge_status_updated(merge_request) + GraphqlTriggers.merge_request_merge_status_updated(merge_request) + end end end diff --git a/app/services/merge_requests/mark_reviewer_reviewed_service.rb b/app/services/merge_requests/mark_reviewer_reviewed_service.rb index 766a4ca0a49..96747eabcf6 100644 --- a/app/services/merge_requests/mark_reviewer_reviewed_service.rb +++ b/app/services/merge_requests/mark_reviewer_reviewed_service.rb @@ -10,6 +10,8 @@ module MergeRequests if reviewer return error("Failed to update reviewer") unless reviewer.update(state: :reviewed) + trigger_merge_request_reviewers_updated(merge_request) + success else error("Reviewer not found") diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index 3e630d40b3d..2a3c1e8bc26 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -9,12 +9,12 @@ module MergeRequests attr_reader :merge_request # Overridden in EE. - def hooks_validation_pass?(_merge_request) + def hooks_validation_pass?(merge_request, validate_squash_message: false) true end # Overridden in EE. - def hooks_validation_error(_merge_request) + def hooks_validation_error(merge_request, validate_squash_message: false) # No-op end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 6d31a29f5a7..6b4f9dbe509 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -26,6 +26,7 @@ module MergeRequests @merge_request = merge_request @options = options + jid = merge_jid validate! @@ -37,7 +38,7 @@ module MergeRequests end end - log_info("Merge process finished on JID #{merge_jid} with state #{state}") + log_info("Merge process finished on JID #{jid} with state #{state}") rescue MergeError => e handle_merge_error(log_message: e.message, save_message_on_model: true) ensure @@ -159,17 +160,32 @@ module MergeRequests end def handle_merge_error(log_message:, save_message_on_model: false) - Gitlab::AppLogger.error("MergeService ERROR: #{merge_request_info} - #{log_message}") + log_error("MergeService ERROR: #{merge_request_info} - #{log_message}") @merge_request.update(merge_error: log_message) if save_message_on_model end def log_info(message) + payload = log_payload("#{merge_request_info} - #{message}") + logger.info(**payload) + end + + def log_error(message) + payload = log_payload(message) + logger.error(**payload) + end + + def logger @logger ||= Gitlab::AppLogger - @logger.info("#{merge_request_info} - #{message}") + end + + def log_payload(message) + Gitlab::ApplicationContext.current + .merge(merge_request_info: merge_request_info, + message: message) end def merge_request_info - merge_request.to_reference(full: true) + @merge_request_info ||= merge_request.to_reference(full: true) end def source_matches? diff --git a/app/services/merge_requests/mergeability/logger.rb b/app/services/merge_requests/mergeability/logger.rb index 8b45d231e03..88ef6d81eaa 100644 --- a/app/services/merge_requests/mergeability/logger.rb +++ b/app/services/merge_requests/mergeability/logger.rb @@ -11,16 +11,12 @@ module MergeRequests end def commit - return unless enabled? - commit_logs end def instrument(mergeability_name:) raise ArgumentError, 'block not given' unless block_given? - return yield unless enabled? - op_start_db_counters = current_db_counter_payload op_started_at = current_monotonic_time @@ -38,15 +34,11 @@ module MergeRequests attr_reader :destination, :merge_request def observe(name, value) - return unless enabled? - observations[name.to_s].push(value) end def commit_logs - attributes = Gitlab::ApplicationContext.current.merge({ - mergeability_project_id: merge_request.project.id - }) + attributes = Gitlab::ApplicationContext.current.merge({ mergeability_project_id: merge_request.project.id }) attributes[:mergeability_merge_request_id] = merge_request.id attributes.merge!(observations_hash) @@ -89,12 +81,6 @@ module MergeRequests ::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_payload end - def enabled? - strong_memoize(:enabled) do - ::Feature.enabled?(:mergeability_checks_logger, merge_request.project) - end - end - def current_monotonic_time ::Gitlab::Metrics::System.monotonic_time end diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index ef251f121ae..aa52349b0ee 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -104,7 +104,7 @@ module MergeRequests merge_request = ::MergeRequests::CreateService.new( project: project, current_user: current_user, - params: merge_request.attributes.merge(assignees: merge_request.assignees, + params: merge_request.attributes.merge(assignee_ids: merge_request.assignee_ids, label_ids: merge_request.label_ids) ).execute end @@ -140,8 +140,8 @@ module MergeRequests params[:add_labels] = params.delete(:label).keys if params.has_key?(:label) params[:remove_labels] = params.delete(:unlabel).keys if params.has_key?(:unlabel) - params[:add_assignee_ids] = params.delete(:assign).keys if params.has_key?(:assign) - params[:remove_assignee_ids] = params.delete(:unassign).keys if params.has_key?(:unassign) + params[:add_assignee_ids] = convert_to_user_ids(params.delete(:assign).keys) if params.has_key?(:assign) + params[:remove_assignee_ids] = convert_to_user_ids(params.delete(:unassign).keys) if params.has_key?(:unassign) if push_options[:milestone] milestone = Milestone.for_projects_and_groups(@project, @project.ancestors_upto)&.find_by_name(push_options[:milestone]) @@ -169,7 +169,7 @@ module MergeRequests params = base_params params.merge!( - assignees: [current_user], + assignee_ids: [current_user.id], source_branch: branch, source_project: project, target_project: target_project @@ -186,6 +186,12 @@ module MergeRequests base_params.merge(merge_params(merge_request.source_branch)) end + def convert_to_user_ids(ids_or_usernames) + ids, usernames = ids_or_usernames.partition { |id_or_username| id_or_username.is_a?(Numeric) || id_or_username.match?(/\A\d+\z/) } + ids += User.by_username(usernames).pluck(:id) unless usernames.empty? # rubocop:disable CodeReuse/ActiveRecord + ids + end + def collect_errors_from_merge_request(merge_request) merge_request.errors.full_messages.each do |error| errors << error diff --git a/app/services/merge_requests/request_review_service.rb b/app/services/merge_requests/request_review_service.rb index b061ed45fee..ebbae98352b 100644 --- a/app/services/merge_requests/request_review_service.rb +++ b/app/services/merge_requests/request_review_service.rb @@ -11,6 +11,7 @@ module MergeRequests return error("Failed to update reviewer") unless reviewer.update(state: :unreviewed) notify_reviewer(merge_request, user) + trigger_merge_request_reviewers_updated(merge_request) success else diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index a13db52e34b..79a3e9f3c22 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -18,7 +18,17 @@ module MergeRequests return merge_request if old_ids.to_set == new_ids.to_set # no-change attrs = update_attrs.merge(assignee_ids: new_ids) - merge_request.update!(**attrs) + + # We now have assignees validation on merge request + # If we use an update with bang, it will explode, + # instead we need to check if its valid then return if its not valid. + if Feature.enabled?(:limit_assignees_per_issuable) + merge_request.update(**attrs) + + return merge_request unless merge_request.valid? + else + merge_request.update!(**attrs) + end # Defer the more expensive operations (handle_assignee_changes) to the background MergeRequests::HandleAssigneesChangeService diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6d518edc88f..745647b727c 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -38,7 +38,6 @@ module MergeRequests handle_target_branch_change(merge_request) handle_milestone_change(merge_request) handle_draft_status_change(merge_request, changed_fields) - handle_label_changes(merge_request, old_labels) track_title_and_desc_edits(changed_fields) track_discussion_lock_toggle(merge_request, changed_fields) @@ -71,7 +70,8 @@ module MergeRequests MergeRequests::CloseService end - def after_update(issuable) + def after_update(issuable, old_associations) + super issuable.cache_merge_request_closes_issues!(current_user) end @@ -179,9 +179,12 @@ module MergeRequests old_title_draft = MergeRequest.draft?(old_title) new_title_draft = MergeRequest.draft?(new_title) - # notify the draft status changed. Added/removed message is handled in the - # email template itself, see `change_in_merge_request_draft_status_email` template. - notify_draft_status_changed(merge_request) if old_title_draft || new_title_draft + if old_title_draft || new_title_draft + # notify the draft status changed. Added/removed message is handled in the + # email template itself, see `change_in_merge_request_draft_status_email` template. + notify_draft_status_changed(merge_request) + trigger_merge_request_status_updated(merge_request) + end if !old_title_draft && new_title_draft # Marked as Draft @@ -320,6 +323,10 @@ module MergeRequests def filter_sentinel_values(param) param.reject { _1 == 0 } end + + def trigger_merge_request_status_updated(merge_request) + GraphqlTriggers.merge_request_merge_status_updated(merge_request) + end end end diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb new file mode 100644 index 00000000000..b6f87995185 --- /dev/null +++ b/app/services/ml/experiment_tracking/candidate_repository.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Ml + module ExperimentTracking + class CandidateRepository + attr_accessor :project, :user, :experiment, :candidate + + def initialize(project, user) + @project = project + @user = user + end + + def by_iid(iid) + ::Ml::Candidate.with_project_id_and_iid(project.id, iid) + end + + def create!(experiment, start_time) + experiment.candidates.create!( + user: user, + start_time: start_time || 0 + ) + end + + def update(candidate, status, end_time) + candidate.status = status.downcase if status + candidate.end_time = end_time if end_time + + candidate.save + end + + def add_metric!(candidate, name, value, tracked_at, step) + candidate.metrics.create!( + name: name, + value: value, + tracked_at: tracked_at, + step: step + ) + end + + def add_param!(candidate, name, value) + candidate.params.create!(name: name, value: value) + end + + def add_metrics(candidate, metric_definitions) + return unless candidate.present? + + metrics = metric_definitions.map do |metric| + { + candidate_id: candidate.id, + name: metric[:key], + value: metric[:value], + tracked_at: metric[:timestamp], + step: metric[:step], + **timestamps + } + end + + ::Ml::CandidateMetric.insert_all(metrics, returning: false) unless metrics.empty? + end + + def add_params(candidate, param_definitions) + return unless candidate.present? + + parameters = param_definitions.map do |p| + { + candidate_id: candidate.id, + name: p[:key], + value: p[:value], + **timestamps + } + end + + ::Ml::CandidateParam.insert_all(parameters, returning: false) unless parameters.empty? + end + + private + + def timestamps + current_time = Time.zone.now + + { created_at: current_time, updated_at: current_time } + end + end + end +end diff --git a/app/services/ml/experiment_tracking/experiment_repository.rb b/app/services/ml/experiment_tracking/experiment_repository.rb new file mode 100644 index 00000000000..891674adc2a --- /dev/null +++ b/app/services/ml/experiment_tracking/experiment_repository.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Ml + module ExperimentTracking + class ExperimentRepository + attr_accessor :project, :user + + def initialize(project, user = nil) + @project = project + @user = user + end + + def by_iid_or_name(iid: nil, name: nil) + return ::Ml::Experiment.by_project_id_and_iid(project.id, iid) if iid + + ::Ml::Experiment.by_project_id_and_name(project.id, name) if name + end + + def all + ::Ml::Experiment.by_project_id(project.id) + end + + def create!(name) + ::Ml::Experiment.create!(name: name, + user: user, + project: project) + end + end + end +end diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb index c0af0900450..0c6fcee9113 100644 --- a/app/services/namespaces/package_settings/update_service.rb +++ b/app/services/namespaces/package_settings/update_service.rb @@ -8,7 +8,13 @@ module Namespaces ALLOWED_ATTRIBUTES = %i[maven_duplicates_allowed maven_duplicate_exception_regex generic_duplicates_allowed - generic_duplicate_exception_regex].freeze + generic_duplicate_exception_regex + maven_package_requests_forwarding + npm_package_requests_forwarding + pypi_package_requests_forwarding + lock_maven_package_requests_forwarding + lock_npm_package_requests_forwarding + lock_pypi_package_requests_forwarding].freeze def execute return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed? diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index b7e6a50fa5c..1aaf7fb769a 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -88,12 +88,14 @@ module Notes return if quick_actions_service.commands_executed_count.to_i == 0 if update_params.present? - if check_for_reviewer_validity(message, update_params) + invalid_message = validate_commands(note, update_params) + + if invalid_message + note.errors.add(:validation, invalid_message) + message = invalid_message + else quick_actions_service.apply_updates(update_params, note) note.commands_changes = update_params - else - message = "Reviewers #{MergeRequest.max_number_of_assignees_or_reviewers_message}" - note.errors.add(:validation, message) end end @@ -114,16 +116,36 @@ module Notes } end - def check_for_reviewer_validity(message, update_params) - return true unless Feature.enabled?(:limit_reviewer_and_assignee_size) + def validate_commands(note, update_params) + if invalid_reviewers?(update_params) + "Reviewers #{note.noteable.class.max_number_of_assignees_or_reviewers_message}" + elsif invalid_assignees?(update_params) + "Assignees #{note.noteable.class.max_number_of_assignees_or_reviewers_message}" + end + end + + def invalid_reviewers?(update_params) + return false unless Feature.enabled?(:limit_reviewer_and_assignee_size) if update_params.key?(:reviewer_ids) possible_reviewers = update_params[:reviewer_ids]&.uniq&.size - return false if possible_reviewers > MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + possible_reviewers > ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + else + false end + end + + def invalid_assignees?(update_params) + return false unless Feature.enabled?(:limit_assignees_per_issuable) - true + if update_params.key?(:assignee_ids) + possible_assignees = update_params[:assignee_ids]&.uniq&.size + + possible_assignees > ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + else + false + end end def track_event(note, user) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5a92adfd25a..1224cf80b76 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -87,6 +87,13 @@ class NotificationService mailer.access_token_expired_email(user).deliver_later end + # Notify the user when one of their personal access tokens is revoked + def access_token_revoked(user, token_name) + return unless user.can?(:receive_notifications) + + mailer.access_token_revoked_email(user, token_name).deliver_later + end + # Notify the user when at least one of their ssh key has expired today def ssh_key_expired(user, fingerprints) return unless user.can?(:receive_notifications) @@ -109,6 +116,14 @@ class NotificationService mailer.unknown_sign_in_email(user, ip, time).deliver_later end + # Notify a user when a wrong 2FA OTP has been entered to + # try to sign in to their account + def two_factor_otp_attempt_failed(user, ip) + return unless user.can?(:receive_notifications) + + mailer.two_factor_otp_attempt_failed_email(user, ip).deliver_later + end + # Notify a user when a new email address is added to the their account def new_email_address_added(user, email) return unless user.can?(:receive_notifications) diff --git a/app/services/onboarding/progress_service.rb b/app/services/onboarding/progress_service.rb index 66f7f2bc33d..c67669b49ab 100644 --- a/app/services/onboarding/progress_service.rb +++ b/app/services/onboarding/progress_service.rb @@ -12,7 +12,7 @@ module Onboarding def execute(action:) return unless Onboarding::Progress.not_completed?(namespace_id, action) - Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action) + Onboarding::ProgressWorker.perform_async(namespace_id, action) end end diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb index 53275fdc9bb..19e68183ea2 100644 --- a/app/services/packages/debian/create_package_file_service.rb +++ b/app/services/packages/debian/create_package_file_service.rb @@ -5,18 +5,20 @@ module Packages class CreatePackageFileService include ::Packages::FIPS - def initialize(package, params) + def initialize(package:, current_user:, params: {}) @package = package + @current_user = current_user @params = params end def execute raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? raise ArgumentError, "Invalid package" unless package.present? + raise ArgumentError, "Invalid user" unless current_user.present? # Debian package file are first uploaded to incoming with empty metadata, # and are moved later by Packages::Debian::ProcessChangesService - package.package_files.create!( + package_file = package.package_files.create!( file: params[:file], size: params[:file]&.size, file_name: params[:file_name], @@ -29,11 +31,17 @@ module Packages fields: nil } ) + + if params[:file_name].end_with? '.changes' + ::Packages::Debian::ProcessChangesWorker.perform_async(package_file.id, current_user.id) + end + + package_file end private - attr_reader :package, :params + attr_reader :package, :current_user, :params end end end diff --git a/app/services/packages/mark_packages_for_destruction_service.rb b/app/services/packages/mark_packages_for_destruction_service.rb new file mode 100644 index 00000000000..023392cf2d9 --- /dev/null +++ b/app/services/packages/mark_packages_for_destruction_service.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Packages + class MarkPackagesForDestructionService + include BaseServiceUtility + + BATCH_SIZE = 20 + + UNAUTHORIZED_RESPONSE = ServiceResponse.error( + message: "You don't have the permission to perform this action", + reason: :unauthorized + ).freeze + + ERROR_RESPONSE = ServiceResponse.error( + message: 'Failed to mark the packages as pending destruction' + ).freeze + + SUCCESS_RESPONSE = ServiceResponse.success( + message: 'Packages were successfully marked as pending destruction' + ).freeze + + # Initialize this service with the given packages and user. + # + # * `packages`: must be an ActiveRecord relationship. + # * `current_user`: an User object. Could be nil. + def initialize(packages:, current_user: nil) + @packages = packages + @current_user = current_user + end + + def execute(batch_size: BATCH_SIZE) + no_access = false + min_batch_size = [batch_size, BATCH_SIZE].min + + @packages.each_batch(of: min_batch_size) do |batched_packages| + loaded_packages = batched_packages.including_project_route.to_a + + break no_access = true unless can_destroy_packages?(loaded_packages) + + ::Packages::Package.id_in(loaded_packages.map(&:id)) + .update_all(status: :pending_destruction) + + sync_maven_metadata(loaded_packages) + mark_package_files_for_destruction(loaded_packages) + end + + return UNAUTHORIZED_RESPONSE if no_access + + SUCCESS_RESPONSE + rescue StandardError + ERROR_RESPONSE + end + + private + + def mark_package_files_for_destruction(packages) + ::Packages::MarkPackageFilesForDestructionWorker.bulk_perform_async_with_contexts( + packages, + arguments_proc: -> (package) { package.id }, + context_proc: -> (package) { { project: package.project, user: @current_user } } + ) + end + + def sync_maven_metadata(packages) + maven_packages_with_version = packages.select { |pkg| pkg.maven? && pkg.version? } + ::Packages::Maven::Metadata::SyncWorker.bulk_perform_async_with_contexts( + maven_packages_with_version, + arguments_proc: -> (package) { [@current_user.id, package.project_id, package.name] }, + context_proc: -> (package) { { project: package.project, user: @current_user } } + ) + end + + def can_destroy_packages?(packages) + packages.all? do |package| + can?(@current_user, :destroy_package, package) + end + end + end +end diff --git a/app/services/packages/rpm/parse_package_service.rb b/app/services/packages/rpm/parse_package_service.rb new file mode 100644 index 00000000000..689a161a81a --- /dev/null +++ b/app/services/packages/rpm/parse_package_service.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Packages + module Rpm + class ParsePackageService + include ::Gitlab::Utils::StrongMemoize + + BUILD_ATTRIBUTES_METHOD_NAMES = %i[changelogs requirements provides].freeze + STATIC_ATTRIBUTES = %i[name version release summary description arch + license sourcerpm group buildhost packager vendor].freeze + + CHANGELOGS_RPM_KEYS = %i[changelogtext changelogtime].freeze + REQUIREMENTS_RPM_KEYS = %i[requirename requireversion requireflags].freeze + PROVIDES_RPM_KEYS = %i[providename provideflags provideversion].freeze + + def initialize(package_file) + @rpm = RPM::File.new(package_file) + end + + def execute + raise ArgumentError, 'Unable to parse package' unless valid_package? + + { + files: rpm.files || [], + epoch: package_tags[:epoch] || '0', + changelogs: build_changelogs, + requirements: build_requirements, + provides: build_provides + }.merge(extract_static_attributes) + end + + private + + attr_reader :rpm + + def valid_package? + rpm.files && package_tags && true + rescue RuntimeError + # if arr-pm throws an error due to an incorrect file format, + # we just want this validation to fail rather than throw an exception + false + end + + def package_tags + strong_memoize(:package_tags) do + rpm.tags + end + end + + def extract_static_attributes + STATIC_ATTRIBUTES.each_with_object({}) do |attribute, hash| + hash[attribute] = package_tags[attribute] + end + end + + # Define methods for building RPM attribute data from parsed package + # Transform + # changelogtime: [123, 234], + # changelogname: ["First", "Second"] + # changelogtext: ["Work1", "Work2"] + # Into + # changelog: [ + # {changelogname: "First", changelogtext: "Work1", changelogtime: 123}, + # {changelogname: "Second", changelogtext: "Work2", changelogtime: 234} + # ] + BUILD_ATTRIBUTES_METHOD_NAMES.each do |resource| + define_method("build_#{resource}") do + resource_keys = self.class.const_get("#{resource.upcase}_RPM_KEYS", false).dup + return [] if resource_keys.any? { package_tags[_1].blank? } + + first_attributes = package_tags[resource_keys.first] + zipped_data = first_attributes.zip(*resource_keys[1..].map { package_tags[_1] }) + build_hashes(resource_keys, zipped_data) + end + end + + def build_hashes(resource_keys, zipped_data) + zipped_data.map do |data| + resource_keys.zip(data).to_h + end + end + end + end +end diff --git a/app/services/packages/rpm/repository_metadata/base_builder.rb b/app/services/packages/rpm/repository_metadata/base_builder.rb index 9d76336d764..2c0a11457ec 100644 --- a/app/services/packages/rpm/repository_metadata/base_builder.rb +++ b/app/services/packages/rpm/repository_metadata/base_builder.rb @@ -3,17 +3,43 @@ module Packages module Rpm module RepositoryMetadata class BaseBuilder + def initialize(xml: nil, data: {}) + @xml = Nokogiri::XML(xml) if xml.present? + @data = data + end + def execute - build_empty_structure + return build_empty_structure if xml.blank? + + update_xml_document + update_package_count + xml.to_xml end private + attr_reader :xml, :data + def build_empty_structure Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| - xml.public_send(self.class::ROOT_TAG, self.class::ROOT_ATTRIBUTES) # rubocop:disable GitlabSecurity/PublicSend + xml.method_missing(self.class::ROOT_TAG, self.class::ROOT_ATTRIBUTES) end.to_xml end + + def update_xml_document + # Add to the root xml element a new package metadata node + xml.at(self.class::ROOT_TAG).add_child(build_new_node) + end + + def update_package_count + packages_count = xml.css("//#{self.class::ROOT_TAG}/package").count + + xml.at(self.class::ROOT_TAG).attributes["packages"].value = packages_count.to_s + end + + def build_new_node + raise NotImplementedError, "#{self.class} should implement #{__method__}" + end end end end diff --git a/app/services/packages/rpm/repository_metadata/build_primary_xml.rb b/app/services/packages/rpm/repository_metadata/build_primary_xml.rb index affb41677c2..580bf844a0c 100644 --- a/app/services/packages/rpm/repository_metadata/build_primary_xml.rb +++ b/app/services/packages/rpm/repository_metadata/build_primary_xml.rb @@ -9,6 +9,79 @@ module Packages 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm', packages: '0' }.freeze + + # Nodes that have only text without attributes + REQUIRED_BASE_ATTRIBUTES = %i[name arch summary description].freeze + NOT_REQUIRED_BASE_ATTRIBUTES = %i[url packager].freeze + FORMAT_NODE_BASE_ATTRIBUTES = %i[license vendor group buildhost sourcerpm].freeze + + private + + def build_new_node + builder = Nokogiri::XML::Builder.new do |xml| + xml.package(type: :rpm, 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm') do + build_required_base_attributes(xml) + build_not_required_base_attributes(xml) + xml.version epoch: data[:epoch], ver: data[:version], rel: data[:release] + xml.checksum data[:checksum], type: 'sha256', pkgid: 'YES' + xml.size package: data[:packagesize], installed: data[:installedsize], archive: data[:archivesize] + xml.time file: data[:filetime], build: data[:buildtime] + xml.location href: data[:location] if data[:location].present? + build_format_node(xml) + end + end + + Nokogiri::XML(builder.to_xml).at('package') + end + + def build_required_base_attributes(xml) + REQUIRED_BASE_ATTRIBUTES.each do |attribute| + xml.method_missing(attribute, data[attribute]) + end + end + + def build_not_required_base_attributes(xml) + NOT_REQUIRED_BASE_ATTRIBUTES.each do |attribute| + xml.method_missing(attribute, data[attribute]) if data[attribute].present? + end + end + + def build_format_node(xml) + xml.format do + build_base_format_attributes(xml) + build_provides_node(xml) + build_requires_node(xml) + end + end + + def build_base_format_attributes(xml) + FORMAT_NODE_BASE_ATTRIBUTES.each do |attribute| + xml[:rpm].method_missing(attribute, data[attribute]) if data[attribute].present? + end + end + + def build_requires_node(xml) + xml[:rpm].requires do + data[:requirements].each do |requires| + xml[:rpm].entry( + name: requires[:requirename], + flags: requires[:requireflags], + ver: requires[:requireversion] + ) + end + end + end + + def build_provides_node(xml) + xml[:rpm].provides do + data[:provides].each do |provides| + xml[:rpm].entry( + name: provides[:providename], + flags: provides[:provideflags], + ver: provides[:provideversion]) + end + end + end end end end diff --git a/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb b/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb index c6cfd77815d..84614196254 100644 --- a/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb +++ b/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb @@ -9,6 +9,7 @@ module Packages xmlns: 'http://linux.duke.edu/metadata/repo', 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm' }.freeze + ALLOWED_DATA_VALUE_KEYS = %i[checksum open-checksum location timestamp size open-size].freeze # Expected `data` structure # @@ -48,9 +49,9 @@ module Packages end def build_file_info(info, xml) - info.each do |key, attributes| + info.slice(*ALLOWED_DATA_VALUE_KEYS).each do |key, attributes| value = attributes.delete(:value) - xml.public_send(key, value, attributes) # rubocop:disable GitlabSecurity/PublicSend + xml.method_missing(key, value, attributes) end end end diff --git a/app/services/pages_domains/create_acme_order_service.rb b/app/services/pages_domains/create_acme_order_service.rb index e289a78091b..c600f497fa5 100644 --- a/app/services/pages_domains/create_acme_order_service.rb +++ b/app/services/pages_domains/create_acme_order_service.rb @@ -2,9 +2,6 @@ module PagesDomains class CreateAcmeOrderService - # elliptic curve algorithm to generate the private key - ECDSA_CURVE = "prime256v1" - attr_reader :pages_domain def initialize(pages_domain) @@ -17,12 +14,7 @@ module PagesDomains challenge = order.new_challenge - private_key = if Feature.enabled?(:pages_lets_encrypt_ecdsa, pages_domain.project) - OpenSSL::PKey::EC.generate(ECDSA_CURVE) - else - OpenSSL::PKey::RSA.new(4096) - end - + private_key = OpenSSL::PKey::RSA.new(4096) saved_order = pages_domain.acme_orders.create!( url: order.url, expires_at: order.expires, diff --git a/app/services/pages_domains/create_service.rb b/app/services/pages_domains/create_service.rb new file mode 100644 index 00000000000..1f771ca3a05 --- /dev/null +++ b/app/services/pages_domains/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PagesDomains + class CreateService < BaseService + def execute + return unless authorized? + + domain = project.pages_domains.create(params) + + publish_event(domain) if domain.persisted? + + domain + end + + private + + def authorized? + current_user.can?(:update_pages, project) + end + + def publish_event(domain) + event = PagesDomainCreatedEvent.new( + data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id, + domain: domain.domain + } + ) + + Gitlab::EventStore.publish(event) + end + end +end diff --git a/app/services/pages_domains/delete_service.rb b/app/services/pages_domains/delete_service.rb new file mode 100644 index 00000000000..af69e1845a9 --- /dev/null +++ b/app/services/pages_domains/delete_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PagesDomains + class DeleteService < BaseService + def execute(domain) + return unless authorized? + + domain.destroy + + publish_event(domain) + end + + private + + def authorized? + current_user.can?(:update_pages, project) + end + + def publish_event(domain) + event = PagesDomainDeletedEvent.new( + data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id, + domain: domain.domain + } + ) + + Gitlab::EventStore.publish(event) + end + end +end diff --git a/app/services/pages_domains/update_service.rb b/app/services/pages_domains/update_service.rb new file mode 100644 index 00000000000..b038aaa95b6 --- /dev/null +++ b/app/services/pages_domains/update_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PagesDomains + class UpdateService < BaseService + def execute(domain) + return unless authorized? + + return false unless domain.update(params) + + publish_event(domain) + + true + end + + private + + def authorized? + current_user.can?(:update_pages, project) + end + + def publish_event(domain) + event = PagesDomainUpdatedEvent.new( + data: { + project_id: project.id, + namespace_id: project.namespace_id, + root_namespace_id: project.root_namespace.id, + domain: domain.domain + } + ) + + Gitlab::EventStore.publish(event) + end + end +end diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb index 0275d03bcc9..732da75da3a 100644 --- a/app/services/personal_access_tokens/revoke_service.rb +++ b/app/services/personal_access_tokens/revoke_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module PersonalAccessTokens - class RevokeService + class RevokeService < BaseService attr_reader :token, :current_user, :group def initialize(current_user = nil, token: nil, group: nil ) @@ -15,6 +15,7 @@ module PersonalAccessTokens if token.revoke! log_event + notification_service.access_token_revoked(token.user, token.name) ServiceResponse.success(message: success_message) else ServiceResponse.error(message: error_message) diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index e6b1b33a82a..ae5aae87a77 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -24,7 +24,7 @@ module Projects end def commands(noteable, type) - return [] unless noteable + return [] unless noteable && current_user QuickActions::InterpretService.new(project, current_user).available_commands(noteable) end @@ -33,9 +33,21 @@ module Projects SnippetsFinder.new(current_user, project: project).execute.select([:id, :title]) end - def contacts - Crm::ContactsFinder.new(current_user, group: project.group).execute - .select([:id, :email, :first_name, :last_name]) + def contacts(target) + available_contacts = Crm::ContactsFinder.new(current_user, group: project.group).execute + .select([:id, :email, :first_name, :last_name, :state]) + + contact_hashes = available_contacts.as_json + + return contact_hashes unless target.is_a?(Issue) + + ids = target.customer_relations_contacts.ids # rubocop:disable CodeReuse/ActiveRecord + + contact_hashes.each do |hash| + hash[:set] = ids.include?(hash['id']) + end + + contact_hashes end def labels_as_hash(target) diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb index 57b913b04e6..58e146e5a32 100644 --- a/app/services/projects/blame_service.rb +++ b/app/services/projects/blame_service.rb @@ -27,6 +27,10 @@ module Projects .page(page) end + def per_page + PER_PAGE + end + private attr_reader :blob, :commit, :pagination_enabled @@ -48,10 +52,6 @@ module Projects page end - def per_page - PER_PAGE - end - def pagination_state(params) return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false) diff --git a/app/services/projects/container_repository/cleanup_tags_base_service.rb b/app/services/projects/container_repository/cleanup_tags_base_service.rb index 8ea4ae4830a..5393c2c080d 100644 --- a/app/services/projects/container_repository/cleanup_tags_base_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb @@ -60,23 +60,6 @@ module Projects service.execute(container_repository) end - def can_destroy? - return true if container_expiration_policy - - can?(current_user, :destroy_container_image, project) - end - - def valid_regex? - %w[name_regex_delete name_regex name_regex_keep].each do |param_name| - regex = params[param_name] - ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank? - end - true - rescue RegexpError => e - ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id) - false - end - def older_than params['older_than'] end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 285c3e252ef..cf2eb81e5f3 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -2,101 +2,57 @@ module Projects module ContainerRepository - class CleanupTagsService < CleanupTagsBaseService - def initialize(container_repository:, current_user: nil, params: {}) - super - - @params = params.dup - @counts = { cached_tags_count: 0 } - end - + class CleanupTagsService < BaseContainerRepositoryService def execute return error('access denied') unless can_destroy? return error('invalid regex') unless valid_regex? - tags = container_repository.tags - @counts[:original_size] = tags.size - - filter_out_latest!(tags) - filter_by_name!(tags) - - tags = truncate(tags) - populate_from_cache(tags) - - tags = filter_keep_n(tags) - tags = filter_by_older_than(tags) - - @counts[:before_delete_size] = tags.size - - delete_tags(tags).merge(@counts).tap do |result| - result[:deleted_size] = result[:deleted]&.size - - result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size] - end + cleanup_tags_service_class.new(container_repository: container_repository, current_user: current_user, params: params) + .execute end private - def filter_keep_n(tags) - tags, tags_to_keep = partition_by_keep_n(tags) - - cache_tags(tags_to_keep) - - tags - end - - def filter_by_older_than(tags) - tags, tags_to_keep = partition_by_older_than(tags) - - cache_tags(tags_to_keep) - - tags + def cleanup_tags_service_class + log_data = { + container_repository_id: container_repository.id, + container_repository_path: container_repository.path, + project_id: project.id + } + + if use_gitlab_service? + log_info(log_data.merge(gitlab_cleanup_tags_service: true)) + ::Projects::ContainerRepository::Gitlab::CleanupTagsService + else + log_info(log_data.merge(third_party_cleanup_tags_service: true)) + ::Projects::ContainerRepository::ThirdParty::CleanupTagsService + end end - def pushed_at(tag) - tag.created_at + def use_gitlab_service? + container_repository.migrated? && + container_repository.gitlab_api_client.supports_gitlab_api? end - def truncate(tags) - @counts[:before_truncate_size] = tags.size - @counts[:after_truncate_size] = tags.size - - return tags if max_list_size == 0 - - # truncate the list to make sure that after the #filter_keep_n - # execution, the resulting list will be max_list_size - truncated_size = max_list_size + keep_n_as_integer - - return tags if tags.size <= truncated_size + def can_destroy? + return true if container_expiration_policy - tags = tags.sample(truncated_size) - @counts[:after_truncate_size] = tags.size - tags + can?(current_user, :destroy_container_image, project) end - def populate_from_cache(tags) - @counts[:cached_tags_count] = cache.populate(tags) if caching_enabled? - end - - def cache_tags(tags) - cache.insert(tags, older_than_in_seconds) if caching_enabled? - end - - def cache - strong_memoize(:cache) do - ::Gitlab::ContainerRepository::Tags::Cache.new(container_repository) + def valid_regex? + %w[name_regex_delete name_regex name_regex_keep].each do |param_name| + regex = params[param_name] + ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank? end + true + rescue RegexpError => e + ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id) + false end - def caching_enabled? - result = ::Gitlab::CurrentSettings.current_application_settings.container_registry_expiration_policies_caching && - container_expiration_policy && - older_than.present? - !!result - end - - def max_list_size - ::Gitlab::CurrentSettings.current_application_settings.container_registry_cleanup_tags_service_max_list_size.to_i + def container_expiration_policy + params['container_expiration_policy'] end end end diff --git a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb index 81bb94c867a..e947e9575e2 100644 --- a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb @@ -14,9 +14,6 @@ module Projects end def execute - return error('access denied') unless can_destroy? - return error('invalid regex') unless valid_regex? - with_timeout do |start_time, result| container_repository.each_tags_page(page_size: TAGS_PAGE_SIZE) do |tags| execute_for_tags(tags, result) diff --git a/app/services/projects/container_repository/third_party/cleanup_tags_service.rb b/app/services/projects/container_repository/third_party/cleanup_tags_service.rb new file mode 100644 index 00000000000..c6335629b52 --- /dev/null +++ b/app/services/projects/container_repository/third_party/cleanup_tags_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + module ThirdParty + class CleanupTagsService < CleanupTagsBaseService + def initialize(container_repository:, current_user: nil, params: {}) + super + + @params = params.dup + @counts = { cached_tags_count: 0 } + end + + def execute + tags = container_repository.tags + @counts[:original_size] = tags.size + + filter_out_latest!(tags) + filter_by_name!(tags) + + tags = truncate(tags) + populate_from_cache(tags) + + tags = filter_keep_n(tags) + tags = filter_by_older_than(tags) + + @counts[:before_delete_size] = tags.size + + delete_tags(tags).merge(@counts).tap do |result| + result[:deleted_size] = result[:deleted]&.size + + result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size] + end + end + + private + + def filter_keep_n(tags) + tags, tags_to_keep = partition_by_keep_n(tags) + + cache_tags(tags_to_keep) + + tags + end + + def filter_by_older_than(tags) + tags, tags_to_keep = partition_by_older_than(tags) + + cache_tags(tags_to_keep) + + tags + end + + def pushed_at(tag) + tag.created_at + end + + def truncate(tags) + @counts[:before_truncate_size] = tags.size + @counts[:after_truncate_size] = tags.size + + return tags if max_list_size == 0 + + # truncate the list to make sure that after the #filter_keep_n + # execution, the resulting list will be max_list_size + truncated_size = max_list_size + keep_n_as_integer + + return tags if tags.size <= truncated_size + + tags = tags.sample(truncated_size) + @counts[:after_truncate_size] = tags.size + tags + end + + def populate_from_cache(tags) + @counts[:cached_tags_count] = cache.populate(tags) if caching_enabled? + end + + def cache_tags(tags) + cache.insert(tags, older_than_in_seconds) if caching_enabled? + end + + def cache + strong_memoize(:cache) do + ::Gitlab::ContainerRepository::Tags::Cache.new(container_repository) + end + end + + def caching_enabled? + result = current_application_settings.container_registry_expiration_policies_caching && + container_expiration_policy && + older_than.present? + !!result + end + + def max_list_size + current_application_settings.container_registry_cleanup_tags_service_max_list_size.to_i + end + + def current_application_settings + ::Gitlab::CurrentSettings.current_application_settings + end + end + end + end +end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index f1525ed9763..4e883f682fb 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -134,7 +134,7 @@ module Projects destroy_ci_records! destroy_mr_diff_relations! - destroy_merge_request_diffs! if ::Feature.enabled?(:extract_mr_diff_deletions) + destroy_merge_request_diffs! # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 4979af6dfe1..de7ede4eabf 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -64,7 +64,11 @@ module Projects def add_repository_to_project if project.external_import? && !unknown_url? begin - Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS) + Gitlab::UrlBlocker.validate!( + project.import_url, + schemes: Project::VALID_IMPORT_PROTOCOLS, + ports: Project::VALID_IMPORT_PORTS + ) rescue Gitlab::UrlBlocker::BlockedUrlError => e raise e, s_("ImportProjects|Blocked import URL: %{message}") % { message: e.message } end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index d757b0700b9..f9a2c825608 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -122,7 +122,7 @@ module Projects update_pending_builds if runners_settings_toggled? - publish_event + publish_events end def after_rename_service(project) @@ -212,7 +212,13 @@ module Projects end end - def publish_event + def publish_events + publish_project_archived_event + publish_project_attributed_changed_event + publish_project_features_changed_event + end + + def publish_project_archived_event return unless project.archived_previously_changed? event = Projects::ProjectArchivedEvent.new(data: { @@ -223,6 +229,36 @@ module Projects Gitlab::EventStore.publish(event) end + + def publish_project_attributed_changed_event + changes = @project.previous_changes + + return if changes.blank? + + event = Projects::ProjectAttributesChangedEvent.new(data: { + project_id: @project.id, + namespace_id: @project.namespace_id, + root_namespace_id: @project.root_namespace.id, + attributes: changes.keys + }) + + Gitlab::EventStore.publish(event) + end + + def publish_project_features_changed_event + changes = @project.project_feature.previous_changes + + return if changes.blank? + + event = Projects::ProjectFeaturesChangedEvent.new(data: { + project_id: @project.id, + namespace_id: @project.namespace_id, + root_namespace_id: @project.root_namespace.id, + features: changes.keys + }) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index b7df201824a..01dd6323d94 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -3,10 +3,10 @@ module Releases class CreateService < Releases::BaseService def execute - return error('Access Denied', 403) unless allowed? - return error('You are not allowed to create this tag as it is protected.', 403) unless can_create_tag? - return error('Release already exists', 409) if release - return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any? + return error(_('Access Denied'), 403) unless allowed? + return error(_('You are not allowed to create this tag as it is protected.'), 403) unless can_create_tag? + return error(_('Release already exists'), 409) if release + return error(format(_("Milestone(s) not found: %{milestones}"), milestones: inexistent_milestones.join(', ')), 400) if inexistent_milestones.any? # rubocop:disable Layout/LineLength # should be found before the creation of new tag # because tag creation can spawn new pipeline diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb index 8abf9308689..ff2b3a7bd18 100644 --- a/app/services/releases/destroy_service.rb +++ b/app/services/releases/destroy_service.rb @@ -3,8 +3,8 @@ module Releases class DestroyService < Releases::BaseService def execute - return error('Release does not exist', 404) unless release - return error('Access Denied', 403) unless allowed? + return error(_('Release does not exist'), 404) unless release + return error(_('Access Denied'), 403) unless allowed? if release.destroy success(tag: existing_tag, release: release) diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb index 2e0a2f8488a..b9b2aba9805 100644 --- a/app/services/releases/update_service.rb +++ b/app/services/releases/update_service.rb @@ -31,11 +31,11 @@ module Releases private def validate - return error('Tag does not exist', 404) unless existing_tag - return error('Release does not exist', 404) unless release - return error('Access Denied', 403) unless allowed? - return error('params is empty', 400) if empty_params? - return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any? + return error(_('Tag does not exist'), 404) unless existing_tag + return error(_('Release does not exist'), 404) unless release + return error(_('Access Denied'), 403) unless allowed? + return error(_('params is empty'), 400) if empty_params? + return error(format(_("Milestone(s) not found: %{milestones}"), milestones: inexistent_milestones.join(', ')), 400) if inexistent_milestones.any? # rubocop:disable Layout/LineLength end def allowed? diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index eed03ba22fe..b8a210c0a95 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -13,7 +13,6 @@ module ResourceAccessTokens return error("User does not have permission to create #{resource_type} access token") unless has_permission_to_create? access_level = params[:access_level] || Gitlab::Access::MAINTAINER - return error("Could not provision owner access to project access token") if do_not_allow_owner_access_level_for_project_bot?(access_level) user = create_user @@ -48,9 +47,9 @@ module ResourceAccessTokens end def create_user - # Even project maintainers can create project access tokens, which in turn + # Even project maintainers/owners can create project access tokens, which in turn # creates a bot user, and so it becomes necessary to have `skip_authorization: true` - # since someone like a project maintainer does not inherently have the ability + # since someone like a project maintainer/owner does not inherently have the ability # to create a new user in the system. ::Users::AuthorizedCreateService.new(current_user, default_user_params).execute @@ -108,7 +107,7 @@ module ResourceAccessTokens end def create_membership(resource, user, access_level) - resource.add_member(user, access_level, expires_at: params[:expires_at]) + resource.add_member(user, access_level, current_user: current_user, expires_at: params[:expires_at]) end def log_event(token) @@ -122,12 +121,6 @@ module ResourceAccessTokens def success(access_token) ServiceResponse.success(payload: { access_token: access_token }) end - - def do_not_allow_owner_access_level_for_project_bot?(access_level) - resource.is_a?(Project) && - access_level == Gitlab::Access::OWNER && - !current_user.can?(:manage_owners, resource) - end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index cea7fc5769e..f38522b9764 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -102,6 +102,16 @@ class SearchService end end + def show_elasticsearch_tabs? + # overridden in EE + false + end + + def show_epics? + # overridden in EE + false + end + private def page diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index ddb20a835e1..0fa1bb96b13 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -106,6 +106,8 @@ module Users def build_user_params_for_non_admin @user_params = params.slice(*signup_params) + # if skip_confirmation is set to `true`, devise will set confirmed_at + # see: https://github.com/heartcombo/devise/blob/8593801130f2df94a50863b5db535c272b00efe1/lib/devise/models/confirmable.rb#L156 @user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting if assign_skip_confirmation_from_settings? @user_params[:name] = fallback_name if use_fallback_name? end diff --git a/app/services/users/dismiss_namespace_callout_service.rb b/app/services/users/dismiss_namespace_callout_service.rb deleted file mode 100644 index 51261a93e20..00000000000 --- a/app/services/users/dismiss_namespace_callout_service.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Users - class DismissNamespaceCalloutService < DismissCalloutService - private - - def callout - current_user.find_or_initialize_namespace_callout(params[:feature_name], params[:namespace_id]) - end - end -end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index fe61335f3ed..b1ffd006795 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -62,12 +62,12 @@ module Users # Updates the list of authorizations for the current user. # - # remove - The IDs of the authorization rows to remove. + # remove - The project IDs of the authorization rows to remove. # add - Rows to insert in the form `[{ user_id: user_id, project_id: project_id, access_level: access_level}, ...]` def update_authorizations(remove = [], add = []) log_refresh_details(remove, add) - user.remove_project_authorizations(remove) if remove.any? + ProjectAuthorization.delete_all_in_batches_for_user(user: user, project_ids: remove) if remove.any? ProjectAuthorization.insert_all_in_batches(add) if add.any? # Since we batch insert authorization rows, Rails' associations may get diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index cd2c7402713..e5e5e375198 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -194,7 +194,8 @@ class WebHookService headers = { 'Content-Type' => 'application/json', 'User-Agent' => "GitLab/#{Gitlab::VERSION}", - Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name) + Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name), + Gitlab::WebHooks::GITLAB_INSTANCE_HEADER => Gitlab.config.gitlab.base_url } headers['X-Gitlab-Token'] = Gitlab::Utils.remove_line_breaks(hook.token) if hook.token.present? diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index 5be8aee3ae8..1a40c877bda 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -17,7 +17,7 @@ module WebHooks end def execute - update_hook_failure_state + update_hook_failure_state if WebHook.web_hooks_disable_failed?(hook) log_execution end diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index c2ceb701a2f..ebda043e873 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -2,7 +2,6 @@ module WorkItems class CreateService < Issues::CreateService - include ::Services::ReturnServiceResponses include WidgetableService def initialize(project:, current_user: nil, params: {}, spam_params:, widget_params: {}) @@ -17,11 +16,10 @@ module WorkItems end def execute - unless @current_user.can?(:create_work_item, @project) - return error(_('Operation not allowed'), :forbidden) - end + result = super + return result if result.error? - work_item = super + work_item = result[:issue] if work_item.valid? success(payload(work_item)) @@ -43,6 +41,10 @@ module WorkItems private + def authorization_action + :create_work_item + end + def payload(work_item) { work_item: work_item } end diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index 2deb8c82741..1351445f6f3 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -26,6 +26,17 @@ module WorkItems private + def prepare_update_params(work_item) + execute_widgets( + work_item: work_item, + callback: :prepare_update_params, + widget_params: @widget_params, + service_params: params + ) + + super + end + def before_update(work_item, skip_spam_check: false) execute_widgets(work_item: work_item, callback: :before_update_callback, widget_params: @widget_params) @@ -38,7 +49,7 @@ module WorkItems super end - def after_update(work_item) + def after_update(work_item, old_associations) super GraphqlTriggers.issuable_title_updated(work_item) if work_item.previous_changes.key?(:title) @@ -47,5 +58,13 @@ module WorkItems def payload(work_item) { work_item: work_item } end + + def handle_label_changes(issuable, old_labels) + return false unless super + + Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_labels_changed_action( + author: current_user + ) + end end end diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb index 37ed2bf4b05..1ff03a09f9f 100644 --- a/app/services/work_items/widgets/base_service.rb +++ b/app/services/work_items/widgets/base_service.rb @@ -5,12 +5,13 @@ module WorkItems class BaseService < ::BaseService WidgetError = Class.new(StandardError) - attr_reader :widget, :work_item, :current_user + attr_reader :widget, :work_item, :current_user, :service_params - def initialize(widget:, current_user:) + def initialize(widget:, current_user:, service_params: {}) @widget = widget @work_item = widget.work_item @current_user = current_user + @service_params = service_params end private diff --git a/app/services/work_items/widgets/labels_service/update_service.rb b/app/services/work_items/widgets/labels_service/update_service.rb new file mode 100644 index 00000000000..f00ea5c95ca --- /dev/null +++ b/app/services/work_items/widgets/labels_service/update_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module LabelsService + class UpdateService < WorkItems::Widgets::BaseService + def prepare_update_params(params: {}) + return if params.blank? + + service_params.merge!(params.slice(:add_label_ids, :remove_label_ids)) + end + end + end + end +end diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb index 83dc1030606..b38e7d93eac 100644 --- a/app/uploaders/job_artifact_uploader.rb +++ b/app/uploaders/job_artifact_uploader.rb @@ -3,6 +3,7 @@ class JobArtifactUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern + include ObjectStorage::CDN::Concern UnknownFileLocationError = Class.new(StandardError) diff --git a/app/uploaders/object_storage/cdn.rb b/app/uploaders/object_storage/cdn.rb index 0711ab0bd28..e49e2780147 100644 --- a/app/uploaders/object_storage/cdn.rb +++ b/app/uploaders/object_storage/cdn.rb @@ -10,6 +10,16 @@ module ObjectStorage include Gitlab::Utils::StrongMemoize + UrlResult = Struct.new(:url, :used_cdn) + + def cdn_enabled_url(project, ip_address) + if Feature.enabled?(:ci_job_artifacts_cdn, project) && use_cdn?(ip_address) + UrlResult.new(cdn_signed_url, true) + else + UrlResult.new(url, false) + end + end + def use_cdn?(request_ip) return false unless cdn_options.is_a?(Hash) && cdn_options['provider'] return false unless cdn_provider diff --git a/app/uploaders/object_storage/cdn/google_cdn.rb b/app/uploaders/object_storage/cdn/google_cdn.rb index ea7683f131c..91bad1f8d6b 100644 --- a/app/uploaders/object_storage/cdn/google_cdn.rb +++ b/app/uploaders/object_storage/cdn/google_cdn.rb @@ -19,7 +19,7 @@ module ObjectStorage ip = IPAddr.new(request_ip) - return false if ip.private? + return false if ip.private? || ip.link_local? || ip.loopback? !GoogleIpCache.google_ip?(request_ip) end @@ -41,7 +41,7 @@ module ObjectStorage private def config_valid? - [key_name, decoded_key, cdn_url].all?(&:present?) + [key_name, decoded_key, cdn_url].all?(&:present?) && cdn_url.start_with?('https://') end def key_name diff --git a/app/uploaders/packages/rpm/repository_file_uploader.rb b/app/uploaders/packages/rpm/repository_file_uploader.rb new file mode 100644 index 00000000000..ff7e2bc719a --- /dev/null +++ b/app/uploaders/packages/rpm/repository_file_uploader.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +module Packages + module Rpm + class RepositoryFileUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.packages + + after :store, :schedule_background_upload + + alias_method :upload, :model + + def filename + model.file_name + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + raise ObjectNotReadyError, 'Repository file model not ready' unless model.id + + Gitlab::HashedPath.new( + 'projects', model.project_id, 'rpm', 'repository_files', model.id, + root_hash: model.project_id + ) + end + end + end +end diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json index 3c8035d0dcf..5dcd33a2cf0 100644 --- a/app/validators/json_schemas/build_metadata_secrets.json +++ b/app/validators/json_schemas/build_metadata_secrets.json @@ -24,7 +24,8 @@ }, "additionalProperties": false }, - "^file$": { "type": "boolean" } + "^file$": { "type": "boolean" }, + "^token$": { "type": "string" } }, "additionalProperties": false } diff --git a/app/validators/json_schemas/ci_secure_file_metadata.json b/app/validators/json_schemas/ci_secure_file_metadata.json new file mode 100644 index 00000000000..46a7ff60b8f --- /dev/null +++ b/app/validators/json_schemas/ci_secure_file_metadata.json @@ -0,0 +1,22 @@ +{ + "description": "CI Secure File Metadata", + "type": "object", + "properties": { + "id": { "type": "string" }, + "team_name": { "type": "string" }, + "team_id": { "type": "string" }, + "app_name": { "type": "string" }, + "app_id": { "type": "string" }, + "app_id_prefix": { "type": "string" }, + "xcode_managed": { "type": "boolean" }, + "entitlements": { "type": "object" }, + "devices": { "type": "array" }, + "certificate_ids": { "type": "array" }, + "issuer": { "type": "object" }, + "subject": { "type": "object" } + }, + "additionalProperties": true, + "required": [ + "id" + ] +} diff --git a/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json index 70112d7e414..8e80b52d9b8 100644 --- a/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json +++ b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json @@ -4,7 +4,7 @@ "properties": { "top_n": { "type": "number" }, "version": { "type": "string" }, - "changes": { "type": "array" } + "reviewers": { "type": "array" } }, "additionalProperties": true } diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index c0e42f22119..c091a2180c5 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -17,14 +17,14 @@ .form-group = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light' - = f.number_field :receive_max_input_size, class: 'form-control gl-form-input qa-receive-max-input-size-field', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' } .form-group = f.label :max_export_size, _('Maximum export size (MB)'), class: 'label-light' = f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('Set to 0 for no size limit.') .form-group = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light' - = f.number_field :max_import_size, class: 'form-control gl-form-input qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.') .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' @@ -54,10 +54,10 @@ - dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link } = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after a period of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more.%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe } .form-group - = f.label :deactivate_dormant_users_period, _('Period of inactivity (days)'), class: 'label-light' - = f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '1' + = f.label :deactivate_dormant_users_period, _('Days of inactivity before deactivation'), class: 'label-light' + = f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '90', step: '1' .form-text.text-muted - = _('Period of inactivity before deactivation.') + = _('Must be 90 days or more.') .form-group = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light' @@ -67,6 +67,6 @@ = f.gitlab_ui_checkbox_component :user_show_add_ssh_key_message, _("Inform users without uploaded SSH keys that they can't push over SSH until one is added") = render 'admin/application_settings/invitation_flow_enforcement', form: f - = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f + = render 'admin/application_settings/user_restrictions', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f - = f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 05aea2b343d..f6635ad17ef 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -53,8 +53,7 @@ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.') - - if Feature.enabled?(:enforce_runner_token_expires_at) - #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } + #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 0bb9be497d9..62c61ad356f 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -31,4 +31,4 @@ .form-text.text-muted = _('Only required if not using role instance credentials.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index fd65d4029f5..e0ff1f4be43 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -21,4 +21,4 @@ .form-group = f.gitlab_ui_checkbox_component :user_deactivation_emails_enabled, _('Enable user deactivation emails'), help_text: _('Send emails to users upon account deactivation.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 7919fde631f..a5e10846488 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -47,4 +47,4 @@ = f.text_field :external_authorization_service_default_label, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_url_help_text - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml index e56ba635890..cb8b2d3dfcd 100644 --- a/app/views/admin/application_settings/_floc.html.haml +++ b/app/views/admin/application_settings/_floc.html.haml @@ -19,4 +19,4 @@ .form-group = f.gitlab_ui_checkbox_component :floc_enabled, s_('FloC|Participate in FLoC') - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index ade6dac606a..f459ff5abc4 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-gitaly-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-gitaly-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -18,4 +18,4 @@ .form-text.text-muted = _('Timeout for moderately fast Gitaly operations (in seconds). Provide a value between Default timeout and Fast timeout.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index df534f18bde..09817a9172f 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -26,4 +26,4 @@ = s_('Gitpod|The URL to your Gitpod instance configured to read your GitLab projects, such as https://gitpod.example.com.') - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings') } = s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{link_start}How do I enable it?%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index 21eb4caf579..11ebad07e9a 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -21,4 +21,4 @@ - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements') - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } %span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml index bc4a1577f90..8cb7915f847 100644 --- a/app/views/admin/application_settings/_import_export_limits.html.haml +++ b/app/views/admin/application_settings/_import_export_limits.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-import-export-limits-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-import-export-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -35,4 +35,4 @@ = f.label :group_download_export_limit, _('Maximum group export download requests per minute'), class: 'label-bold' = f.number_field :group_download_export_limit, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index 4362ae9cb9b..01d7bf0af67 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -66,4 +66,4 @@ .form-text.text-muted = html_escape(_("If blank, defaults to %{code_open}Retry later%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml index 431e2a64c46..147aab443b2 100644 --- a/app/views/admin/application_settings/_issue_limits.html.haml +++ b/app/views/admin/application_settings/_issue_limits.html.haml @@ -6,4 +6,4 @@ = f.label :issues_create_limit, _('Maximum number of requests per minute') = f.number_field :issues_create_limit, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_jira_connect_application_key.html.haml b/app/views/admin/application_settings/_jira_connect_application_key.html.haml index e3df408cd4c..b67e7680720 100644 --- a/app/views/admin/application_settings/_jira_connect_application_key.html.haml +++ b/app/views/admin/application_settings/_jira_connect_application_key.html.haml @@ -18,4 +18,4 @@ .form-group = f.label :jira_connect_application_key, s_('JiraConnect|Jira Connect Application ID'), class: 'label-bold' = f.text_field :jira_connect_application_key, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index a6ed48ef4fe..90cb34395d8 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -15,5 +15,5 @@ - time_tracking_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: time_tracking_help_link } = f.gitlab_ui_checkbox_component :time_tracking_limit_to_hours, _('Limit display of time tracking units to hours.'), help_text: _('Display time tracking in issues in total hours only. %{link_start}What is time tracking?%{link_end}').html_safe % { link_start: time_tracking_help_link_start, link_end: '</a>'.html_safe } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_network_rate_limits.html.haml b/app/views/admin/application_settings/_network_rate_limits.html.haml index f1857a9749a..300180f7b9a 100644 --- a/app/views/admin/application_settings/_network_rate_limits.html.haml +++ b/app/views/admin/application_settings/_network_rate_limits.html.haml @@ -30,4 +30,4 @@ = f.label :"throttle_authenticated_#{setting_fragment}_period_in_seconds", _('Authenticated API rate limit period in seconds'), class: 'label-bold' = f.number_field :"throttle_authenticated_#{setting_fragment}_period_in_seconds", class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml index 40760b3c45e..99cf0ebc669 100644 --- a/app/views/admin/application_settings/_note_limits.html.haml +++ b/app/views/admin/application_settings/_note_limits.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-note-limits-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-note-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -12,4 +12,4 @@ = _('List of users who are allowed to exceed the rate limit. Example: username1, username2') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index bacfe056683..3505a3bf3ee 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -22,4 +22,4 @@ s_('OutboundRequests|Enforce DNS rebinding attack protection'), help_text: _('OutboundRequests|Resolve IP addresses once and uses them to submit requests.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index 4bdfa5bfe83..3506038ca68 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -20,12 +20,12 @@ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.mb-3 - @plans.each_with_index do |plan, index| %li - = link_to admin_plan_limits_path(anchor: 'js-package-settings'), data: { target: "div#plan#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do + = link_to admin_plan_limits_path(anchor: 'js-package-settings'), data: { target: "div#plan-package#{index}", action: "plan#{index}", toggle: 'tab'}, class: index == 0 ? 'active': '' do = plan.name.capitalize .tab-content - @plans.each_with_index do |plan, index| - .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' } - = form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f| + .tab-pane{ :id => "plan-package#{index}", class: index == 0 ? 'active': '' } + = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f| = form_errors(plan) %fieldset = f.hidden_field(:plan_id, value: plan.id) @@ -53,4 +53,4 @@ .form-group = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold' = f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input' - = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-confirm' + = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index cf43d3ddeca..97d9426581e 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -46,4 +46,4 @@ = f.gitlab_ui_checkbox_component :lets_encrypt_terms_of_service_accepted, s_("AdminSettings|I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF).").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index e0ba8d93fbd..86a01e1785e 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -23,4 +23,4 @@ .form-text.text-muted = _('Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3).') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index 4e37c4c3c98..d4f6d84ea74 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -10,4 +10,4 @@ = f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold' = f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm qa-save-changes-button' + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_pipeline_limits.html.haml b/app/views/admin/application_settings/_pipeline_limits.html.haml index e93823172db..b7dffe63777 100644 --- a/app/views/admin/application_settings/_pipeline_limits.html.haml +++ b/app/views/admin/application_settings/_pipeline_limits.html.haml @@ -6,4 +6,4 @@ = f.label :pipeline_limit_per_project_user_sha, _('Maximum number of requests per minute') = f.number_field :pipeline_limit_per_project_user_sha, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index 982531e9a2f..3db1272c77b 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -18,4 +18,4 @@ .form-text.text-muted Only track method calls that take longer to complete than the given duration. - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml index 1f3f67c71c7..3a7a951d137 100644 --- a/app/views/admin/application_settings/_protected_paths.html.haml +++ b/app/views/admin/application_settings/_protected_paths.html.haml @@ -21,4 +21,4 @@ - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link } = _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URLs%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index 856db32e088..6a8ef86a56e 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -34,4 +34,4 @@ = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable container expiration caching."), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index dad8d5f3fae..869f26ceb10 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -9,4 +9,4 @@ = render_if_exists 'admin/application_settings/mirror_settings', form: f - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 9e7f2812d64..12dd8816783 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -27,4 +27,4 @@ = storage_form.text_field storage, class: 'form-text-input' = storage_form.label storage, storage, class: 'label-bold form-check-label' %br - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml index cfd34f6ca15..20164cfe88d 100644 --- a/app/views/admin/application_settings/_sentry.html.haml +++ b/app/views/admin/application_settings/_sentry.html.haml @@ -17,4 +17,4 @@ = f.label :sentry_environment, _('Environment'), class: 'label-light' = f.text_field :sentry_environment, class: 'form-control gl-form-input', placeholder: Rails.env - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml index eaf4bbf4702..068a8155450 100644 --- a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml +++ b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-sidekiq-job-limits-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-sidekiq-job-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -18,4 +18,4 @@ .form-text.text-muted = _("Threshold in bytes at which to reject Sidekiq jobs. Set this to 0 to if you don't want to limit Sidekiq jobs.") - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 48f0b9b2c31..3e2551d753a 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -55,4 +55,4 @@ = f.label :sign_in_text, _('Sign-in text'), class: 'label-bold' = f.text_area :sign_in_text, class: 'form-control gl-form-input', rows: 4 %span.form-text.text-muted#home_help_block= _("Add text to the sign-in page. Markdown enabled.") - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 8684b909853..4e7d9b8ab21 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -31,4 +31,4 @@ .form-text.text-muted = _('The Snowplow cookie domain.') - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index 9e99b496ad0..b56ca12baec 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -29,4 +29,4 @@ = f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|https://sourcegraph.example.com') .form-text.text-muted = s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.') - = f.submit s_('SourcegraphAdmin|Save changes'), class: 'gl-button btn btn-confirm' + = f.submit s_('SourcegraphAdmin|Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index a4b6e061c43..8da441d5245 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -11,4 +11,4 @@ .form-text.text-muted = _("Markdown supported.") = link_to _('What is Markdown?'), help_page_path('user/markdown.md'), target: '_blank', rel: 'noopener noreferrer' - = f.submit _("Save changes"), class: "gl-button btn btn-confirm" + = f.submit _("Save changes"), pajamas_button: true diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index 20a60ac870a..ed809c6db52 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -16,4 +16,4 @@ = f.gitlab_ui_checkbox_component :hide_third_party_offers, _('Do not display content for customer experience improvement and offers from third parties') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 046b59dbd18..2eda3eab8c7 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -50,4 +50,4 @@ %li = s_('AdminSettings|Restrict group access by IP address. %{link_start}Learn more%{link_end}.').html_safe % { link_start: restrict_ip_link, link_end: link_end } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml new file mode 100644 index 00000000000..de8faa6705f --- /dev/null +++ b/app/views/admin/application_settings/_user_restrictions.html.haml @@ -0,0 +1,6 @@ +- form = local_assigns.fetch(:form) + +.form-group + = label_tag _('User restrictions') + = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: form + = form.gitlab_ui_checkbox_component :can_create_group, _("Allow users to create top-level groups") diff --git a/app/views/admin/application_settings/_users_api_limits.html.haml b/app/views/admin/application_settings/_users_api_limits.html.haml index 3918c76b12c..ca6f1113c4a 100644 --- a/app/views/admin/application_settings/_users_api_limits.html.haml +++ b/app/views/admin/application_settings/_users_api_limits.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-users-api-limits-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-users-api-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -11,4 +11,4 @@ .form-text.text-muted{ id: 'users-api-limit-users-allowlist-field-description' } = _('List of users who are allowed to exceed the rate limit. Example: username1, username2') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml index 3248969ca16..986402ad5f1 100644 --- a/app/views/admin/application_settings/_whats_new.html.haml +++ b/app/views/admin/application_settings/_whats_new.html.haml @@ -5,4 +5,4 @@ .gl-mb-4 = f.gitlab_ui_radio_component :whats_new_variant, variant, whats_new_variants_label(variant), help_text: whats_new_variants_description(variant) - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/appearances/preview_sign_in.html.haml b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml index 2e4ab714048..1c2350e2835 100644 --- a/app/views/admin/application_settings/appearances/preview_sign_in.html.haml +++ b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml @@ -9,5 +9,5 @@ = label_tag :password = password_field_tag :password, nil, disabled: true, class: "form-control gl-form-input bottom", title: title .form-group - = button_tag _("Sign in"), disabled: true, class: "btn gl-button btn-confirm", type: "button", title: title - + = render Pajamas::ButtonComponent.new(variant: :confirm, disabled: true, button_options: { title: title }) do + = _("Sign in") diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index f0f7e6868da..b7244c45871 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -10,7 +10,7 @@ %p.settings-message.text-center - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables') } = s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - #js-instance-variables{ data: { endpoint: admin_ci_variables_path, group: 'true', maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} } + #js-instance-variables{ data: { endpoint: admin_ci_variables_path, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} } %section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) } .settings-header @@ -38,12 +38,11 @@ .settings-content = render 'registry' -- if Feature.enabled?(:runner_registration_control) - %section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = s_('Runners|Runner registration') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded_by_default? ? 'Collapse' : 'Expand' - .settings-content - = render 'runner_registrars_form' +%section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('Runners|Runner registration') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded_by_default? ? 'Collapse' : 'Expand' + .settings-content + = render 'runner_registrars_form' diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index cd63873a893..ec5d1ef4a34 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -24,6 +24,8 @@ .settings-content = render 'account_and_limit' += render_if_exists 'admin/application_settings/free_user_cap' + %section.settings.as-diff-limits.no-animate#js-merge-request-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only @@ -102,7 +104,7 @@ = f.gitlab_ui_checkbox_component :web_ide_clientside_preview_enabled, s_('IDE|Live Preview'), help_text: s_('Preview JavaScript projects in the Web IDE with CodeSandbox Live Preview. %{link_start}Learn more.%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true = render_if_exists 'admin/application_settings/maintenance_mode_settings_form' = render 'admin/application_settings/gitpod' diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml index 25c8bd12345..06bb9df84c4 100644 --- a/app/views/admin/application_settings/service_usage_data.html.haml +++ b/app/views/admin/application_settings/service_usage_data.html.haml @@ -5,25 +5,26 @@ - @content_class = "limit-container-width" unless fluid_layout - payload_class = 'js-service-ping-payload' -%h3= name +%section.js-search-settings-section + %h3= name -- if @service_ping_data_present - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } } ) do - = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) - %span.js-text.gl-display-inline= _('Preview payload') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } } ) do - = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) - %span.js-text.gl-display-inline= _('Download payload') - %pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } -- else - = render Pajamas::AlertComponent.new(variant: :warning, - dismissible: false, - title: _('Service Ping payload not found in the application cache')) do |c| + - if @service_ping_data_present + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } } ) do + = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) + %span.js-text.gl-display-inline= _('Preview payload') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } } ) do + = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) + %span.js-text.gl-display-inline= _('Download payload') + %pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } + - else + = render Pajamas::AlertComponent.new(variant: :warning, + dismissible: false, + title: _('Service Ping payload not found in the application cache')) do |c| - = c.body do - - enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics') - - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url } - - generate_manually_link_url = help_page_path('administration/troubleshooting/gitlab_rails_cheat_sheet', anchor: 'generate-service-ping') - - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url } + = c.body do + - enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics') + - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url } + - generate_manually_link_url = help_page_path('administration/troubleshooting/gitlab_rails_cheat_sheet', anchor: 'generate-service-ping') + - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url } - = html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe } + = html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe } diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index fd73d4c5671..83347034cc5 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -36,5 +36,5 @@ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes, f: f .form-actions - = f.submit _('Save application'), class: "gl-button btn btn-confirm wide" + = f.submit _('Save application'), pajamas_button: true = link_to _('Cancel'), admin_applications_path, class: "gl-button btn btn-default btn-cancel" diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index 6d2cab06010..15ce9b692f0 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -7,5 +7,4 @@ = html_escape(_('GitLab uses %{linkStart}Sidekiq%{linkEnd} to process background jobs')) % { linkStart: sidekiq_link_start, linkEnd: '</a>'.html_safe } %hr -.card.gl-rounded-0 - %iframe{ src: sidekiq_path, width: '100%', height: 970, style: "border: 0" } +%iframe{ src: sidekiq_path, width: '100%', height: 970, style: "border: 0" } diff --git a/app/views/admin/broadcast_messages/_table.html.haml b/app/views/admin/broadcast_messages/_table.html.haml new file mode 100644 index 00000000000..c5cd333f9dd --- /dev/null +++ b/app/views/admin/broadcast_messages/_table.html.haml @@ -0,0 +1,38 @@ +- targeted_broadcast_messages_enabled = Feature.enabled?(:role_targeted_broadcast_messages) + +- if @broadcast_messages.any? + .table-responsive + %table.table.b-table.gl-table + %thead + %tr + %th= _('Status') + %th= _('Preview') + %th= _('Starts') + %th= _('Ends') + - if targeted_broadcast_messages_enabled + %th= _('Target roles') + %th= _('Target Path') + %th= _('Type') + %th + %tbody + - @broadcast_messages.each do |message| + %tr + %td + = broadcast_message_status(message) + %td + = broadcast_message(message, preview: true) + %td + = message.starts_at + %td + = message.ends_at + - if targeted_broadcast_messages_enabled + %td + = target_access_levels_display(message.target_access_levels) + %td + = message.target_path + %td + = message.broadcast_type.capitalize + %td.gl-white-space-nowrap< + = link_to sprite_icon('pencil', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button' + = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger gl-ml-3' + = paginate @broadcast_messages, theme: 'gitlab' diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index 46924393a27..7559365e49a 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -1,49 +1,30 @@ - breadcrumb_title _("Messages") - page_title _("Broadcast Messages") -- targeted_broadcast_messages_enabled = Feature.enabled?(:role_targeted_broadcast_messages) +- vue_app_enabled = Feature.enabled?(:vue_broadcast_messages, current_user) %h1.page-title.gl-font-size-h-display = _('Broadcast Messages') %p.light = _('Use banners and notifications to notify your users about scheduled maintenance, recent upgrades, and more.') -= render 'form' - -%br.clearfix - -- if @broadcast_messages.any? - .table-responsive - %table.table.b-table.gl-table - %thead - %tr - %th= _('Status') - %th= _('Preview') - %th= _('Starts') - %th= _('Ends') - - if targeted_broadcast_messages_enabled - %th= _('Target roles') - %th= _('Target Path') - %th= _('Type') - %th - %tbody - - @broadcast_messages.each do |message| - %tr - %td - = broadcast_message_status(message) - %td - = broadcast_message(message, preview: true) - %td - = message.starts_at - %td - = message.ends_at - - if targeted_broadcast_messages_enabled - %td - = target_access_levels_display(message.target_access_levels) - %td - = message.target_path - %td - = message.broadcast_type.capitalize - %td.gl-white-space-nowrap< - = link_to sprite_icon('pencil', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button' - = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger gl-ml-3' - = paginate @broadcast_messages, theme: 'gitlab' +- if vue_app_enabled + #js-broadcast-messages{ data: { + page: params[:page] || 1, + messages_count: @broadcast_messages.total_count, + messages: @broadcast_messages.map { |message| { + id: message.id, + status: broadcast_message_status(message), + preview: broadcast_message(message, preview: true), + starts_at: message.starts_at.to_s, + ends_at: message.ends_at.to_s, + target_roles: target_access_levels_display(message.target_access_levels), + target_path: message.target_path, + type: message.broadcast_type.capitalize, + edit_path: edit_admin_broadcast_message_path(message), + delete_path: admin_broadcast_message_path(message) + '.js' + } }.to_json + } } +- else + = render 'form' + %br.clearfix + = render 'table' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 271f89a6b08..ccea1714973 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -35,7 +35,7 @@ = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-default") = c.footer do .d-flex.align-items-center - = link_to(s_('AdminArea|View latest projects'), admin_projects_path) + = link_to(s_('AdminArea|View latest projects'), admin_projects_path(sort: 'created_desc')) = sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2') .col-md-4.gl-mb-6 = render Pajamas::CardComponent.new(**component_params) do |c| @@ -71,7 +71,7 @@ = link_to(s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-default") = c.footer do .d-flex.align-items-center - = link_to(s_('AdminArea|View latest groups'), admin_groups_path) + = link_to(s_('AdminArea|View latest groups'), admin_groups_path(sort: 'created_desc')) = sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2') .row .col-md-4.gl-mb-6 diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml index 12a1c0c3de2..acdf503727d 100644 --- a/app/views/admin/deploy_keys/edit.html.haml +++ b/app/views/admin/deploy_keys/edit.html.haml @@ -3,8 +3,8 @@ %hr %div - = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| + = gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm' + = f.submit _('Save changes'), pajamas_button: true = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel' diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml index 74882900756..a03d6cb5a94 100644 --- a/app/views/admin/deploy_keys/new.html.haml +++ b/app/views/admin/deploy_keys/new.html.haml @@ -3,8 +3,9 @@ %hr %div - = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| + = gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions - = f.submit 'Create', class: 'btn gl-button btn-confirm', data: { qa_selector: "add_deploy_key_button" } - = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel' + = f.submit 'Create', data: { qa_selector: "add_deploy_key_button" }, pajamas_button: true + = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do + = _('Cancel') diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 69e9e4260b4..7adba0d023b 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -35,10 +35,10 @@ = c.body do = render 'shared/group_tips' .gl-mt-5 - = f.submit _('Create group'), class: "gl-button btn btn-confirm" + = f.submit _('Create group'), pajamas_button: true = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel" - else .gl-mt-5 - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true = link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel" diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index c27ff348f59..a1afb1ddbfa 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -1,7 +1,6 @@ - group = local_assigns.fetch(:group) -- css_class = "gl-display-flex!#{' no-description' if group.description.blank?}" -%li.group-row.gl-py-3.gl-align-items-center{ class: css_class, data: { qa_selector: 'group_row_content' } } +%li.group-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'group_row_content' } } .avatar-container.rect-avatar.s40.gl-flex-shrink-0 = group_icon(group, class: "avatar s40") diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index a57d3170cbd..6d370919460 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -13,123 +13,112 @@ %hr .row .col-md-6 - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c| + - c.header do = _('Group info:') - %ul.content-list - %li - .avatar-container.rect-avatar.s60 - = group_icon(@group, class: "avatar s60") - %li - %span.light= _('Name:') - %strong - = link_to @group.name, group_path(@group) - %li - %span.light= _('Path:') - %strong - = @group.path - - %li - %span.light= _('Description:') - %strong - = @group.description - - %li - %span.light= _('Visibility level:') - %strong - = visibility_level_label(@group.visibility_level) - - %li - %span.light= _('Created on:') - %strong - = @group.created_at.to_s(:medium) - - %li - %span.light= _('ID:') - %strong - = @group.id - - = render_if_exists 'admin/namespace_plan_info', namespace: @group - - %li - = render 'shared/storage_counter_statistics', storage_size: @group.storage_size, storage_details: @group - - %li - %span.light= _('Group Git LFS status:') - %strong - = group_lfs_status(@group) - = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index') - - = render_if_exists 'namespaces/shared_runner_status', namespace: @group - = render_if_exists 'namespaces/additional_minutes_status', namespace: @group + - c.body do + %ul.content-list.content-list-items-padding + %li + .avatar-container.rect-avatar.s60 + = group_icon(@group, class: "avatar s60") + %li + %span.light= _('Name:') + %strong + = link_to @group.name, group_path(@group) + %li + %span.light= _('Path:') + %strong + = @group.path + + %li + %span.light= _('Description:') + %strong + = @group.description + + %li + %span.light= _('Visibility level:') + %strong + = visibility_level_label(@group.visibility_level) + + %li + %span.light= _('Created on:') + %strong + = @group.created_at.to_s(:medium) + + %li + %span.light= _('ID:') + %strong + = @group.id + + = render_if_exists 'admin/namespace_plan_info', namespace: @group + + %li + = render 'shared/storage_counter_statistics', storage_size: @group.storage_size, storage_details: @group + + %li + %span.light= _('Group Git LFS status:') + %strong + = group_lfs_status(@group) + = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index') + + = render_if_exists 'namespaces/shared_runner_status', namespace: @group + = render_if_exists 'namespaces/additional_minutes_status', namespace: @group = render 'shared/custom_attributes', custom_attributes: @group.custom_attributes = render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c| + - c.header do = _('Projects') = gl_badge_tag @group.projects.count - %ul.content-list - - @projects.each do |project| - %li - %strong - = link_to project.full_name, [:admin, project] - = gl_badge_tag storage_counter(project.statistics.storage_size) - %span.float-right.light - %span.monospace= project.full_path + '.git' - - unless @projects.size < Kaminari.config.default_per_page - .card-footer - = paginate @projects, param_name: 'projects_page', theme: 'gitlab' - - - shared_projects = @group.shared_projects.sort_by(&:name) - - unless shared_projects.empty? - .card - .card-header - = _('Projects shared with %{group_name}') % { group_name: @group.name } - = gl_badge_tag shared_projects.size - %ul.content-list - - shared_projects.each do |project| + - c.body do + %ul.content-list.content-list-items-padding + - @projects.each do |project| %li %strong = link_to project.full_name, [:admin, project] = gl_badge_tag storage_counter(project.statistics.storage_size) %span.float-right.light %span.monospace= project.full_path + '.git' + - unless @projects.size < Kaminari.config.default_per_page + - c.footer do + = paginate @projects, param_name: 'projects_page', theme: 'gitlab' + + - shared_projects = @group.shared_projects.sort_by(&:name) + - unless shared_projects.empty? + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c| + - c.header do + = _('Projects shared with %{group_name}') % { group_name: @group.name } + = gl_badge_tag shared_projects.size + - c.body do + %ul.content-list.content-list-items-padding + - shared_projects.each do |project| + %li + %strong + = link_to project.full_name, [:admin, project] + = gl_badge_tag storage_counter(project.statistics.storage_size) + %span.float-right.light + %span.monospace= project.full_path + '.git' .col-md-6 = render 'shared/admin/admin_note' - if can?(current_user, :admin_group_member, @group) - .card - .card-header - = _('Add user(s) to the group:') - .card-body.form-holder - %p.light - - help_link_open = '<strong><a href="%{help_url}">'.html_safe % { help_url: help_page_url("user/permissions") } - = html_escape(_('Read more about project permissions %{help_link_open}here%{help_link_close}')) % { help_link_open: help_link_open, help_link_close: '</a></strong>'.html_safe } - - = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do - %div - = users_select_tag(:user_id, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all) - .gl-mt-3 - = select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2" - %hr - = button_tag _('Add users to group'), class: "gl-button btn btn-confirm" = render 'shared/members/requests', membership_source: @group, group: @group, requesters: @requesters, force_mobile_view: true - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c| + - c.header do = html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe } = gl_badge_tag @group.users_count = render 'shared/members/manage_access_button', path: group_group_members_path(@group) - %ul.content-list.group-users-list.content-list.members-list - = render partial: 'shared/members/member', - collection: @members, as: :member, - locals: { membership_source: @group, - group: @group, - current_user_is_group_owner: current_user_is_group_owner } + - c.body do + %ul.content-list.group-users-list.members-list + = render partial: 'shared/members/member', + collection: @members, as: :member, + locals: { membership_source: @group, + group: @group, + current_user_is_group_owner: current_user_is_group_owner } - unless @members.size < Kaminari.config.default_per_page - .card-footer + - c.footer do = paginate @members, param_name: 'members_page', theme: 'gitlab' diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 224afbff39a..14d37b77a41 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -11,7 +11,7 @@ = gitlab_ui_form_for @hook, as: :hook, url: admin_hook_path do |f| = render partial: 'form', locals: { form: f, hook: @hook } .form-actions - %span>= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3' + %span>= f.submit _('Save changes'), class: 'gl-mr-3', pajamas_button: true = render 'shared/web_hooks/test_button', hook: @hook = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' } diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index f23d77c8da5..d4aeb8dc7e8 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -7,7 +7,7 @@ .col-lg-8.gl-mb-3 = gitlab_ui_form_for @hook, as: :hook, url: admin_hooks_path do |f| = render partial: 'form', locals: { form: f, hook: @hook } - = f.submit _('Add system hook'), class: 'btn gl-button btn-confirm' + = f.submit _('Add system hook'), pajamas_button: true = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 2c526bb38d8..8cf1d8555ce 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -8,12 +8,10 @@ .row.gl-mt-3 .col-lg-12 - - if @new_impersonation_token - = render 'shared/access_tokens/created_container', - type: type, - new_token_value: @new_impersonation_token + #js-new-access-token-app{ data: { access_token_type: type } } = render 'shared/access_tokens/form', + ajax: true, type: type, title: _('Add an impersonation token'), path: admin_user_impersonation_tokens_path, @@ -22,9 +20,4 @@ scopes: @scopes, help_path: help_page_path('api/index', anchor: 'impersonation-tokens') - = render 'shared/access_tokens/table', - type: type, - type_plural: type_plural, - impersonation: true, - active_tokens: @active_impersonation_tokens, - revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) } + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_impersonation_tokens.to_json, information: _("To see all the user's personal access tokens you must impersonate them first.") } } diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index f56b77813b5..c7c30673d74 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -1,32 +1,33 @@ .js-projects-list-holder - if @projects.any? - %ul.projects-list.content-list.admin-projects + %ul.content-list - @projects.each do |project| - %li.project-row{ class: ('no-description' if project.description.blank?) } - .controls - = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button btn-default" - %button.delete-project-button.gl-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } } - = s_('AdminProjects|Delete') + %li.project-row.gl-align-items-center{ class: 'gl-display-flex!' } + .avatar-container.rect-avatar.s40.gl-flex-shrink-0 + = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) + .gl-min-w-0.gl-flex-grow-1 + .title + = link_to(admin_project_path(project)) do + %span.project-full-name + %span.namespace-name + - if project.namespace + = project.namespace.human_name + \/ + %span.project-name + = project.name - .stats + - if project.description.present? + .description + = markdown_field(project, :description) + .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex = gl_badge_tag storage_counter(project.statistics&.storage_size) = render_if_exists 'admin/projects/archived', project: project - .title - = link_to(admin_project_path(project)) do - .dash-project-avatar - .avatar-container.rect-avatar.s40 - = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) - %span.project-full-name - %span.namespace-name - - if project.namespace - = project.namespace.human_name - \/ - %span.project-name - = project.name - - if project.description.present? - .description - = markdown_field(project, :description) + .controls.gl-flex-shrink-0.gl-ml-5 + = render Pajamas::ButtonComponent.new(href: edit_project_path(project), button_options: { id: dom_id(project, :edit) }) do + = s_('Edit') + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { class: 'delete-project-button', data: { delete_project_url: admin_project_path(project), project_name: project.name } } ) do + = s_('AdminProjects|Delete') = paginate @projects, theme: 'gitlab' - else diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index eabb7e51227..a60c3996cf2 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -2,7 +2,6 @@ - add_to_breadcrumbs _("Projects"), admin_projects_path - breadcrumb_title @project.full_name - page_title @project.full_name, _("Projects") -- @content_class = "admin-projects" - current_user_is_group_owner = @group && @group.has_owner?(current_user) %h1.page-title.gl-font-size-h-display diff --git a/app/views/admin/users/_projects.html.haml b/app/views/admin/users/_projects.html.haml index a9f5c560b41..3ccf3ef4f2a 100644 --- a/app/views/admin/users/_projects.html.haml +++ b/app/views/admin/users/_projects.html.haml @@ -1,13 +1,17 @@ - if local_assigns.has_key?(:contributed_projects) && contributed_projects.present? - .card.contributed-projects - .card-header= _('Projects contributed to') - = render 'shared/projects/list', - projects: contributed_projects.sort_by(&:star_count).reverse, - projects_limit: 5, stars: true, avatar: false + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c| + - c.header do + = _('Projects contributed to') + - c.body do + = render 'shared/projects/list', + projects: contributed_projects.sort_by(&:star_count).reverse, + projects_limit: 5, stars: true, avatar: false - if local_assigns.has_key?(:projects) && projects.present? - .card - .card-header= _('Personal projects') - = render 'shared/projects/list', - projects: projects.sort_by(&:star_count).reverse, - projects_limit: 10, stars: true, avatar: false + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c| + - c.header do + = _('Personal projects') + - c.body do + = render 'shared/projects/list', + projects: projects.sort_by(&:star_count).reverse, + projects_limit: 10, stars: true, avatar: false diff --git a/app/views/admin/users/_user_detail_note.html.haml b/app/views/admin/users/_user_detail_note.html.haml index cc4827327c9..c8625833a70 100644 --- a/app/views/admin/users/_user_detail_note.html.haml +++ b/app/views/admin/users/_user_detail_note.html.haml @@ -1,7 +1,7 @@ - if @user.note.present? - text = @user.note - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-pb-0'}) do |c| + - c.header do = _('Admin Note') - .card-body + - c.body do %p= text diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index 2f6c08f123e..ff87cf8f866 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -4,20 +4,22 @@ = render 'admin/users/head' - if @user.groups.any? - .card - .card-header= _('Groups') - %ul.hover-list - - @user.group_members.includes(:source).each do |group_member| # rubocop: disable CodeReuse/ActiveRecord - - group = group_member.group - %li.group_member - %strong= link_to group.name, admin_group_path(group) - – access to - #{pluralize(group.projects.count, 'project')} - .float-right - %span.light.vertical-align-middle= group_member.human_access - - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do - = sprite_icon('remove', size: 16, css_class: 'gl-icon') + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0 gl-px-0'}) do |c| + - c.header do + = _('Groups') + - c.body do + %ul.hover-list + - @user.group_members.includes(:source).each do |group_member| # rubocop: disable CodeReuse/ActiveRecord + - group = group_member.group + %li.group_member + %strong= link_to group.name, admin_group_path(group) + – access to + #{pluralize(group.projects.count, 'project')} + .float-right + %span.light.vertical-align-middle= group_member.human_access + - unless group_member.owner? + = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do + = sprite_icon('remove', size: 16, css_class: 'gl-icon') .row .col-md-6 @@ -28,23 +30,25 @@ .col-md-6 - .card - .card-header= _('Joined projects (%{projects_count})') % { projects_count: @joined_projects.count } - %ul.hover-list - - @joined_projects.sort_by(&:full_name).each do |project| - - member = project.team.find_member(@user.id) - %li.project_member - .list-item-name - = link_to admin_project_path(project), class: dom_class(project) do - = project.full_name + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0 gl-px-0'}) do |c| + - c.header do + = _('Joined projects (%{projects_count})') % { projects_count: @joined_projects.count } + - c.body do + %ul.hover-list + - @joined_projects.sort_by(&:full_name).each do |project| + - member = project.team.find_member(@user.id) + %li.project_member + .list-item-name + = link_to admin_project_path(project), class: dom_class(project) do + = project.full_name - - if member - .float-right - - if member.owner? - %span.light= _('Owner') - - else - %span.light.vertical-align-middle= member.human_access + - if member + .float-right + - if member.owner? + %span.light= _('Owner') + - else + %span.light.vertical-align-middle= member.human_access - - if member.respond_to? :project - = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do - = sprite_icon('remove', size: 16, css_class: 'gl-icon') + - if member.respond_to? :project + = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do + = sprite_icon('remove', size: 16, css_class: 'gl-icon') diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 9197d6684e0..7edea81a123 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -5,138 +5,140 @@ .row .col-md-6 - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-2'}) do |c| + - c.header do = @user.name - %ul.content-list - %li - = image_tag avatar_icon_for_user(@user, 60, current_user: current_user), class: "avatar s60" - %li - %span.light= _('Profile page:') - %strong - = link_to user_path(@user) do - = @user.username + - c.body do + %ul.content-list + %li + = render Pajamas::AvatarComponent.new(@user, size: 64, class: 'gl-mr-3') + %li + %span.light= _('Profile page:') + %strong + = link_to user_path(@user) do + = @user.username -# Rendered on mobile only so order of cards can be different on desktop vs mobile .gl-md-display-none = render 'admin/users/profile', user: @user = render 'admin/users/user_detail_note' - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-2'}) do |c| + - c.header do = _('Account:') - %ul.content-list - %li - %span.light= _('Name:') - %strong= @user.name - %li - %span.light= _('Username:') - %strong - = @user.username - %li - %span.light= _('Email:') - %strong - = render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? } - - @user.emails.reject(&:user_primary_email?).each do |email| - %li - %span.light= _('Secondary email:') + - c.body do + %ul.content-list + %li + %span.light= _('Name:') + %strong= @user.name + %li + %span.light= _('Username:') %strong - = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } - = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do - = sprite_icon('close', size: 16, css_class: 'gl-icon') - %li - %span.light ID: - %strong{ data: { qa_selector: 'user_id_content' } } - = @user.id - %li - %span.light= _('Namespace ID:') - %strong - = @user.namespace_id - - %li.two-factor-status - %span.light= _('Two-factor Authentication:') - %strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' } - - if @user.two_factor_enabled? - = _('Enabled') - = link_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication') - - else - = _('Disabled') - - = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace - - %li - %span.light= _('External User:') - %strong - = @user.external? ? _('Yes') : _('No') - - = render_if_exists 'admin/users/provisioned_by', user: @user - - %li - %span.light= _('Can create groups:') - %strong - = @user.can_create_group ? _('Yes') : _('No') - %li - %span.light= _('Personal projects limit:') - %strong - = @user.projects_limit - %li - %span.light= _('Member since:') - %strong - = @user.created_at.to_s(:medium) - - if @user.confirmed_at - %li - %span.light= _('Confirmed at:') + = @user.username + %li + %span.light= _('Email:') %strong - = @user.confirmed_at.to_s(:medium) - - else + = render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? } + - @user.emails.reject(&:user_primary_email?).each do |email| + %li + %span.light= _('Secondary email:') + %strong + = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } + = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do + = sprite_icon('close', size: 16, css_class: 'gl-icon') %li - %span.ligh= _('Confirmed:') - %strong.cred - = _('No') + %span.light ID: + %strong{ data: { qa_selector: 'user_id_content' } } + = @user.id + %li + %span.light= _('Namespace ID:') + %strong + = @user.namespace_id - %li - %span.light= _('Current sign-in IP:') - %strong - = @user.current_sign_in_ip || _('never') + %li.two-factor-status + %span.light= _('Two-factor Authentication:') + %strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' } + - if @user.two_factor_enabled? + = _('Enabled') + = link_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication') + - else + = _('Disabled') - %li - %span.light= _('Current sign-in at:') - %strong - = @user.current_sign_in_at&.to_s(:medium) || _('never') + = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace - %li - %span.light= _('Last sign-in IP:') - %strong - = @user.last_sign_in_ip || _('never') + %li + %span.light= _('External User:') + %strong + = @user.external? ? _('Yes') : _('No') - %li - %span.light= _('Last sign-in at:') - %strong - = @user.last_sign_in_at&.to_s(:medium) || _('never') + = render_if_exists 'admin/users/provisioned_by', user: @user - %li - %span.light= _('Sign-in count:') - %strong - = @user.sign_in_count + %li + %span.light= _('Can create groups:') + %strong + = @user.can_create_group ? _('Yes') : _('No') + %li + %span.light= _('Personal projects limit:') + %strong + = @user.projects_limit + %li + %span.light= _('Member since:') + %strong + = @user.created_at.to_s(:medium) + - if @user.confirmed_at + %li + %span.light= _('Confirmed at:') + %strong + = @user.confirmed_at.to_s(:medium) + - else + %li + %span.ligh= _('Confirmed:') + %strong.cred + = _('No') - %li - %span.light= _("Highest role:") - %strong - = Gitlab::Access.human_access_with_none(@user.highest_role) + %li + %span.light= _('Current sign-in IP:') + %strong + = @user.current_sign_in_ip || _('never') - = render_if_exists 'admin/users/using_license_seat', user: @user + %li + %span.light= _('Current sign-in at:') + %strong + = @user.current_sign_in_at&.to_s(:medium) || _('never') - - if @user.ldap_user? %li - %span.light= _('LDAP uid:') + %span.light= _('Last sign-in IP:') %strong - = @user.ldap_identity.extern_uid + = @user.last_sign_in_ip || _('never') - - if @user.created_by %li - %span.light= _('Created by:') + %span.light= _('Last sign-in at:') %strong - = link_to @user.created_by.name, [:admin, @user.created_by] + = @user.last_sign_in_at&.to_s(:medium) || _('never') + + %li + %span.light= _('Sign-in count:') + %strong + = @user.sign_in_count + + %li + %span.light= _("Highest role:") + %strong + = Gitlab::Access.human_access_with_none(@user.highest_role) + + = render_if_exists 'admin/users/using_license_seat', user: @user + + - if @user.ldap_user? + %li + %span.light= _('LDAP uid:') + %strong + = @user.ldap_identity.extern_uid + + - if @user.created_by + %li + %span.light= _('Created by:') + %strong + = link_to @user.created_by.name, [:admin, @user.created_by] - = render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace + = render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace = render_if_exists 'admin/users/credit_card_info', user: @user, link_to_match_page: true diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 6ed46847482..3952a450c4a 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -1,7 +1,7 @@ - api_awards_path = local_assigns.fetch(:api_awards_path, nil) - if api_awards_path - .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between + .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-py-3 #js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } } = yield - else diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 02c468cebd7..9ca11b35064 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -1,9 +1,11 @@ - save_endpoint = local_assigns.fetch(:save_endpoint, nil) - if ci_variable_protected_by_default? - %p.settings-message.text-center - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables') } - = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = render Pajamas::AlertComponent.new(variant: :warning, show_icon: false, dismissible: false, + alert_options: { class: 'gl-mb-3'}) do |c| + = c.body do + - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables') } + = _('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - is_group = !@group.nil? - is_project = !@project.nil? diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml index 9c34daf88bd..77bcacdb94b 100644 --- a/app/views/ci/variables/_url_query_variable_row.html.haml +++ b/app/views/ci/variables/_url_query_variable_row.html.haml @@ -15,12 +15,12 @@ %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } = options_for_select(ci_variable_type_options, variable_type) - %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text", + %input.js-ci-variable-input-key.ci-variable-body-item.form-control.table-section.section-15{ type: "text", name: key_input_name, value: key, placeholder: s_('CiVariables|Input variable key') } .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 - %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ rows: 1, + %textarea.js-ci-variable-input-value.js-secret-value.form-control{ rows: 1, name: value_input_name, placeholder: s_('CiVariables|Input variable value') } = value diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 483c767d029..c5e518d8526 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -18,14 +18,14 @@ %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } = options_for_select(ci_variable_type_options, variable_type) - %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.gl-form-input.table-section.section-15{ type: "text", + %input.js-ci-variable-input-key.ci-variable-body-item.form-control.gl-form-input.table-section.section-15{ type: "text", name: key_input_name, value: key, placeholder: s_('CiVariables|Input variable key') } .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 - .form-control.js-secret-value-placeholder.qa-ci-variable-input-value.overflow-hidden{ class: ('hide' unless id) } + .form-control.js-secret-value-placeholder.overflow-hidden{ class: ('hide' unless id) } = '*' * 17 - %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control.gl-form-input{ class: ('hide' if id), + %textarea.js-ci-variable-input-value.js-secret-value.form-control.gl-form-input{ class: ('hide' if id), rows: 1, name: value_input_name, placeholder: s_('CiVariables|Input variable value') } diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml index 75609465eb3..9e7820d3136 100644 --- a/app/views/clusters/clusters/_health.html.haml +++ b/app/views/clusters/clusters/_health.html.haml @@ -1,4 +1,6 @@ -%section.settings.no-animate.expanded.cluster-health-graphs.qa-cluster-health-section#cluster-health +- add_page_specific_style 'page_bundles/prometheus' + +%section.settings.no-animate.expanded.cluster-health-graphs#cluster-health - if @cluster&.integration_prometheus_available? #prometheus-graphs{ data: @cluster.health_data(clusterable) } diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index abe9cc9f27d..7bc782df119 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/clusters' - breadcrumb_title _('Kubernetes') - page_title _('Kubernetes Clusters') diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index bf7b24181c1..557c95f8478 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -36,7 +36,7 @@ = platform_kubernetes_field.form_group :authorization_type, { help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do = platform_kubernetes_field.check_box :authorization_type, - { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'), + { data: { qa_selector: 'rbac_checkbox'}, label: s_('ClusterIntegration|RBAC-enabled cluster'), label_class: 'label-bold', inline: true }, 'rbac', 'abac' .form-group diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index b6719834358..8a960602536 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -9,7 +9,7 @@ .signup-page = render 'devise/shared/signup_box', - url: registration_path(resource_name), + url: registration_path(resource_name, glm_tracking_params.to_hash), button_text: _('Register'), borderless: Feature.enabled?(:restyle_login_page, @project), show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index d06043c1750..7affbafbdeb 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -10,10 +10,10 @@ = label_tag :password = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true } - if !hide_remember_me && devise_mapping.rememberable? - .remember-me.gl-px-5 - %label{ for: "remember_me" } - = check_box_tag :remember_me, '1', false, id: 'remember_me' - %span= _('Remember me') + .gl-px-5 + = render Pajamas::CheckboxTagComponent.new(name: 'remember_me') do |c| + = c.label do + = _('Remember me') .submit-container.move-submit-down.gl-px-5.gl-pb-5 = submit_tag submit_message, class: "gl-button btn btn-confirm", data: { qa_selector: 'sign_in_button' } diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index d4f34a1cb3f..439a2fc4d96 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -13,8 +13,6 @@ %span.gl-button-text = label_for_provider(provider) - unless hide_remember_me - %fieldset - %label{ class: restyle_login_page_enabled ? 'gl-font-weight-normal' : '' } - = check_box_tag :remember_me, nil, false - %span - = _('Remember me') + = render Pajamas::CheckboxTagComponent.new(name: 'remember_me', value: nil) do |c| + = c.label do + = _('Remember me') diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index e81a5928983..76c4cf41a2d 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,7 +1,7 @@ - show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?) - render_signup_link = local_assigns.fetch(:render_signup_link, true) -%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: "#{"custom-provider-tabs" if any_form_based_providers_enabled?} #{"nav-links-unboxed" if Feature.enabled?(:restyle_login_page, @project)}" } +%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: "#{'custom-provider-tabs' if any_form_based_providers_enabled?} #{'nav-links-unboxed' if Feature.enabled?(:restyle_login_page, @project)}" } - if crowd_enabled? %li.nav-item = link_to _("Crowd"), "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab', role: 'tab' diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 477f6c73388..224930e28df 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -3,19 +3,13 @@ .timeline-entry-inner .timeline-content .discussion.js-toggle-container{ data: { discussion_id: discussion.id, is_expanded: expanded.to_s } } - .discussion-header - .timeline-icon + .discussion-header.gl-display-flex.gl--flex-center + .timeline-icon.gl-flex-shrink-0 = link_to user_path(discussion.author) do - = image_tag avatar_icon_for_user(discussion.author), class: "avatar s40" - .discussion-actions - %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) } - = sprite_icon('chevron-up', css_class: "js-sidebar-collapse #{'hidden' unless expanded}") - = sprite_icon('chevron-down', css_class: "js-sidebar-expand #{'hidden' if expanded}") - %span.js-sidebar-collapse{ class: "#{'hidden' unless expanded}" }= _('Hide thread') - %span.js-sidebar-expand{ class: "#{'hidden' if expanded}" }= _('Show thread') + = render Pajamas::AvatarComponent.new(discussion.author, size: 32, class: 'gl-mr-3') = link_to_member(@project, discussion.author, avatar: false) - .inline.discussion-headline-light + .inline.discussion-headline-light.gl-mx-3 = discussion.author.to_reference started a thread @@ -42,10 +36,16 @@ an old version of the diff - = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = render "discussions/headline", discussion: discussion + .discussion-actions.gl-ml-auto + %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) } + = sprite_icon('chevron-up', css_class: "js-sidebar-collapse #{'hidden' unless expanded}") + = sprite_icon('chevron-down', css_class: "js-sidebar-expand #{'hidden' if expanded}") + %span.js-sidebar-collapse{ class: "#{'hidden' unless expanded}" }= _('Hide thread') + %span.js-sidebar-expand{ class: "#{'hidden' if expanded}" }= _('Show thread') + .discussion-body.js-toggle-content{ class: ("hide" unless expanded) } - if discussion.diff_discussion? && discussion.diff_file = render "discussions/diff_with_notes", discussion: discussion diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 1f6ac29bffc..7ef3461a7fb 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -6,7 +6,7 @@ = inline_event_icon(event) - if event.target %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } - = event.action_name + = localized_action_name(event) %span.event-target-type.gl-mr-2= event.target_type_name = link_to event_target_path(event), class: 'has-tooltip event-target-link gl-mr-2', title: event.target_title do = event.target.reference_link_text diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index a9234753aa2..d48bf0173a4 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -1,5 +1,5 @@ -= form_with url: configure_import_bulk_imports_path(namespace_id: params[:parent_id]), class: 'group-form gl-show-field-errors' do |f| - .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 += gitlab_ui_form_with url: configure_import_bulk_imports_path(namespace_id: params[:parent_id]), class: 'gl-show-field-errors' do |f| + .gl-border-l-solid.gl-border-r-solid.gl-border-t-solid.gl-border-gray-100.gl-border-1.gl-p-5.gl-mt-4 .gl-display-flex.gl-align-items-center %h4.gl-display-flex = s_('GroupsNew|Import groups from another instance of GitLab') @@ -32,4 +32,4 @@ id: 'import_gitlab_token', data: { qa_selector: 'import_gitlab_token' } .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 - = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-confirm', data: { qa_selector: 'connect_instance_button' } + = f.submit s_('GroupsNew|Connect instance'), pajamas_button: true, data: { qa_selector: 'connect_instance_button' } diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml index 022777eea27..35e8b7dc977 100644 --- a/app/views/groups/_import_group_from_file_panel.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -2,7 +2,7 @@ - group_path = root_url - group_path << parent.full_path + '/' if parent -= form_for '', url: import_gitlab_group_path, namespace: 'import_group', class: 'group-form gl-show-field-errors', multipart: true do |f| += gitlab_ui_form_for '', url: import_gitlab_group_path, namespace: 'import_group', class: 'group-form gl-show-field-errors', multipart: true do |f| .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 %h4 = _('Import group from file') @@ -22,4 +22,4 @@ .gl-mt-3 = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-confirm-secondary gl-mr-2' .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 - = f.submit _('Import'), class: 'btn gl-button btn-confirm' + = f.submit _('Import'), pajamas_button: true diff --git a/app/views/groups/_personalize.html.haml b/app/views/groups/_personalize.html.haml index bae76952ef8..2f55aefb817 100644 --- a/app/views/groups/_personalize.html.haml +++ b/app/views/groups/_personalize.html.haml @@ -8,7 +8,7 @@ .row .form-group.col-sm-4 = label :user, :role, _('Role') - = select :user, :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { selected: @current_user.role }, class: 'form-control' + = select :user, :role, ::User.roles.keys.map { |role| [localized_user_roles[role] || role.titleize, role] }, { selected: @current_user.role }, class: 'form-control' .row .form-group.col-sm-4 diff --git a/app/views/groups/boards/index.html.haml b/app/views/groups/boards/index.html.haml index bb56769bd3f..e5b5f6404bb 100644 --- a/app/views/groups/boards/index.html.haml +++ b/app/views/groups/boards/index.html.haml @@ -1 +1 @@ -= render "shared/boards/show", board: @boards.first += render "shared/boards/show", board: @board diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 33fcda6129c..6c4a8b53764 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,24 +1,18 @@ -- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.licensed_feature_available?(:group_bulk_edit) +- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.licensed_feature_available?(:group_bulk_edit) && issuables_count_for_state(:merge_requests, :all) > 0 - page_title _("Merge requests") -- if issuables_count_for_state(:merge_requests, :all) == 0 - = render 'shared/issuable/search_bar', type: :merge_requests +.top-area + = render 'shared/issuable/nav', type: :merge_requests + - if current_user + .nav-controls + - if @can_bulk_update + = render_if_exists 'projects/merge_requests/bulk_update_button' - = render 'shared/empty_states/merge_requests', project_select_button: true -- else - .top-area - = render 'shared/issuable/nav', type: :merge_requests - - if current_user - .nav-controls - - if @can_bulk_update - = render_if_exists 'projects/merge_requests/bulk_update_button' + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _("merge request"), type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _("merge request"), type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true += render 'shared/issuable/search_bar', type: :merge_requests +- if @can_bulk_update + = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :merge_requests - = render 'shared/issuable/search_bar', type: :merge_requests - - - if @can_bulk_update - = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :merge_requests - - = render 'shared/merge_requests' += render 'shared/merge_requests' diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 3864b30eb1e..d4b1c3c27f1 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -21,8 +21,8 @@ .form-actions - if @milestone.new_record? - = f.submit _('Create milestone'), class: "btn-confirm gl-button btn", data: { qa_selector: "create_milestone_button" } + = f.submit _('Create milestone'), data: { qa_selector: "create_milestone_button" }, pajamas_button: true = link_to _("Cancel"), group_milestones_path(@group), class: "btn gl-button btn-cancel" - else - = f.submit _('Update milestone'), class: "btn-confirm gl-button btn" + = f.submit _('Update milestone'), pajamas_button: true = link_to _("Cancel"), group_milestone_path(@group, @milestone), class: "btn gl-button btn-cancel" diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 8384c906eeb..657a582bdc5 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -16,7 +16,7 @@ #import-group-pane.tab-pane - if import_sources_enabled? - - if Feature.enabled?(:bulk_import) + - if BulkImports::Features.enabled? = render 'import_group_from_another_instance_panel' .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1 = render 'import_group_from_file_panel' diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index d5c22d9b1f2..e7ae54a8879 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -2,45 +2,46 @@ - page_title _("Projects") - @content_class = "limit-container-width" unless fluid_layout -.card.gl-mt-3 - .card-header - %strong= @group.name - projects: += render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 js-search-settings-section' }, header_options: { class: 'gl-display-flex' }, body_options: { class: 'gl-py-0' }) do |c| + - c.header do + .gl-flex-grow-1 + = html_escape(_("%{strong_open}%{group_name}%{strong_close} projects:")) % { strong_open: '<strong>'.html_safe, group_name: @group.name, strong_close: '</strong>'.html_safe } - if can? current_user, :admin_group, @group .controls - = link_to new_project_path(namespace_id: @group.id), class: "btn gl-button btn-sm btn-confirm" do - New project - %ul.projects-list.content-list.group-settings-projects - - @projects.each do |project| - %li.project-row{ class: ('no-description' if project.description.blank?) } - .controls - = link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button" - = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button" - = render 'delete_project_button', project: project + = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small, variant: :confirm) do + = _("New project") + - c.body do + %ul.content-list + - @projects.each do |project| + %li.project-row.gl-align-items-center{ class: 'gl-display-flex!' } + .avatar-container.rect-avatar.s40.gl-flex-shrink-0 + = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) + .gl-min-w-0.gl-flex-grow-1 + .title + = link_to project_path(project), class: 'js-prefetch-document' do + %span.project-full-name + %span.namespace-name + - if project.namespace + = project.namespace.human_name + \/ + %span.project-name + = project.name + %span{ class: visibility_level_color(project.visibility_level) } + = visibility_level_icon(project.visibility_level) - .stats - = gl_badge_tag storage_counter(project.statistics&.storage_size) - = render 'project_badges', project: project + - if project.description.present? + .description + = markdown_field(project, :description) - .title - = link_to project_path(project), class: 'js-prefetch-document' do - .dash-project-avatar - .avatar-container.rect-avatar.s40 - = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) - %span.project-full-name - %span.namespace-name - - if project.namespace - = project.namespace.human_name - \/ - %span.project-name - = project.name - %span{ class: visibility_level_color(project.visibility_level) } - = visibility_level_icon(project.visibility_level) + .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex + = gl_badge_tag storage_counter(project.statistics&.storage_size) + = render 'project_badges', project: project - - if project.description.present? - .description - = markdown_field(project, :description) - - if @projects.blank? - .nothing-here-block This group has no projects yet + .controls.gl-flex-shrink-0.gl-ml-5 + = link_to _('Members'), project_project_members_path(project), id: dom_id(project, :edit), class: "btn gl-button" + = link_to _('Edit'), edit_project_path(project), id: dom_id(project, :edit), class: "btn gl-button" + = render 'delete_project_button', project: project + - if @projects.blank? + .nothing-here-block= _("This group has no projects yet") = paginate @projects, theme: "gitlab" diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml index 456e0b0f1d0..441e9333630 100644 --- a/app/views/groups/settings/_pages_settings.html.haml +++ b/app/views/groups/settings/_pages_settings.html.haml @@ -1,5 +1,5 @@ -= form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| += gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| = render_if_exists 'shared/pages/max_pages_size_input', form: f .gl-mt-3 - = f.submit s_('GitLabPages|Save changes'), class: 'btn gl-button btn-confirm' + = f.submit s_('GitLabPages|Save changes'), pajamas_button: true diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml index ac6c5d1842c..5e3d814687e 100644 --- a/app/views/groups/settings/access_tokens/index.html.haml +++ b/app/views/groups/settings/access_tokens/index.html.haml @@ -4,7 +4,7 @@ - type_plural = _('group access tokens') - @content_class = 'limit-container-width' unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4 %h4.gl-mt-0 = page_title @@ -24,13 +24,11 @@ = _('You can enable group access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .col-lg-8 - - if @new_resource_access_token - = render 'shared/access_tokens/created_container', - type: type, - new_token_value: @new_resource_access_token + #js-new-access-token-app{ data: { access_token_type: type } } - if current_user.can?(:create_resource_access_tokens, @group) = render 'shared/access_tokens/form', + ajax: true, type: type, path: group_settings_access_tokens_path(@group), resource: @group, @@ -41,10 +39,6 @@ prefix: :resource_access_token, help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') - = render 'shared/access_tokens/table', - active_tokens: @active_resource_access_tokens, - resource: @group, - type: type, - type_plural: type_plural, - revoke_route_helper: ->(token) { revoke_group_settings_access_token_path(id: token) }, - no_active_tokens_message: _('This group has no active access tokens.') + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_resource_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true + } } + diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index 59c67197f81..89e353b94b0 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -1,6 +1,6 @@ .row.gl-mt-3 .col-lg-12 - = form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f| + = gitlab_ui_form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f| %fieldset.builds-feature .form-group = f.label :max_artifacts_size, _('Maximum artifacts size'), class: 'label-bold' @@ -8,4 +8,4 @@ %p.form-text.text-muted = _("The maximum file size in megabytes for individual job artifacts.") = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' - = f.submit _('Save changes'), class: "btn gl-button btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 88352ea351c..67b87f842f9 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -44,7 +44,7 @@ = expanded ? _('Collapse') : _('Expand') %p - auto_devops_url = help_page_path('topics/autodevops/index') - - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') + - quickstart_url = help_page_path('topics/autodevops/cloud_deployments/auto_devops_with_gke') - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } = s_('AutoDevOps|%{auto_devops_start}Automate building, testing, and deploying%{auto_devops_end} your applications based on your continuous integration and delivery configuration. %{quickstart_start}How do I get started?%{quickstart_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml index 1db8edb040b..ec99ceb5f8d 100644 --- a/app/views/groups/settings/integrations/index.html.haml +++ b/app/views/groups/settings/integrations/index.html.haml @@ -2,8 +2,9 @@ - page_title s_('Integrations|Group-level integration management') - @content_class = 'limit-container-width' unless fluid_layout -%h3= s_('Integrations|Group-level integration management') +%section.js-search-settings-section + %h3= s_('Integrations|Group-level integration management') -- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path } -%p= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe } -= render 'shared/integrations/index', integrations: @integrations + - integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path } + %p= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe } + = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index f474f8fbd3b..012a31c1ecf 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -3,9 +3,6 @@ - @skip_current_level_breadcrumb = true - add_page_specific_style 'page_bundles/group' -- if show_thanks_for_purchase_alert? - = render_if_exists 'shared/thanks_for_purchase_alert', plan_title: plan_title, quantity: params[:purchased_quantity].to_i - = render_if_exists 'shared/qrtly_reconciliation_alert', group: @group = render_if_exists 'shared/free_user_cap_alert', source: @group diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 3992cb527ed..eaa58580454 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -34,14 +34,14 @@ .row.gl-mt-3 .col-md-8 - .documentation-index.md + .md = markdown(@help_index) .col-md-4 .card.links-card .card-header = _('Quick help') %ul.content-list - %li= link_to _('See our website for help'), support_url + %li= link_to _('See our website for help'), support_url, { class: 'gl-text-blue-600!' } %li %button.btn-blank.btn-link.js-trigger-search-bar{ type: 'button' } = _('Use the search bar on the top of this page') @@ -49,5 +49,5 @@ %button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' } = _('Use shortcuts') - unless Gitlab::CurrentSettings.help_page_hide_commercial_content? - %li= link_to _('Get a support subscription'), "https://#{ApplicationHelper.promo_host}/pricing/" - %li= link_to _('Compare GitLab editions'), "https://#{ApplicationHelper.promo_host}/features/#compare" + %li= link_to _('Get a support subscription'), "https://#{ApplicationHelper.promo_host}/pricing/", { class: 'gl-text-blue-600!' } + %li= link_to _('Compare GitLab editions'), "https://#{ApplicationHelper.promo_host}/features/#compare", { class: 'gl-text-blue-600!' } diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml index b6d27123be4..4baedca820f 100644 --- a/app/views/help/instance_configuration.html.haml +++ b/app/views/help/instance_configuration.html.haml @@ -1,5 +1,5 @@ - page_title _('Instance Configuration') -.documentation.md +.md.gl-font-lg.gl-mt-3 %h1= _('Instance Configuration') %p diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml index c41f6ea3ed4..39f45e8b649 100644 --- a/app/views/help/show.html.haml +++ b/app/views/help/show.html.haml @@ -1,5 +1,5 @@ - page_title @path.split("/").reverse.map(&:humanize) - @content_class = "limit-container-width" unless fluid_layout -.documentation.md.gl-mt-3 +.md.gl-font-lg.gl-mt-3 = markdown @markdown diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index 35fd5d6eda6..9ea52a8f82f 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -5,6 +5,7 @@ - paginatable = local_assigns.fetch(:paginatable, false) - default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path - provider_title = Gitlab::ImportSources.title(provider) +- optional_stages = local_assigns.fetch(:optional_stages, []) - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') @@ -18,4 +19,5 @@ default_target_namespace: default_namespace_path, import_path: url_for([:import, provider, { format: :json }]), filterable: filterable.to_s, - paginatable: paginatable.to_s }.merge(extra_data) } + paginatable: paginatable.to_s, + optional_stages: optional_stages.to_json }.merge(extra_data) } diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 28836055e0e..9d4c0f62134 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -23,23 +23,21 @@ %p = html_escape(_('Selecting a GitLab user will add a link to the GitLab user in the descriptions of issues and comments (e.g. "By %{link_open}@johnsmith%{link_close}"). It will also associate and/or assign these issues and comments with the selected user.')) % { link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe } - .table-holder - %table.table - %thead + %table.table + %thead + %tr + %th= _("ID") + %th= _("Name") + %th= _("Email") + %th= _("GitLab User") + %tbody + - @user_map.each do |id, user| %tr - %th= _("ID") - %th= _("Name") - %th= _("Email") - %th= _("GitLab User") - %tbody - - @user_map.each do |id, user| - %tr - %td= id - %td= text_field_tag "users[#{id}][name]", user[:name], class: 'form-control' - %td= text_field_tag "users[#{id}][email]", user[:email], class: 'form-control' - %td - = users_select_tag("users[#{id}][gitlab_user]", class: 'custom-form-control', - scope: :all, email_user: true, selected: user[:gitlab_user]) + %td= id + %td= text_field_tag "users[#{id}][name]", user[:name], class: 'form-control gl-form-input' + %td= text_field_tag "users[#{id}][email]", user[:email], class: 'form-control gl-form-input' + %td + .js-gitlab-user{ data: { name: "users[#{id}][gitlab_user]" } } .form-actions = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm' diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index 1b556cd0f7f..25afe9a7b1b 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -7,4 +7,7 @@ - paginatable = Feature.enabled?(:remove_legacy_github_client) -= render 'import/githubish_status', provider: 'github', paginatable: paginatable, default_namespace: @namespace += render 'import/githubish_status', + provider: 'github', paginatable: paginatable, + default_namespace: @namespace, + optional_stages: Gitlab::GithubImport::Settings.stages_array diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index ab4b3cf6afd..5a558d42802 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,8 +1,10 @@ --# We currently only support `alert`, `notice`, `success`, 'toast', and 'raw' -- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'} -- type_to_variant = {'alert' => 'danger', 'notice' => 'info', 'success' => 'success'} +- flash_container_no_margin = local_assigns.fetch(:flash_container_no_margin, false) +- flash_container_class = ('flash-container-no-margin' if flash_container_no_margin) + +-# We currently only support `alert`, `notice`, `success`, `warning`, 'toast', and 'raw' +- type_to_variant = {'alert' => 'danger', 'notice' => 'info', 'success' => 'success', 'warning' => 'warning'} - closable = %w[alert notice success] -.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } } +.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' }, class: flash_container_class } - flash.each do |key, value| - if key == 'toast' && value .js-toast-message{ data: { message: value } } @@ -11,9 +13,5 @@ - elsif value == I18n.t('devise.failure.unconfirmed') = render 'shared/confirm_your_email_alert' - elsif value - %div{ class: "flash-#{key} mb-2", data: { testid: "alert-#{type_to_variant[key]}" } } - = sprite_icon(icons[key], css_class: 'align-middle mr-1') unless icons[key].nil? - %span= value - - if closable.include?(key) - %div{ class: "close-icon-wrapper js-close-icon" } - = sprite_icon('close', css_class: 'close-icon gl-vertical-align-baseline!') + = render Pajamas::AlertComponent.new(variant: type_to_variant[key], dismissible: closable.include?(key), alert_options: {class: "flash-#{key}", data: { testid: "alert-#{type_to_variant[key]}" }}) do |c| + = c.with_body { value } diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 84eb2706929..2ac926a7fc3 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -1,5 +1,5 @@ - page_description brand_title unless page_description -- site_name = "GitLab" +- site_name = _('GitLab') %head{ prefix: "og: http://ogp.me/ns#" } %meta{ charset: "utf-8" } diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index 61a57240ed5..f4f9f39c20e 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -1,11 +1,13 @@ +- minimal = local_assigns.fetch(:minimal, false) !!! 5 %html{ lang: I18n.locale, class: page_class } = render "layouts/head" %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } = render 'peek/bar' = header_message - = render partial: "layouts/header/default", locals: { project: @project, group: @group } - .mobile-overlay + - unless minimal + = render partial: "layouts/header/default", locals: { project: @project, group: @group } + .mobile-overlay .hide-when-top-nav-responsive-open.gl--flex-full.gl-h-full{ class: nav ? ["layout-page", page_with_sidebar_class, "gl-mt-0!"]: '' } - if defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" @@ -14,8 +16,9 @@ = render 'shared/outdated_browser' = render "layouts/broadcast" = yield :flash_message - = render "layouts/flash" + = render "layouts/flash", flash_container_no_margin: true .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" } = yield - = render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto" + - unless minimal + = render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto" = footer_message diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index a00c5c186cc..b74dfd4d3a1 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -132,7 +132,7 @@ - if header_link?(:user_dropdown) %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) } = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do - = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name + = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { qa_selector: 'user_avatar_content' } }) = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right diff --git a/app/views/layouts/header/_gitlab_version.html.haml b/app/views/layouts/header/_gitlab_version.html.haml index 125fbaa084c..fae6926a687 100644 --- a/app/views/layouts/header/_gitlab_version.html.haml +++ b/app/views/layouts/header/_gitlab_version.html.haml @@ -1,6 +1,6 @@ - return unless show_version_check? -.gl-display-flex.gl-flex-direction-column.gl-px-4.gl-py-3 +%a{ class: 'gl-display-flex! gl-flex-direction-column gl-px-4! gl-py-3! gl-line-height-24!', href: help_page_path('update/index'), 'data-testid': 'gitlab-version-container' } %span = s_("VersionCheck|Your GitLab Version") = emoji_icon('rocket') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 3a8f9c1ae8d..bdd1ae291fd 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -1,6 +1,7 @@ %ul - if current_user_menu?(:help) - = render 'layouts/header/gitlab_version' + %li + = render 'layouts/header/gitlab_version' = render_if_exists 'layouts/header/help_dropdown/cross_stage_fdm' = render 'layouts/header/whats_new_dropdown_item' %li diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml index e5b03acbe3b..9801b0cc055 100644 --- a/app/views/layouts/header/_new_dropdown.html.haml +++ b/app/views/layouts/header/_new_dropdown.html.haml @@ -24,4 +24,4 @@ = menu_item.fetch(:title) - if menu_item.fetch(:emoji) -# We need to insert a space between the title and emoji - = " #{emoji_icon(menu_item.fetch(:emoji), 'aria-hidden': true, class: "gl-font-base gl-vertical-align-baseline")}".html_safe + = " #{emoji_icon(menu_item.fetch(:emoji), 'aria-hidden': true, class: 'gl-font-base gl-vertical-align-baseline')}".html_safe diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 56f333664df..8815dec5a6b 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,4 +1,4 @@ -%aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') } +%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation'), data: { qa_selector: 'admin_sidebar_content' } } .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do @@ -6,7 +6,7 @@ = sprite_icon('admin', size: 18) %span.sidebar-context-title = _('Admin Area') - %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } + %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_overview_submenu_content' } } = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts], html_options: {class: 'home'}) do = link_to admin_root_path, class: 'has-sub-items' do .nav-icon-container @@ -28,15 +28,15 @@ %span = _('Projects') = nav_link(controller: %w[users cohorts]) do - = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'users_overview_link' } do + = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'admin_overview_users_link' } do %span = _('Users') = nav_link(controller: :groups) do - = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do + = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'admin_overview_groups_link' } do %span = _('Groups') = nav_link(controller: [:admin, 'admin/topics']) do - = link_to admin_topics_path, title: _('Topics'), data: { qa_selector: 'topics_overview_link' } do + = link_to admin_topics_path, title: _('Topics') do %span = _('Topics') = nav_link path: 'jobs#index' do @@ -75,13 +75,13 @@ = _('Usage Trends') = nav_link(controller: admin_monitoring_nav_links) do - = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' }, class: 'has-sub-items' do + = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_menu_link' }, class: 'has-sub-items' do .nav-icon-container = sprite_icon('monitor') %span.nav-item-name = _('Monitoring') - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } } + %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_monitoring_submenu_content' } } = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" } ) do = link_to admin_system_info_path do %strong.fly-out-top-item-name @@ -222,10 +222,10 @@ = link_to general_admin_application_settings_path, class: 'has-sub-items' do .nav-icon-container = sprite_icon('settings') - %span.nav-item-name.qa-admin-settings-item + %span.nav-item-name{ data: { qa_selector: 'admin_settings_menu_link' } } = _('Settings') - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_settings_submenu_content' } } + %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_settings_submenu_content' } } -# This active_nav_link check is also used in `app/views/layouts/admin.html.haml` = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" } ) do = link_to general_admin_application_settings_path do @@ -233,24 +233,24 @@ = _('Settings') %li.divider.fly-out-top-item = nav_link(path: 'application_settings#general') do - = link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do + = link_to general_admin_application_settings_path, title: _('General'), data: { qa_selector: 'admin_settings_general_link' } do %span = _('General') - = render_if_exists 'layouts/nav/sidebar/advanced_search', class: 'qa-admin-settings-advanced-search' + = render_if_exists 'layouts/nav/sidebar/advanced_search', data: { qa_selector: 'admin_settings_advanced_search_link' } - if instance_level_integrations? = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do - = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do + = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'admin_settings_integrations_link' } do %span = _('Integrations') = nav_link(path: 'application_settings#repository') do - = link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do + = link_to repository_admin_application_settings_path, title: _('Repository'), data: { qa_selector: 'admin_settings_repository_link' } do %span = _('Repository') - if Gitlab.ee? && License.feature_available?(:custom_file_templates) = nav_link(path: 'application_settings#templates') do - = link_to templates_admin_application_settings_path, title: _('Templates'), class: 'qa-admin-settings-template-item' do + = link_to templates_admin_application_settings_path, title: _('Templates'), data: { qa_selector: 'admin_settings_templates_link' } do %span = _('Templates') = nav_link(path: 'application_settings#ci_cd') do @@ -262,7 +262,7 @@ %span = _('Reporting') = nav_link(path: 'application_settings#metrics_and_profiling') do - = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do + = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), data: { qa_selector: 'admin_settings_metrics_and_profiling_link' } do %span = _('Metrics and profiling') = nav_link(path: ['application_settings#service_usage_data']) do @@ -270,7 +270,7 @@ %span = _('Service usage data') = nav_link(path: 'application_settings#network') do - = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do + = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_link' } do %span = _('Network') = nav_link(controller: :appearances ) do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index cf1f84790a2..0e3327935ca 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -2,8 +2,7 @@ .nav-sidebar-inner-scroll .context-header = link_to profile_path, title: _('Profile Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do - %span{ class: ['avatar-container', 'settings-avatar', 's32'] } - = image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32', 'gl-rounded-full!'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' } + = render Pajamas::AvatarComponent.new(current_user, size: 32, alt: current_user.name, class: 'gl-mr-3 js-sidebar-user-avatar', avatar_options: { data: { testid: 'sidebar-user-avatar' } }) %span.sidebar-context-title= _('User Settings') %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do @@ -52,17 +51,18 @@ = link_to profile_chat_names_path do %strong.fly-out-top-item-name = _('Chat') - = nav_link(controller: :personal_access_tokens) do - = link_to profile_personal_access_tokens_path do - .nav-icon-container - = sprite_icon('token') - %span.nav-item-name - = _('Access Tokens') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :personal_access_tokens, html_options: { class: "fly-out-top-item" } ) do - = link_to profile_personal_access_tokens_path do - %strong.fly-out-top-item-name - = _('Access Tokens') + - unless Gitlab::CurrentSettings.personal_access_tokens_disabled? + = nav_link(controller: :personal_access_tokens) do + = link_to profile_personal_access_tokens_path do + .nav-icon-container + = sprite_icon('token') + %span.nav-item-name + = _('Access Tokens') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :personal_access_tokens, html_options: { class: "fly-out-top-item" } ) do + = link_to profile_personal_access_tokens_path do + %strong.fly-out-top-item-name + = _('Access Tokens') = nav_link(controller: :emails) do = link_to profile_emails_path, data: { qa_selector: 'profile_emails_link' } do .nav-icon-container diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index d05b6951fbf..c557dc36534 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -24,7 +24,7 @@ - if @reply_by_email = _('Reply to this email directly or %{view_it_on_gitlab}.').html_safe % { view_it_on_gitlab: link_to(_("view it on GitLab"), @target_url) } - else - #{link_to _("View it on GitLab"), @target_url}. + #{link_to _('View it on GitLab'), @target_url}. %br = notification_reason_text(reason: @reason, show_manage_notifications_link: !@labels_url, show_help_link: true, manage_label_subscriptions_url: @labels_url, unsubscribe_url: @unsubscribe_url, format: :html) diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index c9baf0cd2b8..032be73f70c 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -25,7 +25,7 @@ %ul.nav.navbar-nav %li.header-user.dropdown = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do - = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar", data: { qa_selector: 'user_avatar' } + = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'gl-mr-3', avatar_options: { data: { qa_selector: 'user_avatar_content' } }) = sprite_icon('chevron-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/current_user_dropdown' diff --git a/app/views/notify/access_token_revoked_email.html.haml b/app/views/notify/access_token_revoked_email.html.haml new file mode 100644 index 00000000000..4d9b9e14d14 --- /dev/null +++ b/app/views/notify/access_token_revoked_email.html.haml @@ -0,0 +1,7 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = html_escape(_('A personal access token, named %{code_start}%{token_name}%{code_end}, has been revoked.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe } +%p + - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } + = html_escape(_('You can check your tokens or create a new one in your %{pat_link_start}personal access tokens settings%{pat_link_end}.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/access_token_revoked_email.text.erb b/app/views/notify/access_token_revoked_email.text.erb new file mode 100644 index 00000000000..17dd628d76c --- /dev/null +++ b/app/views/notify/access_token_revoked_email.text.erb @@ -0,0 +1,5 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('A personal access token, named %{token_name}, has been revoked.') % { token_name: @token_name } %> + +<%= _('You can check your tokens or create a new one in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> diff --git a/app/views/notify/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml index 71c62f6be4e..11d761414ff 100644 --- a/app/views/notify/project_was_exported_email.html.haml +++ b/app/views/notify/project_was_exported_email.html.haml @@ -1,8 +1,8 @@ %p - Project #{@project.name} was exported successfully. + = s_('Notify|Project %{project_name} was exported successfully.') % {project_name: @project.name} %p - The project export can be downloaded from: - = link_to download_export_project_url(@project), rel: 'nofollow', download: '' do - = @project.full_name + " export" + - project_link_url = download_export_project_url(@project) + - project_link_start = '<a href="%{url}" target="_blank" rel="nofollow" download="">'.html_safe % { url: project_link_url } + = html_escape(s_('Notify|%{project_link_start}Download%{project_link_end} the project export.')) % {project_link_start: project_link_start, project_link_end: '</a>'.html_safe} %p - The download link will expire in 24 hours. + = s_('Notify|The download link will expire in 24 hours.') diff --git a/app/views/notify/project_was_moved_email.html.haml b/app/views/notify/project_was_moved_email.html.haml index 1b6b1a81665..45bec221e08 100644 --- a/app/views/notify/project_was_moved_email.html.haml +++ b/app/views/notify/project_was_moved_email.html.haml @@ -1,15 +1,9 @@ +- repo_url_styles = "background: #f5f5f5; padding:10px; border:1px solid #ddd" +- ssh_url_to_repo = content_tag(:p, "git remote set-url origin #{strip_tags(@project.ssh_url_to_repo)}", style: repo_url_styles) +- http_url_to_repo = content_tag(:p, "git remote set-url origin #{strip_tags(@project.http_url_to_repo)}", style: repo_url_styles) %p - Project #{@old_path_with_namespace} was moved to another location + = s_('Notify|Project %{old_path_with_namespace} was moved to another location.') % { old_path_with_namespace: @old_path_with_namespace } %p - The project is now located under - = link_to project_url(@project) do - = @project.full_name -%p - To update the remote url in your local repository run (for ssh): -%p{ style: "background: #f5f5f5; padding:10px; border:1px solid #ddd" } - git remote set-url origin #{@project.ssh_url_to_repo} -%p - or for http(s): -%p{ style: "background: #f5f5f5; padding:10px; border:1px solid #ddd" } - git remote set-url origin #{@project.http_url_to_repo} -%br + - project_full_name_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: project_url(@project) } + = html_escape(s_('Notify|The project is now located under %{project_full_name_link_start}%{project_full_name}%{link_end}.')) % { project_full_name_link_start: project_full_name_link_start, link_end: '</a>'.html_safe, project_full_name: @project.full_name } += html_escape(s_('Notify|%{p_start}To update the remote url in your local repository run (for ssh):%{p_end} %{ssh_url_to_repo} %{p_start}or for http(s):%{p_end} %{http_url_to_repo}')) % { p_start: '<p>'.html_safe, p_end: '</p>'.html_safe, ssh_url_to_repo: ssh_url_to_repo, http_url_to_repo: http_url_to_repo } diff --git a/app/views/notify/project_was_not_exported_email.html.haml b/app/views/notify/project_was_not_exported_email.html.haml index c888da29c17..dcd212099b5 100644 --- a/app/views/notify/project_was_not_exported_email.html.haml +++ b/app/views/notify/project_was_not_exported_email.html.haml @@ -1,7 +1,7 @@ %p - Project #{@project.name} couldn't be exported. + = s_("Notify|Project %{project_name} couldn't be exported.") % {project_name: @project.name} %p - The errors we encountered were: + = s_('Notify|The errors we encountered were:') %ul - @errors.each do |error| diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml index 895d8807e47..2ba0a2cf4ab 100644 --- a/app/views/notify/repository_push_email.text.haml +++ b/app/views/notify/repository_push_email.text.haml @@ -13,7 +13,7 @@ \- - - - - \ \ - #{pluralize @message.diffs_count, "changed file"}: + #{pluralize @message.diffs_count, 'changed file'}: \ - @message.diffs.each do |diff_file| - if diff_file.deleted_file? diff --git a/app/views/notify/request_review_merge_request_email.html.haml b/app/views/notify/request_review_merge_request_email.html.haml index d1f72f6529a..a8c7df79ff3 100644 --- a/app/views/notify/request_review_merge_request_email.html.haml +++ b/app/views/notify/request_review_merge_request_email.html.haml @@ -1,2 +1,2 @@ %p - #{sanitize_name(@updated_by.name)} requested a new review on #{merge_request_reference_link(@merge_request)}. + = html_escape(s_('Notify|%{name} requested a new review on %{mr_link}.')) % {name: sanitize_name(@updated_by.name), mr_link: merge_request_reference_link(@merge_request).html_safe} diff --git a/app/views/notify/send_unsubscribed_notification.html.haml b/app/views/notify/send_unsubscribed_notification.html.haml index 9f68feeaa31..ef1577dde97 100644 --- a/app/views/notify/send_unsubscribed_notification.html.haml +++ b/app/views/notify/send_unsubscribed_notification.html.haml @@ -1,2 +1,2 @@ %p - You have been unsubscribed from receiving GitLab administrator notifications. + = s_('Notify|You have been unsubscribed from receiving GitLab administrator notifications.') diff --git a/app/views/notify/two_factor_otp_attempt_failed_email.html.haml b/app/views/notify/two_factor_otp_attempt_failed_email.html.haml new file mode 100644 index 00000000000..fec7083e524 --- /dev/null +++ b/app/views/notify/two_factor_otp_attempt_failed_email.html.haml @@ -0,0 +1,51 @@ +- default_font = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" +- default_style = "#{default_font}font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" +- spacer_style = "#{default_font};height:18px;font-size:18px;line-height:18px;" + +%tr.alert + %td{ style: "#{default_font}padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;color:#ffffff;background-color:#FC6D26;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "#{default_font}vertical-align:middle;color:#ffffff;text-align:center;" } + %span + = _("We detected an attempt to sign in to your %{host} account using a wrong two-factor authentication code") % { host: Gitlab.config.gitlab.host } +%tr.spacer + %td{ style: spacer_style } + +%tr.section + %td{ style: "#{default_font};padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } + %tbody + %tr + %td{ style: default_style } + = _('Hostname') + %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;" } + = Gitlab.config.gitlab.host + %tr + %td{ style: "#{default_style}border-top:1px solid #ededed;" } + = _('IP Address') + %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %span.muted{ style: "color:#333333;text-decoration:none;" } + = @ip + %tr + %td{ style: "#{default_style}border-top:1px solid #ededed;" } + = _('Time') + %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + = @time.strftime('%Y-%m-%d %H:%M:%S %Z') +%tr.spacer + %td{ style: spacer_style } + +%tr.section + %td{ style: "#{default_font};line-height:1.4;text-align:center;padding:0 15px;overflow:hidden;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;width:100%;" } + %tbody + %tr{ style: 'width:100%;' } + %td{ style: "#{default_style}text-align:center;" } + - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } + = _('If you recently tried to sign in, but mistakenly entered a wrong two-factor authentication code, you may ignore this email.') + + - if password_authentication_enabled_for_web? + %p + = _('If you did not recently try to sign in, you should immediately %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe } + = _('Make sure you choose a strong, unique password.') diff --git a/app/views/notify/two_factor_otp_attempt_failed_email.text.haml b/app/views/notify/two_factor_otp_attempt_failed_email.text.haml new file mode 100644 index 00000000000..8f839cd83ee --- /dev/null +++ b/app/views/notify/two_factor_otp_attempt_failed_email.text.haml @@ -0,0 +1,7 @@ += _('Hi %{username}!') % { username: sanitize_name(@user.name) } + += _('We detected an attempt to sign in to your %{host} account using a wrong two-factor authentication code, from the following IP address: %{ip}, at %{time}') % { host: Gitlab.config.gitlab.host, ip: @ip, time: @time } + += _('If you recently tried to sign in, but mistakenly entered a wrong two-factor authentication code, you may ignore this email.') += _('If you did not recently try to sign in, you should immediately change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } += _('Make sure you choose a strong, unique password.') diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml index 47c5656db27..b1c79274e26 100644 --- a/app/views/notify/unknown_sign_in_email.html.haml +++ b/app/views/notify/unknown_sign_in_email.html.haml @@ -42,7 +42,7 @@ %tbody %tr{ style: 'width:100%;' } %td{ style: "#{default_style}text-align:center;" } - - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' } + - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } = _('If you recently signed in and recognize the IP address, you may disregard this email.') - if password_authentication_enabled_for_web? diff --git a/app/views/notify/unknown_sign_in_email.text.haml b/app/views/notify/unknown_sign_in_email.text.haml index f3efc4c4fcd..54c7a245ab9 100644 --- a/app/views/notify/unknown_sign_in_email.text.haml +++ b/app/views/notify/unknown_sign_in_email.text.haml @@ -3,7 +3,7 @@ = _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip } = _('If you recently signed in and recognize the IP address, you may disregard this email.') -= _('If you did not recently sign in, you should immediately change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' } += _('If you did not recently sign in, you should immediately change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } = _('Passwords should be unique and not used for any other sites or services.') - unless @user.two_factor_enabled? diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index be835233528..e9e6ca3ecce 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -1,7 +1,7 @@ - page_title _('Active Sessions') - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 4bbb4a21b39..9997c8c4b4c 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,7 +1,7 @@ - page_title _('Authentication log') - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 54c34228800..41bd81d0250 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,7 +1,7 @@ - page_title _('Chat') - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 1b8f0328a04..f4513d15a30 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,7 +1,7 @@ - page_title _('Emails') - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -10,12 +10,12 @@ .col-lg-8 %h4.gl-mt-0 = _('Add email address') - = form_for 'email', url: profile_emails_path do |f| + = gitlab_ui_form_for 'email', url: profile_emails_path do |f| .form-group = f.label :email, _('Email'), class: 'label-bold' = f.text_field :email, class: 'form-control gl-form-input', data: { qa_selector: 'email_address_field' } .gl-mt-3 - = f.submit _('Add email address'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_email_address_button' } + = f.submit _('Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true %hr %h4.gl-mt-0 = _('Linked emails (%{email_count})') % { email_count: @emails.load.size } diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index 9804a3b7735..ffd8bc3de27 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -1,5 +1,5 @@ %div - = form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f| + = gitlab_ui_form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f| = form_errors(@gpg_key) .form-group @@ -7,4 +7,4 @@ = f.text_area :key, class: "form-control gl-form-input", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.") .gl-mt-3 - = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-confirm" + = f.submit s_('Profiles|Add key'), pajamas_button: true diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 91af6953ee1..539a0cd1f0e 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,7 +1,8 @@ - page_title _('GPG Keys') +- add_page_specific_style 'page_bundles/profile' - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 6f7eb21b7e0..b37a0d9cc1a 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -1,6 +1,6 @@ - max_date = ::Gitlab::CurrentSettings.max_ssh_key_lifetime_from_now.to_date if ssh_key_expiration_policy_enabled? %div - = form_for [:profile, @key], html: { class: 'js-requires-input' } do |f| + = gitlab_ui_form_for [:profile, @key], html: { class: 'js-requires-input' } do |f| = form_errors(@key) .form-group @@ -29,4 +29,4 @@ button_options: { class: 'js-add-ssh-key-validation-confirm-submit' }) do = _("Yes, add it") .gl-mt-3 - = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-confirm js-add-ssh-key-validation-original-submit qa-add-key-button" + = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit qa-add-key-button", pajamas_button: true diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 8016d989ff1..04fa1d96204 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -1,39 +1,40 @@ - is_admin = defined?(admin) ? true : false .row.gl-mt-3 .col-md-4 - .card - .card-header + = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c| + - c.header do = _('SSH Key') - %ul.content-list - %li - %span.light= _('Title:') - %strong= @key.title - %li - %span.light= _('Created on:') - %strong= @key.created_at.to_s(:medium) - %li - %span.light= _('Expires:') - %strong= @key.expires_at.try(:to_s, :medium) || _('Never') - %li - %span.light= _('Last used on:') - %strong= @key.last_used_at.try(:to_s, :medium) || _('Never') + - c.body do + %ul.content-list + %li + %span.light= _('Title:') + %strong= @key.title + %li + %span.light= _('Created on:') + %strong= @key.created_at.to_s(:medium) + %li + %span.light= _('Expires:') + %strong= @key.expires_at.try(:to_s, :medium) || _('Never') + %li + %span.light= _('Last used on:') + %strong= @key.last_used_at.try(:to_s, :medium) || _('Never') .col-md-8 = form_errors(@key, type: 'key') unless @key.valid? %pre.well-pre = @key.key - .card - .card-header + = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c| + - c.header do = _('Fingerprints') - %ul.content-list - %li - %span.light= 'MD5:' - %code.key-fingerprint= @key.fingerprint - - if @key.fingerprint_sha256.present? + - c.body do + %ul.content-list %li - %span.light= 'SHA256:' - %code.key-fingerprint= @key.fingerprint_sha256 - + %span.light= 'MD5:' + %code.key-fingerprint= @key.fingerprint + - if @key.fingerprint_sha256.present? + %li + %span.light= 'SHA256:' + %code.key-fingerprint= @key.fingerprint_sha256 .col-md-12 .float-right diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 35bf7d81502..69e92b9e508 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,7 +1,8 @@ - page_title _('SSH Keys') +- add_page_specific_style 'page_bundles/profile' - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 0f4b130a774..23a0d824bfe 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -10,7 +10,7 @@ %li= msg = hidden_field_tag :notification_type, 'global' - .row.gl-mt-3 + .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 257255eb4d7..99c89dcebb4 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -2,7 +2,7 @@ - page_title _('Password') - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -14,7 +14,7 @@ = _('Change your password') - else = _('Change your password or recover your current one') - = form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| + = gitlab_ui_form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| = form_errors(@user) - unless @user.password_automatically_set? @@ -31,6 +31,7 @@ = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } .gl-mt-3.gl-mb-3 - = f.submit _('Save password'), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'save_password_button' } + = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true - unless @user.password_automatically_set? - = link_to _('I forgot my password'), reset_profile_password_path, method: :put + = render Pajamas::ButtonComponent.new(href: reset_profile_password_path, variant: :link, method: :put) do + = _('I forgot my password') diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index 6f260eb4cc0..a0a9077afe4 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -3,7 +3,7 @@ %h1.page-title.gl-font-size-h-display= _('Set up new password') %hr -= form_for @user, url: profile_password_path, method: :post do |f| += gitlab_ui_form_for @user, url: profile_password_path, method: :post do |f| %p.slead = _('Please set a new password before proceeding.') %br @@ -29,4 +29,4 @@ .col-sm-10 = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } .form-actions - = f.submit _('Set new password'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'set_new_password_button' } + = f.submit _('Set new password'), data: { qa_selector: 'set_new_password_button' }, pajamas_button: true diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index e16108c5c22..a1d6ef3fec5 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -104,6 +104,10 @@ = f.gitlab_ui_checkbox_component :markdown_surround_selection, s_('Preferences|Surround text selection when typing quotes or brackets'), help_text: sprintf(s_( "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe + .form-group + = f.gitlab_ui_checkbox_component :markdown_automatic_lists, + s_('Preferences|Automatically add new list items'), + help_text: html_escape(s_('Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below.')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe } .form-group = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index f38d6021b18..dfaa4c31cdf 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title s_("Profiles|Edit Profile") - page_title s_("Profiles|Edit Profile") +- add_page_specific_style 'page_bundles/profile' - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host @@ -25,15 +26,21 @@ .col-lg-8 .avatar-image = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do - = image_tag avatar_icon_for_user(@user, 96), alt: '', class: 'avatar s96' + = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') %h5.gl-mt-0= s_("Profiles|Upload new avatar") - .gl-my-3 - %button.gl-button.btn.btn-default.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") - %span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") + .gl-display-flex.gl-align-items-center.gl-my-3 + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do + = s_("Profiles|Choose file...") + %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") - if @user.avatar? - = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'gl-button btn btn-danger-secondary btn-sm gl-mt-5' + = render Pajamas::ButtonComponent.new(variant: :danger, + category: :secondary, + href: profile_avatar_path, + button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, + method: :delete) do + = s_("Profiles|Remove avatar") .col-lg-12 %hr .row.js-search-settings-section @@ -54,9 +61,8 @@ %h4.gl-mt-0= s_("Profiles|Time settings") %p= s_("Profiles|Set your local time zone.") .col-lg-8 - %h5= _("Time zone") - = dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg gl-w-full!', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) - %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone } + = f.label :user_timezone, _("Time zone") + .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone } } .col-lg-12 %hr .row.js-search-settings-section @@ -134,9 +140,12 @@ = f.gitlab_ui_checkbox_component :include_private_contributions, s_('Profiles|Include private contributions on my profile'), help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") - %hr - = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn' - = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel' + .row.js-hide-when-nothing-matches-search + .col-lg-12 + %hr + = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true + = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do + = s_('TagsPage|Cancel') #password-prompt-modal @@ -146,19 +155,19 @@ .modal-header %h4.modal-title = s_("Profiles|Position and size your new avatar") - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") } - %span{ "aria-hidden": "true" } × + = render Pajamas::ButtonComponent.new(category: :tertiary, + icon: 'close', + button_options: { class: 'close', "data-dismiss": "modal", "aria-label" => _("Close") }) .modal-body .profile-crop-image-container %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } - .crop-controls + .gl-text-center.gl-mt-4 .btn-group - %button.btn.gl-button.btn-default{ data: { method: 'zoom', option: '-0.1' } } - %span - = sprite_icon('search-minus') - %button.btn.gl-button.btn-default{ data: { method: 'zoom', option: '0.1' } } - %span - = sprite_icon('search-plus') + = render Pajamas::ButtonComponent.new(icon: 'search-minus', + button_options: {data: { method: 'zoom', option: '-0.1' }}) + = render Pajamas::ButtonComponent.new(icon: 'search-plus', + button_options: {data: { method: 'zoom', option: '0.1' }}) .modal-footer - %button.btn.gl-button.btn-confirm.js-upload-user-avatar{ type: 'button' } + = render Pajamas::ButtonComponent.new(variant: :confirm, + button_options: { class: 'js-upload-user-avatar'}) do = s_("Profiles|Set new profile picture") diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 855c73fd323..4c045574834 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -17,6 +17,13 @@ = _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.") %p = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.') + - if @error + = render Pajamas::AlertComponent.new(title: @error[:message], + variant: :danger, + alert_options: { class: 'gl-mb-3' }, + dismissible: false) do |c| + = c.body do + = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' .js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } } - else @@ -46,6 +53,7 @@ - if @error = render Pajamas::AlertComponent.new(title: @error[:message], variant: :danger, + alert_options: { class: 'gl-mb-3' }, dismissible: false) do |c| = c.body do = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml index 55e609c0ffb..47d60593b4a 100644 --- a/app/views/projects/_fork_suggestion.html.haml +++ b/app/views/projects/_fork_suggestion.html.haml @@ -2,6 +2,7 @@ - message = message_base.html_safe % { edit_start: '<span class="js-file-fork-suggestion-section-action">'.html_safe, edit_end: '</span>'.html_safe } .js-file-fork-suggestion-section.file-fork-suggestion.hidden %span.file-fork-suggestion-note= message - = link_to s_('ForkSuggestion|Fork'), nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-confirm-secondary' - %button.js-cancel-fork-suggestion-button.gl-button.btn.btn-grouped{ type: 'button' } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, category: :secondary, button_options: { class: "js-fork-suggestion-button btn-grouped" }) do + = s_('ForkSuggestion|Fork') + = render Pajamas::ButtonComponent.new(button_options: { class: "js-cancel-fork-suggestion-button btn-grouped" }) do = s_('ForkSuggestion|Cancel') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 7ff58d12b9c..a862b841008 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -24,7 +24,7 @@ %span.gl-ml-3.gl-mb-3 = render 'shared/members/access_request_links', source: @project - = cache_if(cache_enabled, [@project, :buttons, current_user, @notification_setting], expires_in: 1.day) do + = cache_if(cache_enabled, [@project, @project.star_count, @project.forks_count, :buttons, current_user, @notification_setting], expires_in: 1.day) do .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5 - if current_user - if current_user.admin? diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 98cd831d6f1..0699e39b420 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -92,5 +92,5 @@ -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675 = render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label -= f.submit _('Create project'), class: "btn gl-button btn-confirm js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" } += f.submit _('Create project'), class: "js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true = link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index 02aa1f7e93b..e3aa2d8afc9 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -1,7 +1,7 @@ - return unless can?(current_user, :change_namespace, @project) - form_id = "transfer-project-form" - hidden_input_id = "new_namespace_id" -- initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id } +- initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id, project_id: @project.id } .sub-section %h4.danger-title= _('Transfer project') diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 3ebac785d55..c91dfe6d28e 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _('Artifacts') - page_title @path.presence, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') +- add_page_specific_style 'page_bundles/tree' = render "projects/jobs/header" diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml index 1ad70506be4..e16e3ef266d 100644 --- a/app/views/projects/artifacts/file.html.haml +++ b/app/views/projects/artifacts/file.html.haml @@ -1,4 +1,5 @@ - page_title @path, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') +- add_page_specific_style 'page_bundles/tree' = render "projects/jobs/header" diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index f2c4fe017f2..dd041377b49 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,6 +1,7 @@ - page_title _("Blame"), @blob.path, @ref +- add_page_specific_style 'page_bundles/tree' -#blob-content-holder.tree-holder{ data: { testid: 'blob-content-holder' } } +#blob-content-holder.tree-holder.js-per-page{ data: { testid: 'blob-content-holder', per_page: @blame_per_page } } = render "projects/blob/breadcrumb", blob: @blob, blame: true .file-holder.gl-overflow-hidden diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 4139be053f8..9fd542e0cfb 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -10,7 +10,7 @@ %ul.blob-commit-info = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref - = render_if_exists 'projects/blob/owners', blob: blob + #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref } } = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 398ca3dd27c..bd08ab67cd3 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -28,16 +28,12 @@ .file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end - if is_markdown - = render 'shared/blob/markdown_buttons', show_fullscreen_button: false - = button_tag class: 'soft-wrap-toggle btn gl-button btn-default', type: 'button', tabindex: '-1' do - .no-wrap - = sprite_icon('soft-unwrap', css_class: 'gl-button-icon') - %span.gl-button-text - No wrap - .soft-wrap - = sprite_icon('soft-wrap', css_class: 'gl-button-icon') - %span.gl-button-text - Soft wrap + = render 'shared/blob/markdown_buttons', show_fullscreen_button: false, supports_file_upload: false + %span.soft-wrap-toggle + = render Pajamas::ButtonComponent.new(icon: 'soft-unwrap', button_options: { class: 'no-wrap' }) do + = _("No wrap") + = render Pajamas::ButtonComponent.new(icon: 'soft-wrap', button_options: { class: 'soft-wrap' }) do + = _("Soft wrap") .file-editor.code - if Feature.enabled?(:source_editor_toolbar, current_user) diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml index 8f1c2f93162..0fa4a90e28b 100644 --- a/app/views/projects/blob/_pipeline_tour_success.html.haml +++ b/app/views/projects/blob/_pipeline_tour_success.html.haml @@ -2,5 +2,5 @@ 'go-to-pipelines-path': project_pipelines_path(@project), 'project-merge-requests-path': project_merge_requests_path(@project), 'example-link': help_page_path('ci/examples/index.md', anchor: 'gitlab-cicd-examples'), - 'code-quality-link': help_page_path('user/project/merge_requests/code_quality'), + 'code-quality-link': help_page_path('ci/testing/code_quality'), 'human-access': @project.team.human_max_access(current_user&.id) } } diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index a76e61bc3dd..249c474587c 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -2,14 +2,14 @@ .template-selector-dropdowns-wrap .template-type-selector.js-template-type-selector-wrap.hidden - toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type' - = dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', dropdown_class: 'dropdown-menu-selectable' }) + = dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector', dropdown_class: 'dropdown-menu-selectable', data: { qa_selector: 'template_type_dropdown' } }) .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector qa-license-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } } ) .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } } ) .metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } } ) #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template] } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } } ) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } } ) diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index 41a0045be89..6f559708d40 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,4 +1,4 @@ -- if markup?(@blob.name) +- if Gitlab::MarkupHelper.markup?(@blob.name) .file-content.md = markup(@blob.name, @content) - else diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 33b2229f5d1..c8cf12c36f9 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _('Repository') - page_title @blob.path, @ref +- add_page_specific_style 'page_bundles/tree' - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco', prefetch: true) diff --git a/app/views/projects/boards/index.html.haml b/app/views/projects/boards/index.html.haml index bb56769bd3f..e5b5f6404bb 100644 --- a/app/views/projects/boards/index.html.haml +++ b/app/views/projects/boards/index.html.haml @@ -1 +1 @@ -= render "shared/boards/show", board: @boards.first += render "shared/boards/show", board: @board diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml index 46665fdb450..27525b441ab 100644 --- a/app/views/projects/branch_rules/_show.html.haml +++ b/app/views/projects/branch_rules/_show.html.haml @@ -9,4 +9,4 @@ = _('Define rules for who can push, merge, and the required approvals for each branch.') .settings-content.gl-pr-0 - #js-branch-rules{ data: { project_path: @project.full_path } } + #js-branch-rules{ data: { project_path: @project.full_path, branch_rules_path: project_settings_repository_branch_rules_path(@project) } } diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 10a6bc6b524..34aecd31c57 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -2,17 +2,17 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') .git-clone-holder.js-git-clone-holder - %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } } %span.gl-mr-2.js-clone-dropdown-label = _('Clone') = sprite_icon("chevron-down", css_class: "icon") - %ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class } + %ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown{ class: dropdown_class, data: { qa_selector: 'clone_dropdown_content' } } - if ssh_enabled? %li{ class: 'gl-px-4!' } %label.label-bold = _('Clone with SSH') .input-group.btn-group - = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') } + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } .input-group-append = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' @@ -21,7 +21,7 @@ %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group.btn-group - = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') } + = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index f6084cfcde8..d36aed44e18 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -1,4 +1,4 @@ .btn-group.ml-0.w-100 - Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index| - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) - = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "gl-button btn btn-sm #{index == 0 ? "btn-confirm" : "btn-default"}" + = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "gl-button btn btn-sm #{index == 0 ? 'btn-confirm' : 'btn-default'}" diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index c57b6dbe28c..3621853430d 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -14,5 +14,5 @@ = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{button_class}" do = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') - = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default btn-sm count has-tooltip #{count_class}" do + = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default btn-sm count has-tooltip fork-count #{count_class}" do = @project.forks_count diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index f607a21ad21..eaf906ad89f 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,15 +1,13 @@ - if current_user + - starred = current_user.starred?(@project) + - icon = starred ? 'star' : 'star-o' + - button_text = starred ? s_('ProjectOverview|Unstar') : s_('ProjectOverview|Star') + - button_text_classes = starred ? 'starred' : '' .count-badge.d-inline-flex.align-item-stretch.gl-mr-3.btn-group - %button.gl-button.btn.btn-default.btn-sm.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } } - - if current_user.starred?(@project) - = sprite_icon('star', css_class: 'icon') - %span.starred= s_('ProjectOverview|Unstar') - - else - = sprite_icon('star-o', css_class: 'icon') - %span= s_('ProjectOverview|Star') + = render Pajamas::ButtonComponent.new(size: :small, icon: icon, button_text_classes: button_text_classes, button_options: { class: 'star-btn toggle-star', data: { endpoint: toggle_star_project_path(@project, :json) } }) do + - button_text = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do = @project.star_count - - else .count-badge.d-inline-flex.align-item-stretch.gl-mr-3.btn-group = link_to new_user_session_path, class: 'gl-button btn btn-default btn-sm has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index c53205b6c58..d00d9f62999 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -16,7 +16,7 @@ .settings-content - url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project) - = form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f| + = gitlab_ui_form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f| %fieldset.gl-mt-0.gl-mb-3 .gl-mb-3 %h5.gl-mt-0 @@ -29,4 +29,4 @@ .form-text.text-muted = _("The maximum file size is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } - = f.submit _('Start cleanup'), class: 'gl-button btn btn-confirm' + = f.submit _('Start cleanup'), pajamas_button: true diff --git a/app/views/projects/cluster_agents/show.html.haml b/app/views/projects/cluster_agents/show.html.haml index a2d3426d99c..98a2c9c3e6d 100644 --- a/app/views/projects/cluster_agents/show.html.haml +++ b/app/views/projects/cluster_agents/show.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/cluster_agents' - add_to_breadcrumbs _('Kubernetes'), project_clusters_path(@project) - page_title @agent_name diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index fb31ac44118..978d83bf2b4 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,3 +1,3 @@ - if signature - - uri = "projects/commit/#{"x509/" if x509_signature?(signature)}" + - uri = "projects/commit/#{'x509/' if x509_signature?(signature)}" = render partial: "#{uri}#{signature.verification_status}_signature_badge", locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 7c896cd71ef..fb30bfc2953 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -17,7 +17,7 @@ - content = capture do - if show_user .clearfix - - uri_signature_badge_user = "projects/commit/#{"x509/" if x509_signature?(signature)}signature_badge_user" + - uri_signature_badge_user = "projects/commit/#{'x509/' if x509_signature?(signature)}signature_badge_user" = render partial: "#{uri_signature_badge_user}", locals: { signature: signature } - if x509_signature?(signature) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 6f44c130603..bf6b628dd36 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -29,7 +29,7 @@ - if view_details && merge_request = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)] - else - = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item #{"font-italic" if commit.message.empty?}", data: link_data_attrs) + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item #{'font-italic' if commit.message.empty?}", data: link_data_attrs) %span.commit-row-message.d-inline.d-sm-none · = commit.short_id @@ -51,7 +51,7 @@ = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project - if commit.description? - %pre{ class: ["commit-row-description gl-mb-3", (collapsible ? "js-toggle-content" : "d-block")] } + %pre{ class: ["commit-row-description gl-mb-3 gl-white-space-pre-line", (collapsible ? "js-toggle-content" : "d-block")] } = preserve(markdown_field(commit, :description)) .commit-actions.flex-row diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 6b06584ea25..ae68a13929e 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -1,5 +1,5 @@ - breadcrumb_title _("Commits") - +- add_page_specific_style 'page_bundles/tree' - page_title _("Commits"), @ref = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_path(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 95186b85838..1bdf3d1e6e3 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -5,7 +5,7 @@ .js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } } #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) } -- if @commits.present? +- if @commits.present? || @diffs.present? -# Only show commit list in the first page - hide_commit_list = params[:page].present? && params[:page] != '1' = render "projects/commits/commit_list" unless hide_commit_list diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index eba0f336f80..04712cd59f7 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -17,7 +17,7 @@ - else .form-group = f.label :default_branch, _("Default branch"), class: 'label-bold' - = f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide', data: { qa_selector: 'default_branch_dropdown' }}) + .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id } } .form-group - help_text = _("When merge requests and commits in the default branch close, any issues they reference also close.") @@ -26,4 +26,4 @@ _("Auto-close referenced issues on default branch"), help_text: (help_text + " " + help_icon).html_safe - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml index 04e364d6b15..91444a00334 100644 --- a/app/views/projects/deploy_keys/edit.html.haml +++ b/app/views/projects/deploy_keys/edit.html.haml @@ -6,5 +6,5 @@ = gitlab_ui_form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true = link_to _('Cancel'), project_settings_repository_path(@project), class: 'gl-button btn btn-default btn-cancel' diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index e92297a5a6a..e3688c8d323 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -23,7 +23,7 @@ - if deployment.deployable .table-mobile-content .flex-truncate-parent - .flex-truncate-child + .flex-truncate-child.has-tooltip.gl-white-space-normal.gl-md-white-space-nowrap{ :title => "#{deployment.deployable.name} (##{deployment.deployable.id})", data: { container: 'body' } } = link_to deployment_path(deployment), class: 'build-link' do #{deployment.deployable.name} (##{deployment.deployable.id}) - else diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index a7befabdc96..223f7520b47 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,7 +1,4 @@ - if deployment.deployable && can?(current_user, :create_deployment, deployment) - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') - = button_tag class: 'js-confirm-rollback-modal-button gl-button btn btn-default btn-icon has-tooltip', type: 'button', data: { environment_name: @environment.name, commit_short_sha: deployment.short_sha, commit_url: project_commit_path(@project, deployment.sha), is_last_deployment: deployment.last?.to_s, retry_path: retry_project_job_path(@environment.project, deployment.deployable) }, title: tooltip do - - if deployment.last? - = sprite_icon('repeat', css_class: 'gl-icon') - - else - = sprite_icon('redo', css_class: 'gl-icon') + - icon = deployment.last? ? 'repeat' : 'redo' + = render Pajamas::ButtonComponent.new(icon: icon, button_options: { title: tooltip, class: 'js-confirm-rollback-modal-button has-tooltip', data: { environment_name: @environment.name, commit_short_sha: deployment.short_sha, commit_url: project_commit_path(@project, deployment.sha), is_last_deployment: deployment.last?.to_s, retry_path: retry_project_job_path(@environment.project, deployment.deployable) } }) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 70df995cdf3..f6e3c15c08b 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -28,7 +28,7 @@ %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } -- if show_merge_request_settings_callout? +- if show_merge_request_settings_callout?(@project) %section.settings.expanded = render Pajamas::AlertComponent.new(variant: :info, title: _('Merge requests and approvals settings have moved.'), diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index cd7339edd1a..31041d124e4 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -1,3 +1,5 @@ +- add_page_specific_style 'page_bundles/prometheus' + - page_title _("Metrics Dashboard"), @environment.name .prometheus-container diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 2e024b8ffc4..7cd4ab08680 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,4 +1,5 @@ - page_title _("Find File"), @ref +- add_page_specific_style 'page_bundles/tree' .file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } .nav-block diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 04d400688d4..edf8f71c673 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -1,4 +1,5 @@ - page_title _("Repository Analytics") +- add_page_specific_style 'page_bundles/graph_charts' .mb-3 %h3 diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index b350455807d..ca71990f5e3 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -12,7 +12,7 @@ = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3' + = f.submit _('Save changes'), pajamas_button: true = render 'shared/web_hooks/test_button', hook: @hook = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this project hook?'), confirm_btn_variant: 'danger' } diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index 7d62a851aa1..0476193c2cb 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -2,13 +2,13 @@ - breadcrumb_title _('Webhook Settings') - page_title _('Webhooks') -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4 = render 'shared/web_hooks/title_and_docs', hook: @hook .col-lg-8.gl-mb-3 = gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - = f.submit 'Add webhook', class: 'gl-button btn btn-confirm' + = f.submit 'Add webhook', pajamas_button: true = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml index 5043f94bd5c..7a1e7f503f8 100644 --- a/app/views/projects/incidents/show.html.haml +++ b/app/views/projects/incidents/show.html.haml @@ -2,6 +2,7 @@ - add_to_breadcrumbs _("Incidents"), project_incidents_path(@project) - breadcrumb_title @issue.to_reference - page_title "#{@issue.title} (#{@issue.to_reference})", _("Incidents") +- add_page_specific_style 'page_bundles/incidents' - add_page_specific_style 'page_bundles/issues_show' = render 'projects/issuable/show', issuable: @issue diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 11b652cc818..40935ab6f70 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -6,6 +6,9 @@ #js-vue-notes{ data: { notes_data: notes_data(@issue).to_json, noteable_data: serialize_issuable(@issue, with_blocking_issues: true), noteable_type: 'Issue', + notes_filters: UserPreference.notes_filters.to_json, + notes_filter_value: current_user&.notes_filter_for(@issue), target_type: 'issue', + show_timeline_view_toggle: show_timeline_view_toggle?(@issue).to_s, current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json, can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}" } } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 4d4645c7087..1d3320e4f82 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,10 +1,5 @@ %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } } .issuable-info-container - - if @can_bulk_update - .issue-check.hidden - - checkbox_id = dom_id(issue, "selected") - %label.gl-sr-only{ for: checkbox_id }= issue.title - = check_box_tag checkbox_id, nil, false, 'data-id' => issue.id, class: "selected-issuable" .issuable-main-info .issue-title.title %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index bc2136b89fb..c0de711136a 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,2 +1,2 @@ - if Feature.enabled?(:work_items_hierarchy, @project) - .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } } + .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_namespace: @project.namespace.path, project_path: @project.full_path, wi: work_items_index_data(@project) } } diff --git a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml index bad75ac2cd9..2ed5675c0ad 100644 --- a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml @@ -4,7 +4,7 @@ - can_admin_issues = can?(current_user, :admin_issue, @project) - title_text = s_("ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab") -.non-empty-state.media +.media.gl-border-b.gl-pb-3.gl-text-left .svg-content = render partial: 'shared/empty_states/icons/service_desk_callout', formats: :svg diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 06c422fc4d6..76b725d140c 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,6 +2,7 @@ - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title @issue.to_reference - page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues") +- add_page_specific_style 'page_bundles/incidents' - add_page_specific_style 'page_bundles/issues_show' - add_page_specific_style 'page_bundles/work_items' diff --git a/app/views/projects/jobs/_user.html.haml b/app/views/projects/jobs/_user.html.haml index 90ce581a903..03cbabb0c2a 100644 --- a/app/views/projects/jobs/_user.html.haml +++ b/app/views/projects/jobs/_user.html.haml @@ -1,7 +1,7 @@ by %a{ href: user_path(@build.user) } %span.d-none.d-sm-inline - = image_tag avatar_icon_for_user(@build.user, 24), class: "avatar s24" + = render Pajamas::AvatarComponent.new(@build.user, size: 24, alt: "") %strong{ data: { toggle: 'tooltip', placement: 'top', title: @build.user.to_reference } } = @build.user.name %strong.d-inline.d-sm-none= @build.user.to_reference diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml index 64d35b4dfe6..820927fdd1a 100644 --- a/app/views/projects/merge_requests/_awards_block.html.haml +++ b/app/views/projects/merge_requests/_awards_block.html.haml @@ -1,5 +1,2 @@ .content-block.emoji-block.emoji-list-container.js-noteable-awards - = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) do - .gl-my-2.gl-xs-w-full - #js-vue-sort-issue-discussions - = render "projects/merge_requests/discussion_filter" + = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index 22571b11639..478db70877d 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -1,8 +1,7 @@ - display_issuable_type = issuable_display_type(@merge_request) .float-left.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-new-dropdown.gl-md-w-auto.gl-w-full - = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", data: { 'toggle' => 'dropdown' } do - %span.gl-sr-only= _('Toggle dropdown') + = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Merge request actions'), testid: 'merge-request-actions' } do = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon" = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do %span.gl-new-dropdown-button-text= _('Merge request actions') @@ -11,7 +10,7 @@ .gl-new-dropdown-inner .gl-new-dropdown-contents %ul - - if !@merge_request.merged? && current_user && moved_mr_sidebar_enabled? + - if current_user && moved_mr_sidebar_enabled? %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point %li.gl-new-dropdown-divider %hr.dropdown-divider diff --git a/app/views/projects/merge_requests/_discussion_filter.html.haml b/app/views/projects/merge_requests/_discussion_filter.html.haml deleted file mode 100644 index 96886661a8d..00000000000 --- a/app/views/projects/merge_requests/_discussion_filter.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), - notes_filters: UserPreference.notes_filters.to_json } } diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 98d2928fc97..71f8e4c32f5 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,9 +1,12 @@ %li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } } - if @can_bulk_update - .issue-check.hidden - - checkbox_id = dom_id(merge_request, "selected") - %label.gl-sr-only{ for: checkbox_id }= merge_request.title - = check_box_tag checkbox_id, nil, false, 'data-id' => merge_request.id, class: "selected-issuable" + .issue-check.gl-mr-3.hidden + = render Pajamas::CheckboxTagComponent.new(name: dom_id(merge_request, "selected"), + value: nil, + checkbox_options: { 'data-id' => merge_request.id }) do |c| + = c.label do + %span.gl-sr-only + = merge_request.title .issuable-info-container .issuable-main-info diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml index 00d12423eb9..1efea6a1d37 100644 --- a/app/views/projects/merge_requests/_nav_btns.html.haml +++ b/app/views/projects/merge_requests/_nav_btns.html.haml @@ -5,7 +5,8 @@ .js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } } - if @can_bulk_update - = button_tag _("Edit merge requests"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" + = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-mr-3 js-bulk-update-toggle' }) do + = _("Edit merge requests") - if merge_project - = link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: _("New merge request") do - = _('New merge request') + = render Pajamas::ButtonComponent.new(href: new_merge_request_path, variant: :confirm) do + = _("New merge request") diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 783e3ac97c1..4f6983c6fe3 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -12,6 +12,7 @@ window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}'; window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}'; + window.gl.mrWidgetData.security_configuration_path = '#{project_security_configuration_path(@project)}'; window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}'; window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/approvals/index.md")}'; diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index d34848c801d..d77d5231a7d 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -60,10 +60,10 @@ %section .issuable-discussion.js-vue-notes-event - if @merge_request.description.present? - .detail-page-description + .detail-page-description.gl-pb-0 = render "projects/merge_requests/description" - = render "projects/merge_requests/widget" = render "projects/merge_requests/awards_block" + = render "projects/merge_requests/widget" - if mr_action === "show" - add_page_startup_api_call Feature.enabled?(:paginated_mr_discussions, @project) ? discussions_path(@merge_request, per_page: 20) : discussions_path(@merge_request) - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json) @@ -72,6 +72,8 @@ endpoint_metadata: @endpoint_metadata_url, noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), noteable_type: 'MergeRequest', + notes_filters: UserPreference.notes_filters.to_json, + notes_filter_value: current_user&.notes_filter_for(@merge_request), target_type: 'merge_request', help_page_path: suggest_changes_help_path, current_user_data: @current_user_data, diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index c11d5e7c9b6..fb7c1130f5c 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,6 +1,8 @@ = gitlab_ui_form_for [@project, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone) + - if @redirect_path.present? + = f.hidden_field(:redirect_path, name: :redirect_path, id: :redirect_path, value: @redirect_path) .form-group.row .col-form-label.col-sm-2 = f.label :title, _('Title') diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 28b433b2514..4b549aaf1cd 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -3,12 +3,10 @@ .form-group = f.label :auth_method, _('Authentication method'), class: 'label-bold' - .select-wrapper - = f.select :auth_method, - options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control gl-form-select select-control js-mirror-auth-type qa-authentication-method" } - = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") - = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" + = f.select :auth_method, + options_for_select(auth_options, mirror.auth_method), + {}, { class: "custom-select gl-form-select js-mirror-auth-type qa-authentication-method" } + = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" .form-group .well-password-auth.collapse.js-well-password-auth diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 2ae7d300979..c98f88fa31e 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -35,7 +35,7 @@ = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' .panel-footer - = f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror + = f.submit _('Mirror repository'), class: 'js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror, pajamas_button: true - else = render Pajamas::AlertComponent.new(dismissible: false) do |c| = c.body do diff --git a/app/views/projects/network/_head.html.haml b/app/views/projects/network/_head.html.haml index 701cb37a1c8..e430dc2f372 100644 --- a/app/views/projects/network/_head.html.haml +++ b/app/views/projects/network/_head.html.haml @@ -1,4 +1,4 @@ -.row-content-block.second-block.content-component-block +.row-content-block.second-block.content-component-block.gl-px-0.gl-py-3 .tree-ref-holder = render partial: 'shared/ref_switcher', locals: {destination: 'graph'} diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml index 993026d2884..ff30c9ce1ea 100644 --- a/app/views/projects/pages/_destroy.haml +++ b/app/views/projects/pages/_destroy.haml @@ -1,14 +1,16 @@ - if @project.pages_deployed? - if can?(current_user, :remove_pages, @project) - .card.border-danger - .card-header.bg-danger.text-white + = render Pajamas::CardComponent.new(card_options: { class: 'border-danger' }, header_options: {class: 'bg-danger text-white'}) do |c| + - c.with_header do = s_('GitLabPages|Remove pages') - .errors-holder - .card-body - %p.gl-mb-0 - = s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.') - .card-footer - = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, method: :delete, class: "btn gl-button btn-danger", "aria-label": s_('GitLabPages|Remove pages') + - c.with_body do + = s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.') + - c.with_footer do + = render Pajamas::ButtonComponent.new(href: project_pages_path(@project), + variant: :danger, + method: :delete, + button_options: {data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_('GitLabPages|Remove pages')}) do + = s_('GitLabPages|Remove pages') - else .nothing-here-block = s_('GitLabPages|Only project maintainers can remove pages') diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml index 5dea6b02e36..f1f3510d0f8 100644 --- a/app/views/projects/pages/new.html.haml +++ b/app/views/projects/pages/new.html.haml @@ -1,7 +1,8 @@ -- if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group) - #js-pages{ data: @pipeline_wizard_data } +%section.js-search-settings-section + - if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group) + #js-pages{ data: @pipeline_wizard_data } -- else - = render 'header' + - else + = render 'header' - = render 'use' + = render 'use' diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 5d5ca2aaaf3..ab692d1830a 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -39,5 +39,5 @@ %div = f.gitlab_ui_checkbox_component :active, _('Active'), checkbox_options: { value: @schedule.active, required: false } .footer-block - = f.submit _('Save pipeline schedule'), class: 'btn gl-button btn-confirm' + = f.submit _('Save pipeline schedule'), pajamas_button: true = link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn gl-button btn-default btn-cancel' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 10dc74647b2..7b16564dfa2 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -4,9 +4,9 @@ = pipeline_schedule.description %td.branch-name-cell.gl-text-truncate - if pipeline_schedule.for_tag? - = sprite_icon('tag', size: 12) + = sprite_icon('tag', size: 12, css_class: 'gl-vertical-align-middle!' ) - else - = sprite_icon('fork', size: 12) + = sprite_icon('fork', size: 12, css_class: 'gl-vertical-align-middle!') - if pipeline_schedule.ref.present? = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name" %td @@ -24,7 +24,7 @@ = s_("PipelineSchedules|Inactive") %td - if pipeline_schedule.owner - = image_tag avatar_icon_for_user(pipeline_schedule.owner, 20), class: "avatar s20" + = render Pajamas::AvatarComponent.new(pipeline_schedule.owner, size: 24, class: "gl-mr-2") = link_to user_path(pipeline_schedule.owner) do = pipeline_schedule.owner&.name %td diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml index 642b458eea6..3f843ce6aec 100644 --- a/app/views/projects/pipeline_schedules/edit.html.haml +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -7,4 +7,7 @@ = _("Edit Pipeline Schedule") %hr -= render "form" +- if Feature.enabled?(:pipeline_schedules_vue, @project) + #pipeline-schedules-form-edit{ data: { full_path: @project.full_path } } +- else + = render "form" diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 661cf465081..47ad8cc826d 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -1,22 +1,28 @@ - breadcrumb_title _("Schedules") - page_title _("Pipeline Schedules") - add_page_specific_style 'page_bundles/pipeline_schedules' +- add_page_specific_style 'page_bundles/ci_status' #pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_url: image_path('illustrations/pipeline_schedule_callout.svg') } } -.top-area - - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } - = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope - - if can?(current_user, :create_pipeline_schedule, @project) - .nav-controls - = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-confirm' do - %span= _('New schedule') - -- if @schedules.present? - %ul.content-list - = render partial: "table" +- if Feature.enabled?(:pipeline_schedules_vue, @project) + #pipeline-schedules-app{ data: { full_path: @project.full_path } } - else - .card.bg-light.gl-mt-3 - .nothing-here-block= _("No schedules") + .top-area + - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } + = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope + + - if can?(current_user, :create_pipeline_schedule, @project) + .nav-controls + = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-confirm' do + %span= _('New schedule') + + - if @schedules.present? + %ul.content-list + = render partial: "table" + - else + = render Pajamas::CardComponent.new(card_options: { class: 'bg-light gl-mt-3 gl-text-center' }) do |c| + - c.body do + = _("No schedules") -#pipeline-take-ownership-modal + #pipeline-take-ownership-modal diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml index 3b4acf5b8c5..d3757d0e339 100644 --- a/app/views/projects/pipeline_schedules/new.html.haml +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -8,4 +8,7 @@ %h1.page-title.gl-font-size-h-display = _("Schedule a new pipeline") -= render "form" +- if Feature.enabled?(:pipeline_schedules_vue, @project) + #pipeline-schedules-form-new{ data: { full_path: @project.full_path } } +- else + = render "form" diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 07e299d71ea..2e403358e2e 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -9,18 +9,21 @@ .well-segment.pipeline-info{ class: "gl-align-items-baseline!" } .icon-container = sprite_icon('clock', css_class: 'gl-top-0!') - = pluralize @pipeline.total_size, "job" - = @pipeline.ref_text + - jobs = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) + = s_('Pipelines|%{jobs} %{ref_text} in %{duration}').html_safe % { jobs: jobs, ref_text: @pipeline.ref_text, duration: time_interval_in_words(@pipeline.duration) } + - else + = jobs + = @pipeline.ref_text - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + = s_("Pipelines|(queued for %{queued_duration})") % { queued_duration: time_interval_in_words(@pipeline.queued_duration)} - if has_pipeline_badges?(@pipeline) .well-segment.qa-pipeline-badges .icon-container = sprite_icon('flag', css_class: 'gl-top-0!') + - if @pipeline.schedule? + = gl_badge_tag _('Scheduled'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-scheduled', title: _('This pipeline was triggered by a schedule.') } - if @pipeline.child? - text = sprintf(s_('Pipelines|Child pipeline (%{link_start}parent%{link_end})'), { link_start: "<a href='#{pipeline_path(@pipeline.triggered_by_pipeline)}' class='text-underline'>", link_end: "</a>"}).html_safe = gl_badge_tag text, { variant: :info, size: :sm }, { class: 'js-pipeline-child has-tooltip', title: s_("Pipelines|This is a child pipeline within the parent pipeline") } diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index a4144f8ab0d..d2b2a58fcf8 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -12,6 +12,7 @@ ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, + project_path: @project.full_path, project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'), settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } diff --git a/app/views/projects/project_templates/_project_fields_form.html.haml b/app/views/projects/project_templates/_project_fields_form.html.haml index 7908550ca88..c3528b421b9 100644 --- a/app/views/projects/project_templates/_project_fields_form.html.haml +++ b/app/views/projects/project_templates/_project_fields_form.html.haml @@ -8,5 +8,5 @@ .selected-icon.gl-mr-3 .selected-template .input-group-append - %button.btn.gl-button.btn-default.change-template{ type: "button" } + = render Pajamas::ButtonComponent.new(button_options: { class: 'change-template' }) do = _('Change template') diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index 34fe9a29068..76aadc3be28 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -1,12 +1,12 @@ - content_for :merge_access_levels do .merge_access_levels-container - = dropdown_tag('Select', + = dropdown_tag(_('Select'), options: { toggle_class: 'js-allowed-to-merge wide', dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown', data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'allowed_to_merge_dropdown' }}) - content_for :push_access_levels do .push_access_levels-container - = dropdown_tag('Select', + = dropdown_tag(_('Select'), options: { toggle_class: "js-allowed-to-push js-multiselect wide", dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }}) diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index 277cbf00034..770d79943b3 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -1,4 +1,4 @@ -= form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| += gitlab_ui_form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } = render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c| - c.header do @@ -32,4 +32,4 @@ = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe = render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f - c.footer do - = f.submit s_('ProtectedBranch|Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_button' } + = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml index 4b09d36e7c3..d5111bd8be5 100644 --- a/app/views/projects/protected_branches/shared/_dropdown.html.haml +++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml @@ -2,11 +2,11 @@ = f.hidden_field(:name) -= dropdown_tag('Select branch or create wildcard', += dropdown_tag(_('Select branch or create wildcard'), options: { toggle_class: "js-protected-branch-select js-filter-submit wide monospace qa-protected-branch-select #{toggle_classes}", filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown", - placeholder: "Search protected branches", + placeholder: _("Search protected branches"), footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], @@ -14,6 +14,6 @@ %ul.dropdown-footer-list %li - %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: "New Protected Branch" } - Create wildcard + %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: _("New Protected Branch") } + = _('Create wildcard') %code diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index ba0935fff7d..9ea7f397c0a 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -1,9 +1,9 @@ = form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' } - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c| + - c.header do = _('Protect a tag') - .card-body + - c.body do = form_errors(@protected_tag) .form-group.row = f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right' @@ -19,5 +19,5 @@ .create_access_levels-container = yield :create_access_levels - .card-footer + - c.footer do = f.submit _('Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_tag_button' } diff --git a/app/views/projects/releases/index.html.haml b/app/views/projects/releases/index.html.haml index 9ddf2201fad..975abaefc6c 100644 --- a/app/views/projects/releases/index.html.haml +++ b/app/views/projects/releases/index.html.haml @@ -1,4 +1,5 @@ - page_title _('Releases') +- add_page_specific_style 'page_bundles/releases' - if use_startup_query_for_index_page? - add_page_startup_graphql_call('releases/all_releases', index_page_startup_query_variables) diff --git a/app/views/projects/releases/show.html.haml b/app/views/projects/releases/show.html.haml index 91ee9ad70a3..66b187c8c72 100644 --- a/app/views/projects/releases/show.html.haml +++ b/app/views/projects/releases/show.html.haml @@ -1,5 +1,6 @@ - add_to_breadcrumbs _("Releases"), project_releases_path(@project) - page_title @release.name - page_description @release.description_html +- add_page_specific_style 'page_bundles/releases' #js-show-release-page{ data: data_for_show_page } diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml deleted file mode 100644 index ae0d9ab9908..00000000000 --- a/app/views/projects/repositories/_feed.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- commit = update -%tr - %td - = link_to project_commits_path(@project, commit.head.name) do - %strong - = commit.head.name - - if @project.root_ref?(commit.head.name) - %span.label default - - %td - %div - = link_to project_commits_path(@project, commit.id) do - %code= commit.short_id - = image_tag avatar_icon_for_email(commit.author_email), class: "", width: 16, alt: '' - = markdown(truncate(commit.title, length: 40), pipeline: :single_line, author: commit.author) - %td - %span.float-right.cgray - = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 3df4f3a0bd0..4689e70d907 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -5,6 +5,6 @@ - if @shared_runners_count == 0 = _('This GitLab instance does not provide any shared runners yet. Instance administrators can register shared runners in the admin area.') - else - %h4.underlined-title #{_('Available shared runners:')} #{@shared_runners_count} + %h5.gl-mt-6.gl-mb-0 #{_('Available shared runners:')} #{@shared_runners_count} %ul.bordered-list.available-shared-runners = render partial: 'projects/runners/runner', collection: @shared_runners, as: :runner diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 7ecc8004334..9f598ffb2d1 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -4,7 +4,7 @@ - type_plural = _('project access tokens') - @content_class = 'limit-container-width' unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4 %h4.gl-mt-0 = page_title @@ -24,13 +24,11 @@ = _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .col-lg-8 - - if @new_resource_access_token - = render 'shared/access_tokens/created_container', - type: type, - new_token_value: @new_resource_access_token + #js-new-access-token-app{ data: { access_token_type: type } } - if current_user.can?(:create_resource_access_tokens, @project) = render 'shared/access_tokens/form', + ajax: true, type: type, path: project_settings_access_tokens_path(@project), resource: @project, @@ -39,12 +37,8 @@ access_levels: ProjectMember.permissible_access_level_roles(current_user, @project), default_access_level: Gitlab::Access::GUEST, prefix: :resource_access_token, + description_prefix: :project_access_token, help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token') - = render 'shared/access_tokens/table', - active_tokens: @active_resource_access_tokens, - resource: @project, - type: type, - type_plural: type_plural, - revoke_route_helper: ->(token) { revoke_namespace_project_settings_access_token_path(id: token) }, - no_active_tokens_message: _('This project has no active access tokens.') + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_resource_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true + } } diff --git a/app/views/projects/settings/branch_rules/index.html.haml b/app/views/projects/settings/branch_rules/index.html.haml index 384d504e51f..a7e80101a88 100644 --- a/app/views/projects/settings/branch_rules/index.html.haml +++ b/app/views/projects/settings/branch_rules/index.html.haml @@ -1,6 +1,6 @@ - add_to_breadcrumbs _('Repository Settings'), project_settings_repository_path(@project) -- page_title _('Branch rules') +- page_title s_('BranchRules|Branch rules details') -%h3= _('Branch rules') +%h3.gl-mb-5= s_('BranchRules|Branch rules details') -#js-branch-rules{ data: { project_path: @project.full_path } } +#js-branch-rules{ data: { project_path: @project.full_path, protected_branches_path: project_settings_repository_path(@project, anchor: 'js-protected-branches-settings'), approval_rules_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-approval-settings') } } diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 81526685bfc..5748b4b0330 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -41,4 +41,4 @@ = form.gitlab_ui_radio_component :deploy_strategy, 'timed_incremental', (s_('CICD|Continuous deployment to production using timed incremental rollout') + ' ' + help_link_timed).html_safe = form.gitlab_ui_radio_component :deploy_strategy, 'manual', (s_('CICD|Automatic deployment to staging, manual deployment to production') + ' ' + help_link_incremental).html_safe - = f.submit _('Save changes'), class: "btn gl-button btn-confirm gl-mt-5", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), class: "gl-mt-5", data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/projects/settings/ci_cd/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml index 38d8c8d26e1..99eef38827b 100644 --- a/app/views/projects/settings/ci_cd/_badge.html.haml +++ b/app/views/projects/settings/ci_cd/_badge.html.haml @@ -2,15 +2,15 @@ .col-lg-12 %h4 = badge.title.capitalize - .card - .card-header - %b - = badge.title.capitalize - · - = badge.to_html - .float-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .card-body + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, header_options: { class: 'gl-display-flex gl-align-items-center' }) do |c| + - c.header do + .gl-flex-grow-1 + %b + = badge.title.capitalize + · + = badge.to_html + = render 'shared/ref_switcher', destination: 'badges', align_right: true + - c.body do .row .col-md-2.gl-text-center Markdown diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 9419dacc16f..51d28411b30 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -77,7 +77,7 @@ = _("The maximum file size in megabytes for individual job artifacts.") = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' - = f.submit _('Save changes'), class: "btn gl-button btn-confirm" + = f.submit _('Save changes'), pajamas_button: true %hr diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index c1df7b88352..c4f589f3f91 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -24,7 +24,7 @@ = expanded ? _('Collapse') : _('Expand') %p - auto_devops_url = help_page_path('topics/autodevops/index') - - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') + - quickstart_url = help_page_path('topics/autodevops/cloud_deployments/auto_devops_with_gke') - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } = s_('AutoDevOps|%{auto_devops_start}Automate building, testing, and deploying%{auto_devops_end} your applications based on your continuous integration and delivery configuration. %{quickstart_start}How do I get started?%{quickstart_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } @@ -119,4 +119,3 @@ = link_to _('Learn more'), help_page_path('ci/secure_files/index'), target: '_blank', rel: 'noopener noreferrer' .settings-content #js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } } - diff --git a/app/views/projects/settings/integrations/index.html.haml b/app/views/projects/settings/integrations/index.html.haml index 84635941436..2077d244b24 100644 --- a/app/views/projects/settings/integrations/index.html.haml +++ b/app/views/projects/settings/integrations/index.html.haml @@ -2,8 +2,9 @@ - breadcrumb_title _('Integration Settings') - page_title _('Integrations') -%h3= _('Integrations') -- integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') } -- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) } -%p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe } -= render 'shared/integrations/index', integrations: @integrations +%section.js-search-settings-section + %h3= _('Integrations') + - integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/index') } + - webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) } + %p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe } + = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml index 886e276dea5..7dfd304e07b 100644 --- a/app/views/projects/settings/merge_requests/show.html.haml +++ b/app/views/projects/settings/merge_requests/show.html.haml @@ -4,7 +4,7 @@ %section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests') + %h4= _('Merge requests') = render_if_exists 'projects/merge_request_settings_description_text' .settings-content @@ -13,6 +13,7 @@ = gitlab_ui_form_for @project, url: project_settings_merge_requests_path(@project), html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } = render 'projects/merge_request_settings', form: f - = f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' } + = f.submit _('Save changes'), class: "rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }, pajamas_button: true = render_if_exists 'projects/settings/merge_requests/merge_request_approvals_settings', expanded: true += render_if_exists 'projects/settings/merge_requests/suggested_reviewers_settings', expanded: true diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index e9d1661a4f1..c7ac28fa194 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -2,6 +2,7 @@ - @content_class = "limit-container-width" unless fluid_layout - @skip_current_level_breadcrumb = true - add_page_specific_style 'page_bundles/project' +- add_page_specific_style 'page_bundles/tree' = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 3e6acdb130a..ddebc19be15 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -5,7 +5,7 @@ #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } } -.row-content-block.top-block.content-component-block +.row-content-block.top-block.content-component-block.gl-px-0.gl-py-2 = render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet) #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml index e24276fcaea..c1cd2488142 100644 --- a/app/views/projects/starrers/_starrer.html.haml +++ b/app/views/projects/starrers/_starrer.html.haml @@ -2,8 +2,8 @@ .col-lg-3.col-md-4.col-sm-12 .card - .card-body - = image_tag avatar_icon_for_user(starrer.user, 40), class: "avatar s40", alt: '' + .card-body.gl-display-flex + = render Pajamas::AvatarComponent.new(starrer.user, size: 48, alt: "", class: 'gl-mr-3') .user-info .block-truncated diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 79fc1a64790..ed06c90efa8 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -9,6 +9,11 @@ %h1.page-title.gl-font-size-h-display = s_('TagsPage|New Tag') +%p.gl-text-secondary + - link_start = '<a href="%{url}">'.html_safe % { url: new_namespace_project_release_path } + - link_end = '</a>'.html_safe + = s_('TagsPage|Do you want to create a release with the new tag? You can do that in the %{link_start}New release page%{link_end}.').html_safe % { link_start: link_start, link_end: link_end } + = form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "common-note-form tag-form js-quick-submit js-requires-input" do .form-group.row .col-sm-12 @@ -31,22 +36,7 @@ = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" } .form-text.text-muted = tag_description_help_text - .form-group.row - .col-sm-12 - = label_tag :release_description, s_('TagsPage|Release notes'), class: 'gl-mb-0' - .form-text.mb-3 - - link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe - - releases_page_path = project_releases_path(@project) - - releases_page_link_start = link_start % { url: releases_page_path } - - docs_url = help_page_path('user/project/releases/index.md', anchor: 'create-a-release') - - docs_link_start = link_start % { url: docs_url } - - link_end = '</a>'.html_safe - - replacements = { releases_page_link_start: releases_page_link_start, docs_link_start: docs_link_start, link_end: link_end } - = s_('TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}').html_safe % replacements - = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field' - = render 'shared/notes/hints' .gl-display-flex = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3', data: { qa_selector: "create_tag_button" }, type: 'submit' }) do = s_('TagsPage|Create tag') diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 1553eda1cfb..6d1ab80bdc5 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/tree' - current_route_path = request.fullpath.match(%r{-/tree/[^/]+/(.+$)}).to_a[1] - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) - add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path }) diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 0c53ed48210..a7f29b5cbf9 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,11 +1,11 @@ .row.gl-mt-3.gl-mb-3 .col-lg-12 - .card - .card-header + = render Pajamas::CardComponent.new do |c| + - c.header do = _("Manage your project's triggers") - .card-body + - c.body do = render 'projects/triggers/form', btn_text: _('Add trigger') - %hr + .gl-mb-5 - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } } - else @@ -28,11 +28,11 @@ %th = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else - %p.settings-message.text-center.gl-mb-3{ data: { testid: 'no_triggers_content' } } - = _('No triggers exist yet. Use the form above to create one.') - - .card-footer - + = render Pajamas::AlertComponent.new(variant: :warning, show_icon: false, dismissible: false, + alert_options: { data: { testid: 'no_triggers_content' }}) do |c| + = c.body do + = _('No triggers exist yet. Use the form above to create one.') + - c.footer do %p = _("These examples show how to trigger this project's pipeline for a branch or tag.") diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml index 8575fd10ad3..69597aab7ef 100644 --- a/app/views/projects/work_items/index.html.haml +++ b/app/views/projects/work_items/index.html.haml @@ -1,5 +1,7 @@ - page_title s_('WorkItem|Work Items') - add_page_specific_style 'page_bundles/work_items' +- @gfm_form = true +- @noteable_type = 'WorkItem' #js-work-items{ data: work_items_index_data(@project) } = render 'projects/invite_members_modal', project: @project diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 911ba5e8042..fe455f4a0bc 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -17,7 +17,11 @@ %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. We won\'t share this information with anyone.')) % { gitlab_experience_text: gitlab_experience_text } - else %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. Don\'t worry, this information isn\'t shared outside of your self-managed GitLab instance.')) % { gitlab_experience_text: gitlab_experience_text } - = gitlab_ui_form_for(current_user, url: users_sign_up_welcome_path, html: { class: 'card gl-w-full! gl-p-5 js-users-signup-welcome', 'aria-live' => 'assertive' }) do |f| + = gitlab_ui_form_for(current_user, + url: users_sign_up_welcome_path(glm_tracking_params), + html: { class: 'card gl-w-full! gl-p-5 js-users-signup-welcome', + 'aria-live' => 'assertive', + data: { testid: 'welcome-form' } }) do |f| .devise-errors = render 'devise/shared/error_messages', resource: current_user .row diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 168f4ca10bc..8262c3c90e1 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,26 +1,19 @@ - search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' + = render_if_exists 'shared/promotions/promote_advanced_search' -= render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty? +- if Feature.enabled?(:search_page_vertical_nav, current_user) && %w[issues merge_requests].include?(@scope) + .results.gl-md-display-flex.gl-mt-0 + #js-search-sidebar{ class: search_bar_classes, data: { navigation: search_navigation_json } } + .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden + = render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty? + = render partial: 'search/results_list' + +- else + = render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty? -.results.gl-md-display-flex.gl-mt-3 - - if %w[issues merge_requests].include?(@scope) - #js-search-sidebar{ class: search_bar_classes } - .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - - if @timeout - = render partial: "search/results/timeout" - - elsif @search_objects.to_a.empty? - = render partial: "search/results/empty" - - else - - if @scope == 'commits' - %ul.content-list.commit-list - = render partial: "search/results/commit", collection: @search_objects - - else - .search-results - - if @scope == 'projects' - .term - = render 'shared/projects/list', projects: @search_objects, pipeline_status: false - - else - = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects + .results.gl-md-display-flex.gl-mt-3 + - if %w[issues merge_requests].include?(@scope) + #js-search-sidebar{ class: search_bar_classes, data: { navigation: search_navigation_json } } - - if @scope != 'projects' - = paginate_collection(@search_objects) + .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden + = render partial: 'search/results_list' diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml new file mode 100644 index 00000000000..cf910402ad4 --- /dev/null +++ b/app/views/search/_results_list.html.haml @@ -0,0 +1,18 @@ +- if @timeout + = render partial: "search/results/timeout" +- elsif @search_objects.to_a.empty? + = render partial: "search/results/empty" +- else + - if @scope == 'commits' + %ul.content-list.commit-list + = render partial: "search/results/commit", collection: @search_objects + - else + .search-results + - if @scope == 'projects' + .term + = render 'shared/projects/list', projects: @search_objects, pipeline_status: false + - else + = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects + + - if @scope != 'projects' + = paginate_collection(@search_objects) diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index ef5e3e83103..e6bb0c18b90 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -2,24 +2,8 @@ - return unless search_service.show_results_status? -.search-results-status - .row-content-block.gl-display-flex - .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 - - unless search_service.without_count? - = search_entries_info(search_service.search_objects, search_service.scope, params[:search]) - - unless search_service.show_snippets? - - if search_service.project - - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1') - - if search_service.scope == 'blobs' - = _("in") - .mx-md-1 - #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } } - = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - - else - = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - - elsif search_service.group - - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1') - = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - - if search_service.show_sort_dropdown? - .gl-md-display-flex.gl-flex-direction-column - #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } +- if Feature.enabled?(:search_page_vertical_nav, current_user) + = render partial: 'search/results_status_vert_nav', locals: { search_service: @search_service } + +- else + = render partial: 'search/results_status_horiz_nav', locals: { search_service: @search_service } diff --git a/app/views/search/_results_status_horiz_nav.html.haml b/app/views/search/_results_status_horiz_nav.html.haml new file mode 100644 index 00000000000..fe6ee0f12ec --- /dev/null +++ b/app/views/search/_results_status_horiz_nav.html.haml @@ -0,0 +1,22 @@ +.search-results-status + .row-content-block.gl-display-flex + .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 + - unless search_service.without_count? + = search_entries_info(search_service.search_objects, search_service.scope, params[:search]) + - unless search_service.show_snippets? + - if search_service.project + - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1') + - if search_service.scope == 'blobs' + = _("in") + .mx-md-1 + #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } } + = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } + - else + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } + - elsif search_service.group + - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1') + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } + - if search_service.show_sort_dropdown? + .gl-md-display-flex.gl-flex-direction-column + #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } + diff --git a/app/views/search/_results_status_vert_nav.html.haml b/app/views/search/_results_status_vert_nav.html.haml new file mode 100644 index 00000000000..03916911f43 --- /dev/null +++ b/app/views/search/_results_status_vert_nav.html.haml @@ -0,0 +1,23 @@ +.search-results-status + .gl-display-flex.gl-flex-direction-column + .gl-p-5.gl-display-flex + .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 + - unless search_service.without_count? + = search_entries_info(search_service.search_objects, search_service.scope, params[:search]) + - unless search_service.show_snippets? + - if search_service.project + - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1') + - if search_service.scope == 'blobs' + = _("in") + .mx-md-1 + #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } } + = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } + - else + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } + - elsif search_service.group + - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1') + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } + - if search_service.show_sort_dropdown? + .gl-md-display-flex.gl-flex-direction-column + #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } + %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index a28d9effbdd..a811dabf399 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -15,6 +15,6 @@ %span by = link_to user_snippets_path(snippet_title.author) do - = image_tag avatar_icon_for_user(snippet_title.author), class: "avatar avatar-inline s16", alt: '' + = render Pajamas::AvatarComponent.new(snippet_title.author, size: 16, class: 'gl-mt-n1') = snippet_title.author_name %span.light= time_ago_with_tooltip(snippet_title.created_at) diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index b59275c35df..d6900c397a0 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -4,6 +4,6 @@ %div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' } %span.gl-display-flex.gl-align-items-center = link_to wiki_blob_link, data: { track_action: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do - %span.term.str-truncated.gl-font-weight-bold= ::Gitlab::Git::Wiki::GollumSlug.canonicalize_filename(wiki_blob.path) + %span.term.str-truncated.gl-font-weight-bold= ::Wiki.canonicalize_filename(wiki_blob.path) .description.term.col-sm-10.gl-px-0 = simple_search_highlight_and_truncate(wiki_blob.data, @search_term) diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 5a45e512579..9d812e77ad4 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -22,5 +22,6 @@ .gl-mt-3 #js-search-topbar{ data: { "group-initial-data": group_attributes.to_json, "project-initial-data": project_attributes.to_json } } - if @search_term - = render 'search/category' + - if Feature.disabled?(:search_page_vertical_nav, current_user) + = render 'search/category' = render 'search/results' diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 6b502ee928e..48ae1f7eb1d 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -9,14 +9,14 @@ %span.js-clone-dropdown-label = default_clone_protocol.upcase = sprite_icon('chevron-down', css_class: 'gl-icon') - %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown + %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown{ data: { qa_selector: 'clone_dropdown_content' } } %li = ssh_clone_button(container) %li = http_clone_button(container) = render_if_exists 'shared/kerberos_clone_button', container: container - = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') } + = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'clone_url_content' } .input-group-append = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text gl-button btn-default btn-clipboard") diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml deleted file mode 100644 index 48fe258d01f..00000000000 --- a/app/views/shared/_commit_well.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.info-well.d-none.d-sm-block.project-last-commit.gl-mb-3 - .well-segment - %ul.blob-commit-info - = render 'projects/commits/commit', commit: commit, ref: ref, project: project diff --git a/app/views/shared/_custom_attributes.html.haml b/app/views/shared/_custom_attributes.html.haml index 966ab8e3cb1..6e5f1cb063c 100644 --- a/app/views/shared/_custom_attributes.html.haml +++ b/app/views/shared/_custom_attributes.html.haml @@ -1,12 +1,13 @@ - return unless custom_attributes.present? -.card - .card-header += render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c| + - c.header do = link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md')) - %ul.content-list - - custom_attributes.each do |custom_attribute| - %li - %span.light - = custom_attribute.key - %strong - = custom_attribute.value + - c.body do + %ul.content-list + - custom_attributes.each do |custom_attribute| + %li + %span.light + = custom_attribute.key + %strong + = custom_attribute.value diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index 89be816fc76..73ace033dc6 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -4,11 +4,10 @@ - blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) .line-numbers{ class: "gl-px-0!", data: { blame_path: blame_path } } - if blob.data.present? - - link = blob_link if defined?(blob_link) - blob.data.each_line.each_with_index do |_, index| - i = index + offset -# We're not using `link_to` because it is too slow once we get to thousands of lines. - %a.file-line-num.diff-line-num{ class: ("js-line-links" if Feature.enabled?(:file_line_blame)), href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i } + %a.file-line-num.diff-line-num{ class: ("js-line-links" if Feature.enabled?(:file_line_blame)), href: "#L#{i}", id: "L#{i}", 'data-line-number' => i } = i - highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } } diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index 0c88ac66b8b..eada58091b7 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -1,6 +1,7 @@ - ajax = local_assigns.fetch(:ajax, false) - title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type }) - prefix = local_assigns.fetch(:prefix, :personal_access_token) +- description_prefix = local_assigns.fetch(:description_prefix, prefix) - help_path = local_assigns.fetch(:help_path) - resource = local_assigns.fetch(:resource, false) - access_levels = local_assigns.fetch(:access_levels, false) @@ -43,7 +44,7 @@ %p.text-secondary#select_scope_help_text = s_('Tokens|Scopes set the permission levels granted to the token.') = link_to _("Learn more."), help_path, target: '_blank', rel: 'noopener noreferrer' - = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes, f: f + = render 'shared/tokens/scopes_form', prefix: prefix, description_prefix: description_prefix, token: token, scopes: scopes, f: f .gl-mt-3 - = f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-confirm', data: { qa_selector: 'create_token_button' } + = f.submit _('Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml index 4db1d20e81b..db53d78dadb 100644 --- a/app/views/shared/blob/_markdown_buttons.html.haml +++ b/app/views/shared/blob/_markdown_buttons.html.haml @@ -1,4 +1,5 @@ - modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+') +- supports_file_upload = local_assigns.fetch(:supports_file_upload, true) .md-header-toolbar.active = markdown_toolbar_button({ icon: "bold", @@ -23,14 +24,19 @@ = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") }) = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") }) + = markdown_toolbar_button({ icon: "list-indent", + data: { "md-command" => 'indentLines', "md-shortcuts": '["mod+]"]' }, + css_class: 'gl-display-none', + title: sprintf(s_("MarkdownEditor|Indent line (%{modifier_key}])") % { modifier_key: modifier_key }) }) + = markdown_toolbar_button({ icon: "list-outdent", + data: { "md-command" => 'outdentLines', "md-shortcuts": '["mod+["]' }, + css_class: 'gl-display-none', + title: sprintf(s_("MarkdownEditor|Outdent line (%{modifier_key}[)") % { modifier_key: modifier_key }) }) = markdown_toolbar_button({ icon: "details-block", data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" }, title: _("Add a collapsible section") }) - = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) - = markdown_toolbar_button({ icon: "paperclip", - data: { "testid" => "button-attach-file" }, - css_class: 'js-attach-file-button markdown-selector', - title: _("Attach a file or image") }) + = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") }) + - if supports_file_upload + = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button', data: { testid: 'button-attach-file', container: 'body' } }) - if show_fullscreen_button - %button.gl-button.btn.btn-default-tertiary.btn-icon.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } - = sprite_icon("maximize") + = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter', data: { container: 'body' } }) diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index d76ef8feb62..11fa44fe282 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -17,4 +17,4 @@ help_text: _('Allow this key to push to this repository') .form-group.row - = f.submit _("Add key"), class: "btn gl-button btn-confirm", data: { qa_selector: "add_deploy_key_button"} + = f.submit _("Add key"), data: { qa_selector: "add_deploy_key_button"}, pajamas_button: true diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index eade973d72a..1b48843eb10 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -34,4 +34,4 @@ = f.gitlab_ui_checkbox_component :write_package_registry, 'write_package_registry', help_text: s_('DeployTokens|Allows read and write access to the package registry.'), checkbox_options: { data: { qa_selector: 'deploy_token_write_package_registry_checkbox' } } .gl-mt-3 - = f.submit s_('DeployTokens|Create deploy token'), class: 'btn gl-button btn-confirm', data: { qa_selector: 'create_deploy_token_button' } + = f.submit s_('DeployTokens|Create deploy token'), data: { qa_selector: 'create_deploy_token_button' }, pajamas_button: true diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index 79bf35e2726..faec379e42b 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -8,10 +8,20 @@ %p = description .settings-content - - if @created_deploy_token - = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @created_deploy_token - %h5.gl-mt-0 - = s_('DeployTokens|New deploy token') - = render 'shared/deploy_tokens/form', group_or_project: group_or_project, token: @new_deploy_token, presenter: @deploy_tokens + - if Feature.enabled?(:ajax_new_deploy_token, group_or_project) + #js-new-deploy-token{ data: { + container_registry_enabled: container_registry_enabled?(group_or_project), + packages_registry_enabled: packages_registry_enabled?(group_or_project), + create_new_token_path: create_deploy_token_path(group_or_project), + token_type: group_or_project.is_a?(Group) ? 'group' : 'project', + deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md') + } + } + - else + - if @created_deploy_token + = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @created_deploy_token + %h5.gl-mt-0 + = s_('DeployTokens|New deploy token') + = render 'shared/deploy_tokens/form', group_or_project: group_or_project, token: @new_deploy_token, presenter: @deploy_tokens %hr = render 'shared/deploy_tokens/table', group_or_project: group_or_project, active_tokens: @deploy_tokens diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index b14ff9b2508..6a770a4fcb2 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index 0bec94f70ea..e6bdefc64d2 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -14,7 +14,7 @@ .block .title = _('Status') - .js-issue-status + .js-status-dropdown .block .title = _('Assignee') @@ -41,15 +41,7 @@ .block .title = _('Subscriptions') - .filter-item - = dropdown_tag(_("Select subscription"), options: { toggle_class: "js-subscription-event", title: _("Change subscription"), dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: _("Subscription") } } ) do - %ul - %li - %a{ href: "#", data: { id: "subscribe" } } - = _('Subscribe') - %li - %a{ href: "#", data: { id: "unsubscribe" } } - = _('Unsubscribe') + .js-subscriptions-dropdown = hidden_field_tag "update[issuable_ids]", [] = hidden_field_tag :state_event, params[:state_event] diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index 69ff477d415..94b7fe14721 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,8 +1,8 @@ - show_calendar_button = local_assigns.fetch(:show_calendar_button, true) -= link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') , 'aria-label': _('Subscribe to RSS feed') do - = sprite_icon('rss') += render Pajamas::ButtonComponent.new(href: safe_params.merge(rss_url_options), icon: 'rss', button_options: { class: 'has-tooltip', 'aria-label': _('Subscribe to RSS feed'), data: { container: 'body', testid: 'rss-feed-link' } }) do + = _('Subscribe to RSS feed') - if show_calendar_button - = link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar'), 'aria-label': _('Subscribe to calendar') do - = sprite_icon('calendar') + = render Pajamas::ButtonComponent.new(href: safe_params.merge(calendar_url_options), icon: 'calendar', button_options: { class: 'has-tooltip', 'aria-label': _('Subscribe to calendar'), data: { container: 'body' } }) do + = _('Subscribe to calendar') diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 53eb6f4c63b..5b7f9c4226c 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -62,9 +62,9 @@ = sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end }) - if issuable.new_record? - = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button', track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - else - = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2', data: { track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - if issuable.new_record? = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default js-reset-autosave' diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index ec78b3f7ce3..eb3acd8e055 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -19,7 +19,7 @@ %input.js-add-list{ type: "checkbox", name: "add_list", checked: add_list } %span= _('Add list') .clearfix - %button.gl-button.btn.btn-confirm.float-left.js-new-label-btn{ type: "button" } + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'float-left js-new-label-btn' }) do = _('Create') - %button.gl-button.btn.btn-default.float-right.js-cancel-label-btn{ type: "button" } + = render Pajamas::ButtonComponent.new(button_options: { class: 'float-right js-cancel-label-btn' }) do = _('Cancel') diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index ef539029272..58108ceeb76 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -11,10 +11,6 @@ placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone'), qa_selector: "issuable_milestone_dropdown", testid: "issuable-milestone-dropdown" } }) do - if project %ul.dropdown-footer-list - - if can? current_user, :admin_milestone, project - %li - = link_to new_project_milestone_path(project), title: _('New Milestone') do - = _('Create new') %li = link_to project_milestones_path(project) do - if can? current_user, :admin_milestone, project diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 21716710015..72940b64801 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -11,10 +11,11 @@ - if params[:search].present? = hidden_field_tag :search, params[:search] - if @can_bulk_update - .check-all-holder.gl-display-none.gl-sm-display-block.hidden.gl-float-left.gl-mr-5.gl-line-height-36 - - checkbox_id = 'check-all-issues' - %label.gl-sr-only{ for: checkbox_id }= _('Select all') - = check_box_tag checkbox_id, nil, false, class: "check-all-issues left" + .check-all-holder.gl-display-none.gl-sm-display-block.hidden.gl-float-left.gl-mr-3.gl-line-height-36 + = render Pajamas::CheckboxTagComponent.new(name: 'check-all-issues', value: nil) do |c| + = c.label do + %span.gl-sr-only + = _('Select all') .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .filtered-search-box - if type != :boards diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index f2ce0676a9a..4199b7e870b 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -27,7 +27,7 @@ #js-severity - if reviewers - .block.reviewer + .block.reviewer{ data: { qa_selector: 'reviewers_block_container' } } = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in - if issuable_sidebar[:supports_escalation] @@ -65,7 +65,7 @@ = gl_loading_icon(inline: true) - if issuable_sidebar.dig(:features_available, :health_status) - .js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) } + .js-sidebar-health-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) } - if issuable_sidebar.has_key?(:confidential) -# haml-lint:disable InlineJavaScript diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index e9b04579808..62221fb8218 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -39,7 +39,7 @@ - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] + - data['max-select'] = dropdown_max_select(dropdown_options[:data], :limit_assignees_per_issuable) - options[:data].merge!(data) = render 'shared/issuable/sidebar_user_dropdown', diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index 3f78f29ea24..771db8af6a8 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -36,7 +36,10 @@ - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_max_select(dropdown_options[:data]) + - data[:suggested_reviewers_header] = dropdown_options[:data][:suggested_reviewers_header] + - data[:all_members_header] = dropdown_options[:data][:all_members_header] + - data[:show_suggested] = dropdown_options[:data][:show_suggested] + - data['max-select'] = dropdown_max_select(dropdown_options[:data], :limit_reviewer_and_assignee_size) - options[:data].merge!(data) = render 'shared/issuable/sidebar_user_dropdown', diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index e36c4cd6be0..ccc1a9fda6e 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -1,5 +1,5 @@ - viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' -- viewing_merge_requests = controller.controller_name == 'merge_requests' +- viewing_merge_requests = controller.controller_name == 'merge_requests' || controller.action_name == 'merge_requests' - items = issuable_sort_options(viewing_issues, viewing_merge_requests) - selected = issuable_sort_option_overrides[@sort] || @sort diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index 369aa53586f..8a9b71fd91e 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -2,7 +2,7 @@ - api_awards_path = local_assigns.fetch(:api_awards_path, nil) .issue-details.issuable-details.js-issue-details - .detail-page-description.content-block.js-detail-page-description + .detail-page-description.content-block.js-detail-page-description.gl-pb-0.gl-border-none #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json, issuable_id: issuable.id, full_path: @project.full_path } } .title-container %h1.title.page-title.gl-font-size-h-display= markdown_field(issuable, :title) @@ -12,6 +12,9 @@ = edited_time_ago_with_tooltip(issuable, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') + .js-issue-widgets + = render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path + .js-issue-widgets = render 'shared/issue_type/sentry_stack_trace', issuable: issuable @@ -32,7 +35,6 @@ -# This element is filled in using JavaScript. .js-issue-widgets - = render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path = render 'projects/issues/discussion' diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index 08fba712d5e..ccb501dae11 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -2,7 +2,7 @@ - badge_classes = 'issuable-status-badge gl-mr-3' .detail-page-header - .detail-page-header-body + .detail-page-header-body.gl-flex-wrap-wrap = gl_badge_tag({ variant: :info, icon: 'issue-closed', icon_classes: 'gl-mr-0!' }, { class: "#{issue_status_visibility(issuable, status_box: :closed)} #{badge_classes} issuable-status-badge-closed" }) do .gl-display-none.gl-sm-display-block.gl-ml-2 = issue_closed_text(issuable, current_user) @@ -13,9 +13,8 @@ %span.gl-display-none.gl-sm-display-block.gl-ml-2 = _('Open') - .issuable-meta - #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } - = issuable_meta(issuable, @project) + #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } + = issuable_meta(issuable, @project) %a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } = sprite_icon('chevron-double-lg-left') diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml index 61e28f18d3b..a5c71fb1d24 100644 --- a/app/views/shared/issue_type/_emoji_block.html.haml +++ b/app/views/shared/issue_type/_emoji_block.html.haml @@ -4,7 +4,5 @@ .row.gl-m-0.gl-justify-content-space-between .js-noteable-awards = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path - .new-branch-col.gl-display-flex.gl-my-2.gl-font-size-0.gl-gap-3 - = render_if_exists "projects/issues/timeline_toggle", issuable: issuable - #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } } + .new-branch-col.gl-font-size-0 = render 'new_branch' if show_new_branch_button? diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index e6d6d0998dc..c6932d49d33 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -1,4 +1,4 @@ -= form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f| += gitlab_ui_form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f| = form_errors(@label) .form-group.row @@ -26,9 +26,9 @@ .gl-display-flex.gl-justify-content-space-between %div - if @label.persisted? - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm js-save-button gl-mr-2' + = f.submit _('Save changes'), class: 'js-save-button gl-mr-2', pajamas_button: true - else - = f.submit _('Create label'), class: 'btn gl-button btn-confirm js-save-button gl-mr-2', data: { qa_selector: 'label_create_button' } + = f.submit _('Create label'), class: 'js-save-button gl-mr-2', data: { qa_selector: 'label_create_button' }, pajamas_button: true = link_to _('Cancel'), back_path, class: 'btn gl-button btn-default btn-cancel gl-mr-2' - if @label.persisted? - presented_label = @label.present diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 23f78f4be45..376e51a6b15 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -12,7 +12,7 @@ %li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } %span.list-item-name.mb-2.m-md-0 - if user - = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' + = render Pajamas::AvatarComponent.new(user, size: 32, class: 'gl-mr-3 flex-shrink-0 flex-grow-0') .user-info %span.mr-1 = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id } diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 98e2c6c43b1..31625c22a94 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -5,7 +5,7 @@ - return if requesters.empty? -= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c| - c.header do = _('Users requesting access to') %strong= membership_source.name diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml index 460ddd0897c..2502f7fca62 100644 --- a/app/views/shared/milestones/_issuables.html.haml +++ b/app/views/shared/milestones/_issuables.html.haml @@ -1,22 +1,20 @@ - show_counter = local_assigns.fetch(:show_counter, false) - primary = local_assigns.fetch(:primary, false) -- panel_class = primary ? 'bg-primary text-white' : '' -.card - .card-header{ class: panel_class } - .header.gl-mb-2 - .title - = title - .issuable-count-weight.gl-ml-3 += render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }, header_options: { class: milestone_header_class(primary, issuables) }) do |c| + - c.header do + .gl-flex-grow-2 + = title + .gl-ml-3.gl-flex-shrink-0.gl-font-weight-bold.gl-white-space-nowrap{ class: milestone_counter_class(primary) } - if show_counter - %span.counter + %span = sprite_icon('issues', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(issuables.length) = render_if_exists "shared/milestones/issuables_weight", issuables: issuables - - - class_prefix = dom_class(issuables).pluralize - %ul{ class: "content-list milestone-#{class_prefix}-list", id: "#{class_prefix}-list-#{id}" } - = render partial: 'shared/milestones/issuable', - collection: issuables, - as: :issuable, - locals: { show_project_name: show_project_name } + = c.body do + - class_prefix = dom_class(issuables).pluralize + %ul{ class: "content-list milestone-#{class_prefix}-list", id: "#{class_prefix}-list-#{id}" } + = render partial: 'shared/milestones/issuable', + collection: issuables, + as: :issuable, + locals: { show_project_name: show_project_name } diff --git a/app/views/shared/milestones/_participants_tab.html.haml b/app/views/shared/milestones/_participants_tab.html.haml index fe83040c168..f90967c3b15 100644 --- a/app/views/shared/milestones/_participants_tab.html.haml +++ b/app/views/shared/milestones/_participants_tab.html.haml @@ -1,8 +1,8 @@ %ul.bordered-list - users.each do |user| %li - = link_to user, title: user.name, class: "darken" do - = image_tag avatar_icon_for_user(user, 32), class: "avatar s32" - %strong= truncate(user.name, length: 40) - %div + = link_to user, title: user.name, class: "gl-display-flex" do + = render Pajamas::AvatarComponent.new(user, size: 32, class: "gl-mr-3") + .gl-display-flex.gl-flex-direction-column + %strong= truncate(user.name, length: 40) %small.cgray= user.username diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 3ab8514aebf..c552e94ac57 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -6,17 +6,18 @@ - note_counter = local_assigns.fetch(:note_counter, 0) %li.timeline-entry.note-wrapper{ id: dom_id(note), - class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], + class: ["note", "note-comment", "note-row-#{note.id}", ('system-note' if note.system)], data: { author_id: note.author.id, editable: note_editable, note_id: note.id } } .timeline-entry-inner - .timeline-icon - - if note.system + - if note.system + .timeline-icon = icon_for_system_note(note) - - else + - else + .timeline-avatar.gl-float-left %a.image-diff-avatar-link{ href: user_path(note.author) } - = image_tag avatar_icon_for_user(note.author), alt: '', class: 'avatar s40' + = render Pajamas::AvatarComponent.new(note.author, size: 32, alt: '') - if note.is_a?(DiffNote) && note.on_image? - if show_image_comment_badge && note_counter == 0 -# Only show this for the first comment in the discussion @@ -34,9 +35,9 @@ %span.note-header-author-name.bold = note.author.name = user_status(note.author) - %span.note-headline-light{ data: { qa_selector: 'note_author_content' } } + %spannote-headline-light{ data: { qa_selector: 'note_author_content' } } = note.author.to_reference - %span.note-headline-light.note-headline-meta + %span.note-headline-ligh.note-headline-meta - if note.system %span.system-note-message = markdown_field(note, :note) diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index ae264f2188f..81e2e066bd3 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -9,7 +9,7 @@ - compact_mode = false unless local_assigns[:compact_mode] == true - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project) - css_class = '' unless local_assigns[:css_class] -- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description +- css_class += " gl-display-flex!" - cache_key = project_list_cache_key(project, pipeline_status: pipeline_status) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) - show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) @@ -18,15 +18,15 @@ - css_controls_class << "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present? - avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' -%li.project-row.d-flex{ class: css_class } +%li.project-row.gl-align-items-center{ class: css_class } = cache(cache_key) do - if avatar - .avatar-container.s48.flex-grow-0.flex-shrink-0{ class: avatar_container_class } + .flex-grow-0.flex-shrink-0{ class: avatar_container_class } = link_to project_path(project), class: dom_class(project) do - if project.creator && use_creator_avatar - = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt: '' + = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5') - else - = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) + = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5') .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project_content', qa_project_name: project.name } } .flex-wrapper .d-flex.align-items-center.flex-wrap.project-title @@ -52,7 +52,7 @@ -# haml-lint:disable UnnecessaryStringOutput = ' ' # prevent haml from eating the space between elements .metadata-info.gl-mt-3 - %span.user-access-role.d-block{ data: { qa_selector: 'user_role_content' } }= Gitlab::Access.human_access(access) + %span.user-access-role.gl-display-block{ data: { qa_selector: 'user_role_content' } }= localized_project_human_access(access) - if !explore_projects_tab? .metadata-info.gl-mt-3 diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index a5170b199e8..e598343d698 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,4 +1,4 @@ -- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : '' +- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : 'gl-w-full! gl-pl-7 ' - placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : _('Filter by name') = form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml index 436dbfd2b49..dc689303f77 100644 --- a/app/views/shared/runners/_runner_description.html.haml +++ b/app/views/shared/runners/_runner_description.html.haml @@ -1,6 +1,12 @@ .light.gl-mt-3 %p - = _("Register as many runners as you want. You can register runners as separate users, on separate servers, and on your local machine. Runners are either:") + = s_("Runners|Register as many runners as you want. You can register runners as separate users, on separate servers, and on your local machine.") + + %h5 + = s_("Runners|How do runners pick up jobs?") + + %p + = s_("Runners|Runners are either:") %div %ul @@ -10,3 +16,7 @@ %li = gl_badge_tag s_("Runners|paused"), variant: :danger, size: :sm = _('- Not available to run jobs.') + + %p + = s_("Runners|Tags control which type of jobs a runner can handle. By tagging a runner, you make sure shared runners only handle the jobs they are equipped to run.") + = link_to _("Learn more."), help_page_path("ci/runners/configure_runners", anchor: "use-tags-to-control-which-jobs-a-runner-can-run"), target: '_blank' diff --git a/app/views/shared/runners/_shared_runners_description.html.haml b/app/views/shared/runners/_shared_runners_description.html.haml index 2779901ca0c..c8ddb5d5176 100644 --- a/app/views/shared/runners/_shared_runners_description.html.haml +++ b/app/views/shared/runners/_shared_runners_description.html.haml @@ -1,12 +1,9 @@ --# "MaxBuilds" is a runner configuration keyword so it must not be translated. -- link = link_to 'MaxBuilds', 'https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersmachine-section', target: '_blank', rel: 'noopener noreferrer' +- shared_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('ci/runners/runners_scope.md', anchor: 'shared-runners') } %h4 = _('Shared runners') .bs-callout{ data: { testid: 'shared-runners-description' } } - %p= _('These runners are shared across this GitLab instance.') + %p= s_('Runners|%{link_start}These runners%{link_end} are available to all groups and projects.').html_safe % { link_start: shared_link_start, link_end: '</a>'.html_safe } - if Gitlab::CurrentSettings.shared_runners_text.present? = markdown(Gitlab::CurrentSettings.current_application_settings.shared_runners_text) - - else - %p= _('The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com).').html_safe % { link: link } diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 3cd70dab4d5..6caadeb0ba4 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -2,7 +2,7 @@ - notes_count = @noteable_meta_data[snippet.id].user_notes_count %li.snippet-row.py-3{ data: { qa_selector: 'snippet_link', qa_snippet_title: snippet.title } } - = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' + = render Pajamas::AvatarComponent.new(snippet.author, size: 48, alt: "", class: 'gl-display-none gl-sm-display-block gl-float-left gl-mr-3') = link_to gitlab_snippet_path(snippet), class: "title" do = snippet.title @@ -20,16 +20,15 @@ = visibility_level_icon(snippet.visibility_level) .snippet-info - #{snippet.to_reference} · - authored #{time_ago_with_tooltip(snippet.created_at, placement: 'bottom', html_class: 'snippet-created-ago')} - by - = link_to user_snippets_path(snippet.author), class: "js-user-link", data: { user_id: snippet.author.id } do - = snippet.author_name - - if link_project && snippet.project_id? - %span.d-none.d-sm-inline-block - in - = link_to project_path(snippet.project) do - = snippet.project.full_name + .gl-display-inline{ data: { testid: 'snippet-created-at'} } + - created_at = time_ago_with_tooltip(snippet.created_at, placement: 'bottom') + - author = link_to(snippet.author_name, user_snippets_path(snippet.author), data: { user_id: snippet.author.id }) + #{snippet.to_reference} · + - if link_project && snippet.project_id? + - project_link = link_to(snippet.project.full_name, project_path(snippet.project)) + = _('created %{timeAgo} by %{author} in %{project_link}').html_safe % { timeAgo: created_at, author: author, project_link: project_link } + - else + = _('created %{timeAgo} by %{author}').html_safe % { timeAgo: created_at, author: author } - .float-right.snippet-updated-at - %span updated #{time_ago_with_tooltip(snippet.updated_at, placement: 'bottom')} + .float-right + = _('updated %{timeAgo}').html_safe % { timeAgo: time_ago_with_tooltip(snippet.updated_at, placement: 'bottom') } diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index 010376464f1..1c63ce490ed 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -1,11 +1,12 @@ - scopes = local_assigns.fetch(:scopes) - prefix = local_assigns.fetch(:prefix) +- description_prefix = local_assigns.fetch(:description_prefix, prefix) - token = local_assigns.fetch(:token) - f = local_assigns.fetch(:f) %fieldset - scopes.each do |scope| - - help_text = t scope, scope: scope_description(prefix) + - help_text = t scope, scope: scope_description(description_prefix) = f.gitlab_ui_checkbox_component :scopes, scope, help_text: help_text, checkbox_options: { checked: token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", multiple: true, data: { qa_selector: "#{scope}_checkbox" } }, diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml index 93b3ce5f319..51eb24f6d4a 100644 --- a/app/views/shared/users/_user.html.haml +++ b/app/views/shared/users/_user.html.haml @@ -3,7 +3,7 @@ .col-lg-3.col-md-4.col-sm-12 = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c| = c.body do - = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: '' + = render Pajamas::AvatarComponent.new(user, size: 48, alt: "", class: 'gl-float-left gl-mr-3') .user-info .block-truncated diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index afe72767b9a..c95e63bdc83 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -1,10 +1,13 @@ = form_errors(hook) -.form-group - = form.label :url, s_('Webhooks|URL'), class: 'label-bold' - = form.text_field :url, class: 'form-control gl-form-input', placeholder: 'http://example.com/trigger-ci.json' - %p.form-text.text-muted - = s_('Webhooks|URL must be percent-encoded if it contains one or more special characters.') +- if Feature.enabled?(:webhook_form_mask_url) + .js-vue-webhook-form{ data: webhook_form_data(hook) } +- else + .form-group + = form.label :url, s_('Webhooks|URL'), class: 'label-bold' + = form.text_field :url, class: 'form-control gl-form-input', placeholder: 'http://example.com/trigger-ci.json' + %p.form-text.text-muted + = s_('Webhooks|URL must be percent-encoded if it contains one or more special characters.') .form-group = form.label :token, s_('Webhooks|Secret token'), class: 'label-bold' = form.text_field :token, class: 'form-control gl-form-input', placeholder: '' diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml index d95efe83e15..098cc19c435 100644 --- a/app/views/shared/web_hooks/_hook_errors.html.haml +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -4,16 +4,12 @@ - link_end = '</a>'.html_safe - if hook.rate_limited? - - support_path = 'https://support.gitlab.com/hc/en-us/requests/new' - - placeholders = { strong_start: strong_start, - strong_end: strong_end, - limit: hook.rate_limit, - support_link_start: link_start % { url: support_path }, - support_link_end: link_end } - = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook was automatically disabled'), + - placeholders = { limit: number_with_delimiter(hook.rate_limit), + root_namespace: hook.parent.root_namespace.path } + = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook rate limit has been reached'), variant: :danger) do |c| = c.body do - = s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders + = s_("Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute.").html_safe % placeholders - elsif hook.permanently_disabled? = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'), variant: :danger) do |c| diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 5ec82ad6702..868633143cd 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -1,14 +1,13 @@ %hr -.card#webhooks-index - .card-header - %h5 - = hook_class.underscore.humanize.titleize.pluralize - (#{hooks.size}) - - - if hooks.any? - %ul.content-list - - hooks.each do |hook| - = render 'shared/web_hooks/hook', hook: hook - - else - %p.text-center.gl-mt-3.gl-mb-3 - = _('No webhooks enabled. Select trigger events above.') += render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index' }, body_options: { class: 'gl-py-0'}) do |c| + - c.header do + = hook_class.underscore.humanize.titleize.pluralize + (#{hooks.size}) + - c.body do + - if hooks.any? + %ul.content-list + - hooks.each do |hook| + = render 'shared/web_hooks/hook', hook: hook + - else + %p.text-center.gl-mt-3.gl-mb-3 + = _('No webhooks enabled. Select trigger events above.') diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml index 21d63a6db3d..e1252e91c10 100644 --- a/app/views/shared/wikis/pages.html.haml +++ b/app/views/shared/wikis/pages.html.haml @@ -2,7 +2,6 @@ - breadcrumb_title s_("Wiki|Pages") - page_title s_("Wiki|Pages"), _("Wiki") - add_page_specific_style 'page_bundles/wiki' -- wiki_sort_options = [{ text: s_("Wiki|Title"), value: 'title', href: wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER)}, { text: s_("Wiki|Created date"), value: 'created_at', href: wiki_path(@wiki, action: :pages, sort: Wiki::CREATED_AT_ORDER) }] .wiki-page-header.top-area.flex-column.flex-lg-row %h1.page-title.gl-font-size-h-display.gl-flex-grow-1 @@ -15,8 +14,7 @@ .dropdown.inline.wiki-sort-dropdown .btn-group{ role: 'group' } - = gl_redirect_listbox_tag wiki_sort_options, params[:sort], data: { right: true } - = wiki_sort_controls(@wiki, params[:sort], params[:direction]) + = wiki_sort_controls(@wiki, params[:direction]) %ul.wiki-pages-list.content-list = render @wiki_entries, context: 'pages' diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index f1093a3b730..47ccc449e1b 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -14,7 +14,7 @@ #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } } -.row-content-block.top-block.content-component-block +.row-content-block.top-block.content-component-block.gl-px-0.gl-py-2 = render 'award_emoji/awards_block', awardable: @snippet, inline: true #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 9b282340d0a..a0f6da57f9e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1020,6 +1020,42 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_attachments_import_issue + :worker_name: Gitlab::GithubImport::Attachments::ImportIssueWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: github_importer:github_import_attachments_import_merge_request + :worker_name: Gitlab::GithubImport::Attachments::ImportMergeRequestWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: github_importer:github_import_attachments_import_note + :worker_name: Gitlab::GithubImport::Attachments::ImportNoteWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: github_importer:github_import_attachments_import_release + :worker_name: Gitlab::GithubImport::Attachments::ImportReleaseWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_import_diff_note :worker_name: Gitlab::GithubImport::ImportDiffNoteWorker :feature_category: :importers @@ -2181,6 +2217,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: ci_parse_secure_file_metadata + :worker_name: Ci::ParseSecureFileMetadataWorker + :feature_category: :mobile_signing_deployment + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: ci_runners_process_runner_version_update :worker_name: Ci::Runners::ProcessRunnerVersionUpdateWorker :feature_category: :runner_fleet @@ -2352,15 +2397,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: experiments_record_conversion_event - :worker_name: Experiments::RecordConversionEventWorker - :feature_category: :users - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: export_csv :worker_name: ExportCsvWorker :feature_category: :team_planning @@ -2739,42 +2775,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: namespaces_onboarding_issue_created - :worker_name: Namespaces::OnboardingIssueCreatedWorker - :feature_category: :onboarding - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] -- :name: namespaces_onboarding_pipeline_created - :worker_name: Namespaces::OnboardingPipelineCreatedWorker - :feature_category: :onboarding - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] -- :name: namespaces_onboarding_progress - :worker_name: Namespaces::OnboardingProgressWorker - :feature_category: :onboarding - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :cpu - :weight: 1 - :idempotent: true - :tags: [] -- :name: namespaces_onboarding_user_added - :worker_name: Namespaces::OnboardingUserAddedWorker - :feature_category: :onboarding - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: namespaces_process_sync_events :worker_name: Namespaces::ProcessSyncEventsWorker :feature_category: :pods @@ -2820,6 +2820,42 @@ :weight: 2 :idempotent: false :tags: [] +- :name: onboarding_issue_created + :worker_name: Onboarding::IssueCreatedWorker + :feature_category: :onboarding + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: onboarding_pipeline_created + :worker_name: Onboarding::PipelineCreatedWorker + :feature_category: :onboarding + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: onboarding_progress + :worker_name: Onboarding::ProgressWorker + :feature_category: :onboarding + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: true + :tags: [] +- :name: onboarding_user_added + :worker_name: Onboarding::UserAddedWorker + :feature_category: :onboarding + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: packages_composer_cache_update :worker_name: Packages::Composer::CacheUpdateWorker :feature_category: :package_registry diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index c7efc92b25e..d5eca86744e 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -22,10 +22,9 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker created_entities.find_each do |entity| BulkImports::CreatePipelineTrackersService.new(entity).execute! - BulkImports::ExportRequestWorker.perform_async(entity.id) - BulkImports::EntityWorker.perform_async(entity.id) - entity.start! + + BulkImports::ExportRequestWorker.perform_async(entity.id) end re_enqueue diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index f6b1c693fe4..ada3210624c 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -15,9 +15,11 @@ module BulkImports if stage_running?(entity_id, current_stage) logger.info( structured_payload( - entity_id: entity_id, + bulk_import_entity_id: entity_id, + bulk_import_id: bulk_import_id(entity_id), current_stage: current_stage, - message: 'Stage running' + message: 'Stage running', + importer: 'gitlab_migration' ) ) @@ -26,9 +28,11 @@ module BulkImports logger.info( structured_payload( - entity_id: entity_id, + bulk_import_entity_id: entity_id, + bulk_import_id: bulk_import_id(entity_id), current_stage: current_stage, - message: 'Stage starting' + message: 'Stage starting', + importer: 'gitlab_migration' ) ) @@ -42,13 +46,17 @@ module BulkImports rescue StandardError => e logger.error( structured_payload( - entity_id: entity_id, + bulk_import_entity_id: entity_id, + bulk_import_id: bulk_import_id(entity_id), current_stage: current_stage, - message: e.message + message: e.message, + importer: 'gitlab_migration' ) ) - Gitlab::ErrorTracking.track_exception(e, entity_id: entity_id) + Gitlab::ErrorTracking.track_exception( + e, bulk_import_entity_id: entity_id, bulk_import_id: bulk_import_id(entity_id), importer: 'gitlab_migration' + ) end private @@ -63,6 +71,10 @@ module BulkImports BulkImports::Tracker.next_pipeline_trackers_for(entity_id).update(status_event: 'enqueue') end + def bulk_import_id(entity_id) + @bulk_import_id ||= Entity.find(entity_id).bulk_import_id + end + def logger @logger ||= Gitlab::Import::Logger.build end diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index 0d3e4f013dd..a57071ddcf1 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -13,45 +13,112 @@ module BulkImports def perform(entity_id) entity = BulkImports::Entity.find(entity_id) + entity.update!(source_xid: entity_source_xid(entity)) if entity.source_xid.nil? + request_export(entity) + + BulkImports::EntityWorker.perform_async(entity_id) rescue BulkImports::NetworkError => e - log_export_failure(e, entity) + if e.retriable?(entity) + retry_request(e, entity) + else + log_export_failure(e, entity) - entity.fail_op! + entity.fail_op! + end end private def request_export(entity) - http_client(entity.bulk_import.configuration).post(entity.export_relations_url_path) + http_client(entity).post(entity.export_relations_url_path) end - def http_client(configuration) + def http_client(entity) @client ||= Clients::HTTP.new( - url: configuration.url, - token: configuration.access_token + url: entity.bulk_import.configuration.url, + token: entity.bulk_import.configuration.access_token ) end def log_export_failure(exception, entity) - attributes = { + Gitlab::Import::Logger.error( + structured_payload( + log_attributes(exception, entity).merge( + bulk_import_id: entity.bulk_import_id, + bulk_import_entity_type: entity.source_type, + message: "Request to export #{entity.source_type} failed", + importer: 'gitlab_migration' + ) + ) + ) + + BulkImports::Failure.create(log_attributes(exception, entity)) + end + + def log_attributes(exception, entity) + { bulk_import_entity_id: entity.id, pipeline_class: 'ExportRequestWorker', exception_class: exception.class.to_s, exception_message: exception.message.truncate(255), correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id } + end + + def graphql_client(entity) + @graphql_client ||= BulkImports::Clients::Graphql.new( + url: entity.bulk_import.configuration.url, + token: entity.bulk_import.configuration.access_token + ) + end + + def entity_source_xid(entity) + query = entity_query(entity) + client = graphql_client(entity) + + response = client.execute( + client.parse(query.to_s), + { full_path: entity.source_full_path } + ).original_hash + + ::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id + rescue StandardError => e + Gitlab::Import::Logger.error( + structured_payload( + log_attributes(e, entity).merge( + message: 'Failed to fetch source entity id', + bulk_import_id: entity.bulk_import_id, + bulk_import_entity_type: entity.source_type, + importer: 'gitlab_migration' + ) + ) + ) + + nil + end + + def entity_query(entity) + if entity.group? + BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil) + else + BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil) + end + end + def retry_request(exception, entity) Gitlab::Import::Logger.error( structured_payload( - attributes.merge( - bulk_import_id: entity.bulk_import.id, - bulk_import_entity_type: entity.source_type + log_attributes(exception, entity).merge( + message: 'Retrying export request', + bulk_import_id: entity.bulk_import_id, + bulk_import_entity_type: entity.source_type, + importer: 'gitlab_migration' ) ) ) - BulkImports::Failure.create(attributes) + self.class.perform_in(2.seconds, entity.id) end end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index e171ec1e194..6d314774cff 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -19,8 +19,11 @@ module BulkImports if pipeline_tracker.present? logger.info( structured_payload( - entity_id: pipeline_tracker.entity.id, - pipeline_name: pipeline_tracker.pipeline_name + bulk_import_entity_id: pipeline_tracker.entity.id, + bulk_import_id: pipeline_tracker.entity.bulk_import_id, + pipeline_name: pipeline_tracker.pipeline_name, + message: 'Pipeline starting', + importer: 'gitlab_migration' ) ) @@ -28,9 +31,11 @@ module BulkImports else logger.error( structured_payload( - entity_id: entity_id, + bulk_import_entity_id: entity_id, + bulk_import_id: bulk_import_id(entity_id), pipeline_tracker_id: pipeline_tracker_id, - message: 'Unstarted pipeline not found' + message: 'Unstarted pipeline not found', + importer: 'gitlab_migration' ) ) end @@ -44,9 +49,10 @@ module BulkImports attr_reader :pipeline_tracker def run - raise(Entity::FailedError, 'Failed entity status') if pipeline_tracker.entity.failed? + return skip_tracker if pipeline_tracker.entity.failed? + raise(Pipeline::ExpiredError, 'Pipeline timeout') if job_timeout? - raise(Pipeline::FailedError, export_status.error) if export_failed? + raise(Pipeline::FailedError, "Export from source instance failed: #{export_status.error}") if export_failed? return re_enqueue if export_empty? || export_started? @@ -59,21 +65,29 @@ module BulkImports fail_tracker(e) end + def bulk_import_id(entity_id) + @bulk_import_id ||= Entity.find(entity_id).bulk_import_id + end + def fail_tracker(exception) pipeline_tracker.update!(status_event: 'fail_op', jid: jid) logger.error( structured_payload( - entity_id: pipeline_tracker.entity.id, + bulk_import_entity_id: pipeline_tracker.entity.id, + bulk_import_id: pipeline_tracker.entity.bulk_import_id, pipeline_name: pipeline_tracker.pipeline_name, - message: exception.message + message: exception.message, + importer: 'gitlab_migration' ) ) Gitlab::ErrorTracking.track_exception( exception, - entity_id: pipeline_tracker.entity.id, - pipeline_name: pipeline_tracker.pipeline_name + bulk_import_entity_id: pipeline_tracker.entity.id, + bulk_import_id: pipeline_tracker.entity.bulk_import_id, + pipeline_name: pipeline_tracker.pipeline_name, + importer: 'gitlab_migration' ) BulkImports::Failure.create( @@ -138,9 +152,11 @@ module BulkImports def retry_tracker(exception) logger.error( structured_payload( - entity_id: pipeline_tracker.entity.id, + bulk_import_entity_id: pipeline_tracker.entity.id, + bulk_import_id: pipeline_tracker.entity.bulk_import_id, pipeline_name: pipeline_tracker.pipeline_name, - message: "Retrying error: #{exception.message}" + message: "Retrying error: #{exception.message}", + importer: 'gitlab_migration' ) ) @@ -148,5 +164,19 @@ module BulkImports re_enqueue(exception.retry_delay) end + + def skip_tracker + logger.info( + structured_payload( + bulk_import_entity_id: pipeline_tracker.entity.id, + bulk_import_id: pipeline_tracker.entity.bulk_import_id, + pipeline_name: pipeline_tracker.pipeline_name, + message: 'Skipping pipeline due to failed entity', + importer: 'gitlab_migration' + ) + ) + + pipeline_tracker.update!(status_event: 'skip', jid: jid) + end end end diff --git a/app/workers/ci/cancel_pipeline_worker.rb b/app/workers/ci/cancel_pipeline_worker.rb index 147839a0625..2735498b6bb 100644 --- a/app/workers/ci/cancel_pipeline_worker.rb +++ b/app/workers/ci/cancel_pipeline_worker.rb @@ -10,6 +10,7 @@ module Ci idempotent! deduplicate :until_executed urgency :high + loggable_arguments 1 def perform(pipeline_id, auto_canceled_by_pipeline_id) ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| diff --git a/app/workers/ci/parse_secure_file_metadata_worker.rb b/app/workers/ci/parse_secure_file_metadata_worker.rb new file mode 100644 index 00000000000..0d2495d3155 --- /dev/null +++ b/app/workers/ci/parse_secure_file_metadata_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + class ParseSecureFileMetadataWorker + include ::ApplicationWorker + + feature_category :mobile_signing_deployment + urgency :low + idempotent! + + def perform(secure_file_id) + ::Ci::SecureFile.find_by_id(secure_file_id).try(&:update_metadata!) + end + end +end diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb index 590514424bb..2a1f492cacb 100644 --- a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb +++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb @@ -13,7 +13,9 @@ module Ci def perform(pipeline_id) ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - break unless pipeline.has_archive_artifacts? + # TODO: Move this check inside the Ci::UnlockArtifactsService + # once the feature flags in it have been removed. + break unless pipeline.has_erasable_artifacts? results = ::Ci::UnlockArtifactsService .new(pipeline.project, pipeline.user) diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb index da290eaf1f6..b71f87014aa 100644 --- a/app/workers/clusters/applications/uninstall_worker.rb +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -14,11 +14,7 @@ module Clusters worker_has_external_dependencies! loggable_arguments 0 - def perform(app_name, app_id) - find_application(app_name, app_id) do |app| - Clusters::Applications::UninstallService.new(app).execute - end - end + def perform(app_name, app_id); end end end end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index c1fec4f0196..f51c2852da6 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -2,7 +2,7 @@ require 'sidekiq/api' -Sidekiq::Worker.extend ActiveSupport::Concern +Sidekiq::Worker.extend ActiveSupport::Concern # rubocop:disable Cop/SidekiqApiUsage module ApplicationWorker extend ActiveSupport::Concern @@ -134,10 +134,6 @@ module ApplicationWorker @log_bulk_perform_async = true end - def queue_size - Sidekiq::Queue.new(queue).size - end - def bulk_perform_async(args_list) if log_bulk_perform_async? Sidekiq.logger.info('class' => self.name, 'args_list' => args_list, 'args_list_count' => args_list.length, 'message' => 'Inserting multiple jobs') @@ -177,7 +173,7 @@ module ApplicationWorker end in_safe_limit_batches(args_list, schedule_at) do |args_batch, schedule_at_for_batch| - Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch, 'at' => schedule_at_for_batch) + Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch, 'at' => schedule_at_for_batch) # rubocop:disable Cop/SidekiqApiUsage end end @@ -185,7 +181,7 @@ module ApplicationWorker def do_push_bulk(args_list) in_safe_limit_batches(args_list) do |args_batch, _| - Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch) + Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch) # rubocop:disable Cop/SidekiqApiUsage end end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index c2cd50d8c21..9793278ac0c 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -37,27 +37,22 @@ module Gitlab importer_class.new(object, project, client).execute - Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported) + if increment_object_counter?(object) + Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported) + end info(project.id, message: 'importer finished') rescue NoMethodError => e # This exception will be more useful in development when a new # Representation is created but the developer forgot to add a # `:github_identifiers` field. - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: importer_class.name, - exception: e, - fail_import: true - ) - - raise(e) + track_and_raise_exception(project, e, fail_import: true) rescue StandardError => e - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: importer_class.name, - exception: e - ) + track_and_raise_exception(project, e) + end + + def increment_object_counter?(_object) + true end def object_type @@ -90,6 +85,17 @@ module Gitlab github_identifiers: github_identifiers ) end + + def track_and_raise_exception(project, exception, fail_import: false) + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: importer_class.name, + exception: exception, + fail_import: fail_import + ) + + raise(exception) + end end end end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index b12c2311ea8..1feaaf917b2 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -63,6 +63,10 @@ module Gitlab import_stage: self.class.name ) end + + def import_settings(project) + Gitlab::GithubImport::Settings.new(project) + end end end end diff --git a/app/workers/experiments/record_conversion_event_worker.rb b/app/workers/experiments/record_conversion_event_worker.rb deleted file mode 100644 index 6487f030628..00000000000 --- a/app/workers/experiments/record_conversion_event_worker.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Experiments - class RecordConversionEventWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - feature_category :users - urgency :low - - idempotent! - - def perform(experiment, user_id) - return unless Gitlab::Experimentation.active?(experiment) - - ::Experiment.record_conversion_event(experiment, user_id) - end - end -end diff --git a/app/workers/gitlab/github_import/attachments/import_issue_worker.rb b/app/workers/gitlab/github_import/attachments/import_issue_worker.rb new file mode 100644 index 00000000000..1a9fa15b850 --- /dev/null +++ b/app/workers/gitlab/github_import/attachments/import_issue_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Attachments + class ImportIssueWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Representation::NoteText + end + + def importer_class + Importer::NoteAttachmentsImporter + end + + def object_type + :issue_attachment + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/attachments/import_merge_request_worker.rb b/app/workers/gitlab/github_import/attachments/import_merge_request_worker.rb new file mode 100644 index 00000000000..a86852b094f --- /dev/null +++ b/app/workers/gitlab/github_import/attachments/import_merge_request_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Attachments + class ImportMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Representation::NoteText + end + + def importer_class + Importer::NoteAttachmentsImporter + end + + def object_type + :merge_request_attachment + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/attachments/import_note_worker.rb b/app/workers/gitlab/github_import/attachments/import_note_worker.rb new file mode 100644 index 00000000000..2f5bc50ee0b --- /dev/null +++ b/app/workers/gitlab/github_import/attachments/import_note_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Attachments + class ImportNoteWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Representation::NoteText + end + + def importer_class + Importer::NoteAttachmentsImporter + end + + def object_type + :note_attachment + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/attachments/import_release_worker.rb b/app/workers/gitlab/github_import/attachments/import_release_worker.rb new file mode 100644 index 00000000000..5eea5702d3c --- /dev/null +++ b/app/workers/gitlab/github_import/attachments/import_release_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Attachments + class ImportReleaseWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Representation::NoteText + end + + def importer_class + Importer::NoteAttachmentsImporter + end + + def object_type + :release_attachment + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_issue_worker.rb b/app/workers/gitlab/github_import/import_issue_worker.rb index 8fdc0219ffd..7d6a28f4a96 100644 --- a/app/workers/gitlab/github_import/import_issue_worker.rb +++ b/app/workers/gitlab/github_import/import_issue_worker.rb @@ -16,6 +16,10 @@ module Gitlab def object_type :issue end + + def increment_object_counter?(object) + !object.pull_request? + end end end end diff --git a/app/workers/gitlab/github_import/import_release_attachments_worker.rb b/app/workers/gitlab/github_import/import_release_attachments_worker.rb index c6f45ec1d7c..bf901f2f7b8 100644 --- a/app/workers/gitlab/github_import/import_release_attachments_worker.rb +++ b/app/workers/gitlab/github_import/import_release_attachments_worker.rb @@ -1,16 +1,18 @@ # frozen_string_literal: true +# TODO: remove in 16.X milestone +# https://gitlab.com/gitlab-org/gitlab/-/issues/377059 module Gitlab module GithubImport class ImportReleaseAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker include ObjectImporter def representation_class - Representation::ReleaseAttachments + Representation::NoteText end def importer_class - Importer::ReleaseAttachmentsImporter + Importer::NoteAttachmentsImporter end def object_type diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb index e9086dca503..e4a413b4081 100644 --- a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb @@ -28,7 +28,12 @@ module Gitlab # For future issue/mr/milestone/etc attachments importers def importers - [::Gitlab::GithubImport::Importer::ReleasesAttachmentsImporter] + [ + ::Gitlab::GithubImport::Importer::Attachments::ReleasesImporter, + ::Gitlab::GithubImport::Importer::Attachments::NotesImporter, + ::Gitlab::GithubImport::Importer::Attachments::IssuesImporter, + ::Gitlab::GithubImport::Importer::Attachments::MergeRequestsImporter + ] end def start_importer(project, importer, client) @@ -50,7 +55,7 @@ module Gitlab end def feature_disabled?(project) - Feature.disabled?(:github_importer_attachments_import, project.group, type: :ops) + import_settings(project).disabled?(:attachments_import) end end end diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb index 0ec0a1b58d2..54ed4c47e78 100644 --- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -15,9 +15,9 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) - importer = importer_class(project) - return skip_to_next_stage(project) if importer.nil? + return skip_to_next_stage(project) if import_settings(project).disabled?(:single_endpoint_issue_events_import) + importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter info(project.id, message: "starting importer", importer: importer.name) waiter = importer.new(project, client).execute move_to_next_stage(project, { waiter.key => waiter.jobs_remaining }) @@ -25,16 +25,6 @@ module Gitlab private - def importer_class(project) - if Feature.enabled?(:github_importer_single_endpoint_issue_events_import, project.group, type: :ops) - ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter - elsif Feature.enabled?(:github_importer_issue_events_import, project.group, type: :ops) - ::Gitlab::GithubImport::Importer::IssueEventsImporter - else - nil - end - end - def skip_to_next_stage(project) info(project.id, message: "skipping importer", importer: "IssueEventsImporter") move_to_next_stage(project) diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb index 7922c1113c4..3d1a8437da2 100644 --- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -36,7 +36,7 @@ module Gitlab private def diff_notes_importer(project) - if project.group.present? && Feature.enabled?(:github_importer_single_endpoint_notes_import, project.group, type: :ops) + if import_settings(project).enabled?(:single_endpoint_notes_import) Importer::SingleEndpointDiffNotesImporter else Importer::DiffNotesImporter diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index b53e31ce40e..40ca12b130f 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -25,7 +25,7 @@ module Gitlab end def importers(project) - if project.group.present? && Feature.enabled?(:github_importer_single_endpoint_notes_import, project.group, type: :ops) + if import_settings(project).enabled?(:single_endpoint_notes_import) [ Importer::SingleEndpointMergeRequestNotesImporter, Importer::SingleEndpointIssueNotesImporter diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index 3e914cc7590..8c1a2cd2677 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -26,6 +26,11 @@ module Gitlab RefreshImportJidWorker.perform_in_the_future(project.id, jid) info(project.id, message: "starting importer", importer: 'Importer::RepositoryImporter') + + # If a user creates an issue while the import is in progress, this can lead to an import failure. + # The workaround is to allocate IIDs before starting the importer. + allocate_issues_internal_id!(project, client) + importer = Importer::RepositoryImporter.new(project, client) importer.execute @@ -56,6 +61,19 @@ module Gitlab def abort_on_failure true end + + private + + def allocate_issues_internal_id!(project, client) + return if InternalId.exists?(project: project, usage: :issues) # rubocop: disable CodeReuse/ActiveRecord + + options = { state: 'all', sort: 'number', direction: 'desc', per_page: '1' } + last_github_issue = client.each_object(:issues, project.import_source, options).first + + return unless last_github_issue + + Issue.track_project_iid!(project, last_github_issue[:number]) + end end end end diff --git a/app/workers/incident_management/pager_duty/process_incident_worker.rb b/app/workers/incident_management/pager_duty/process_incident_worker.rb index 933d8e12d25..181e336e6b0 100644 --- a/app/workers/incident_management/pager_duty/process_incident_worker.rb +++ b/app/workers/incident_management/pager_duty/process_incident_worker.rb @@ -38,7 +38,7 @@ module IncidentManagement def log_error(result) Gitlab::AppLogger.warn( message: 'Cannot create issue for PagerDuty incident', - issue_errors: result.message + issue_errors: result.errors.join(', ') ) end end diff --git a/app/workers/incident_management/process_alert_worker_v2.rb b/app/workers/incident_management/process_alert_worker_v2.rb index f3049560bcd..04c02d17704 100644 --- a/app/workers/incident_management/process_alert_worker_v2.rb +++ b/app/workers/incident_management/process_alert_worker_v2.rb @@ -37,13 +37,13 @@ module IncidentManagement end def log_warning(alert, result) - issue_id = result.payload[:issue]&.id + issue_id = result[:issue]&.id Gitlab::AppLogger.warn( message: 'Cannot process an Incident', issue_id: issue_id, alert_id: alert.id, - errors: result.message + errors: result.errors.join(', ') ) end end diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb index 69bd3949e9d..66392c670b5 100644 --- a/app/workers/merge_requests/delete_source_branch_worker.rb +++ b/app/workers/merge_requests/delete_source_branch_worker.rb @@ -18,9 +18,13 @@ class MergeRequests::DeleteSourceBranchWorker # Source branch changed while it's being removed return if merge_request.source_branch_sha != source_branch_sha - ::Branches::DeleteService.new(merge_request.source_project, user) + delete_service_result = ::Branches::DeleteService.new(merge_request.source_project, user) .execute(merge_request.source_branch) + if Feature.enabled?(:track_delete_source_errors, merge_request.source_project) + delete_service_result.track_exception if delete_service_result&.error? + end + ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user) .execute(merge_request) rescue ActiveRecord::RecordNotFound diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/onboarding/issue_created_worker.rb index 4f0cc71cd91..ff39fefad81 100644 --- a/app/workers/namespaces/onboarding_issue_created_worker.rb +++ b/app/workers/onboarding/issue_created_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -module Namespaces - class OnboardingIssueCreatedWorker +module Onboarding + class IssueCreatedWorker include ApplicationWorker data_consistency :always @@ -22,3 +22,6 @@ module Namespaces end end end + +# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432 +Namespaces::OnboardingIssueCreatedWorker = Onboarding::IssueCreatedWorker diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/onboarding/pipeline_created_worker.rb index c3850880df0..6bd5863b0e0 100644 --- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb +++ b/app/workers/onboarding/pipeline_created_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -module Namespaces - class OnboardingPipelineCreatedWorker +module Onboarding + class PipelineCreatedWorker include ApplicationWorker data_consistency :always @@ -22,3 +22,6 @@ module Namespaces end end end + +# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432 +Namespaces::OnboardingPipelineCreatedWorker = Onboarding::PipelineCreatedWorker diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/onboarding/progress_worker.rb index 49629428459..525934c4a7c 100644 --- a/app/workers/namespaces/onboarding_progress_worker.rb +++ b/app/workers/onboarding/progress_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -module Namespaces - class OnboardingProgressWorker +module Onboarding + class ProgressWorker include ApplicationWorker data_consistency :always @@ -23,3 +23,6 @@ module Namespaces end end end + +# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432 +Namespaces::OnboardingProgressWorker = Onboarding::ProgressWorker diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/onboarding/user_added_worker.rb index a1b349eedd3..38e9cd063ea 100644 --- a/app/workers/namespaces/onboarding_user_added_worker.rb +++ b/app/workers/onboarding/user_added_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -module Namespaces - class OnboardingUserAddedWorker +module Onboarding + class UserAddedWorker include ApplicationWorker data_consistency :always @@ -19,3 +19,6 @@ module Namespaces end end end + +# remove in %15.6 as per https://gitlab.com/gitlab-org/gitlab/-/issues/372432 +Namespaces::OnboardingUserAddedWorker = Onboarding::UserAddedWorker diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index cd6ce6eb28b..708dd3433cb 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -51,23 +51,13 @@ class ProcessCommitWorker end def close_issues(project, user, author, commit, issues) - if Feature.enabled?(:process_issue_closure_in_background, project) - Issues::CloseWorker.bulk_perform_async_with_contexts( - issues, - arguments_proc: -> (issue) { - [project.id, issue.id, issue.class.to_s, { closed_by: author.id, commit_hash: commit.to_hash }] - }, - context_proc: -> (issue) { { project: project } } - ) - else - # We don't want to run permission related queries for every single issue, - # therefore we use IssueCollection here and skip the authorization check in - # Issues::CloseService#execute. - IssueCollection.new(issues).updatable_by_user(user).each do |issue| - Issues::CloseService.new(project: project, current_user: author) - .close_issue(issue, closed_via: commit) - end - end + Issues::CloseWorker.bulk_perform_async_with_contexts( + issues, + arguments_proc: -> (issue) { + [project.id, issue.id, issue.class.to_s, { closed_by: author.id, commit_hash: commit.to_hash }] + }, + context_proc: -> (issue) { { project: project } } + ) end def issues_to_close(project, commit, user) |